lib/sat/csf.ex

defmodule Sat.Csf do
  @moduledoc """
  Extrae datos estructurados de una **Constancia de Situación Fiscal (CSF)**
  emitida por el SAT a partir de su PDF.

  ## Uso

      {:ok, %Sat.Csf.Document{} = csf} = Sat.Csf.from_file("priv/csf.pdf")

      csf.identificacion.rfc
      #=> "MACA961017759"

      csf.regimenes
      #=> [%Sat.Csf.Regimen{regimen: "...", codigo: "626", ...}, ...]

  Bajo el capó usa `Pdf.Reader.read/2` con `dictionary: :es` para reconstruir
  el texto correctamente. El parser de la respuesta (`Sat.Csf.Parser`) usa
  posiciones X de tokens para separar columnas en las tablas de obligaciones.

  ## Errores

  - `{:error, :not_a_csf}` — el PDF no contiene los marcadores de sección
    de un CSF (no se detectó "Datos de Identificación del Contribuyente").
  - cualquier error reportado por `Pdf.Reader.open/2` o `Pdf.Reader.read/2`.
  """

  alias Sat.Csf.{Document, Parser}

  @type from_result :: {:ok, Document.t()} | {:error, term()}

  @default_read_opts [dictionary: :es]

  @doc """
  Lee y parsea un CSF desde una ruta de archivo.

  Las opciones se pasan a `Pdf.Reader.read/2`. Por defecto se incluye
  `dictionary: :es` para que el extractor separe palabras pegadas (puedes
  sobrescribirlo pasando `dictionary: nil` o tu propia `MapSet`).
  """
  @spec from_file(Path.t(), keyword()) :: from_result()
  def from_file(path, opts \\ []) when is_binary(path) do
    open_and_parse(path, opts)
  end

  @doc """
  Lee y parsea un CSF desde un binario `.pdf` ya cargado en memoria.
  """
  @spec from_binary(binary(), keyword()) :: from_result()
  def from_binary(<<"%PDF-", _::binary>> = bin, opts \\ []) do
    open_and_parse(bin, opts)
  end

  @doc """
  Parsea un `%Pdf.Reader.Result{}` ya extraído. Útil si ya estás trabajando
  con el reader y quieres evitar reabrir el PDF.
  """
  @spec from_result(Pdf.Reader.Result.t()) :: from_result()
  def from_result(%Pdf.Reader.Result{} = result) do
    Parser.parse(result)
  end

  defp open_and_parse(path_or_bin, opts) do
    read_opts = Keyword.merge(@default_read_opts, opts)

    with {:ok, doc} <- Pdf.Reader.open(path_or_bin),
         {:ok, result, _doc} <- Pdf.Reader.read(doc, read_opts) do
      Parser.parse(result)
    end
  end

  @doc false
  def version, do: Application.spec(:sat_csf, :vsn)
end