lib/iso_20022/camt/053/parser.ex

defmodule ISO20022.Camt053.Parser do
  @moduledoc false
  # Internal parser for camt.053 messages using Saxy.SimpleForm.
  #
  # Saxy returns a simple-form tree: {tag, attrs, children}
  # where children is a list of either {tag, attrs, children} or binary text nodes.
  #
  # Tag names in Saxy simple form include the namespace prefix when present
  # in the source XML, but the actual element name (local part) is what we
  # pattern match on after stripping any prefix.

  alias ISO20022.Camt053.{
    Account,
    Balance,
    BankTxCode,
    Document,
    Entry,
    EntryDetails,
    GroupHeader,
    Statement,
    TransactionDetails
  }

  @supported_namespaces ~w(
    urn:iso:std:iso:20022:tech:xsd:camt.053.001.02
    urn:iso:std:iso:20022:tech:xsd:camt.053.001.03
    urn:iso:std:iso:20022:tech:xsd:camt.053.001.04
    urn:iso:std:iso:20022:tech:xsd:camt.053.001.06
    urn:iso:std:iso:20022:tech:xsd:camt.053.001.08
    urn:iso:std:iso:20022:tech:xsd:camt.053.001.11
    urn:iso:std:iso:20022:tech:xsd:camt.053.001.12
    urn:iso:std:iso:20022:tech:xsd:camt.053.001.13
    urn:iso:std:iso:20022:tech:xsd:camt.053.001.14
  )

  @doc """
  Parses raw XML into a `%Document{}`. Returns `{:ok, doc}` or `{:error, reason}`.
  """
  @spec parse(binary()) :: {:ok, Document.t()} | {:error, term()}
  def parse(xml) when is_binary(xml) do
    case Saxy.SimpleForm.parse_string(xml, []) do
      {:ok, root} ->
        with :ok <- validate_namespace(root),
             {:ok, doc} <- build_document(root) do
          {:ok, doc}
        end

      {:error, reason} ->
        {:error, {:parse_error, reason}}
    end
  end

  # ---------------------------------------------------------------------------
  # Namespace validation
  # ---------------------------------------------------------------------------

  defp validate_namespace({_tag, attrs, _children}) do
    ns = find_attr(attrs, "xmlns")

    cond do
      is_nil(ns) ->
        # Accept documents without a namespace (some older senders omit it)
        :ok

      ns in @supported_namespaces ->
        :ok

      String.contains?(ns, "camt.053") ->
        # Unknown minor version — attempt to parse anyway
        :ok

      true ->
        {:error, {:unsupported_version, ns}}
    end
  end

  # ---------------------------------------------------------------------------
  # Document
  # ---------------------------------------------------------------------------

  defp build_document({_tag, _attrs, children}) do
    # The root is <Document>; the payload is <BkToCstmrStmt>
    case find_child(children, "BkToCstmrStmt") do
      nil ->
        {:error, {:missing_required_field, [:bk_to_cstmr_stmt]}}

      {_, _, stmt_children} ->
        with {:ok, group_header} <- parse_group_header(stmt_children),
             {:ok, statements} <- parse_statements(stmt_children) do
          {:ok, %Document{group_header: group_header, statements: statements}}
        end
    end
  end

  # ---------------------------------------------------------------------------
  # GroupHeader
  # ---------------------------------------------------------------------------

  defp parse_group_header(children) do
    case find_child(children, "GrpHdr") do
      nil ->
        {:error, {:missing_required_field, [:group_header]}}

      {_, _, hdr_children} ->
        with {:ok, message_id} <- require_text(hdr_children, "MsgId", [:group_header, :message_id]),
             {:ok, created_at} <- require_datetime(hdr_children, "CreDtTm", [:group_header, :created_at]) do
          pagination = parse_pagination(hdr_children)

          {:ok,
           %GroupHeader{
             message_id: message_id,
             created_at: created_at,
             pagination: pagination
           }}
        end
    end
  end

  defp parse_pagination(children) do
    case find_child(children, "MsgPgntn") do
      nil ->
        nil

      {_, _, pg_children} ->
        %{
          page_number: get_text(pg_children, "PgNb"),
          last_page: get_text(pg_children, "LastPgInd") == "true"
        }
    end
  end

  # ---------------------------------------------------------------------------
  # Statements
  # ---------------------------------------------------------------------------

  defp parse_statements(children) do
    stmts =
      children
      |> Enum.filter(&match_tag?(&1, "Stmt"))
      |> Enum.with_index()
      |> Enum.reduce_while({:ok, []}, fn {{_, _, stmt_children}, idx}, {:ok, acc} ->
        case parse_statement(stmt_children, idx) do
          {:ok, stmt} -> {:cont, {:ok, [stmt | acc]}}
          {:error, _} = err -> {:halt, err}
        end
      end)

    case stmts do
      {:ok, list} -> {:ok, Enum.reverse(list)}
      err -> err
    end
  end

  defp parse_statement(children, idx) do
    with {:ok, id} <- require_text(children, "Id", [:statements, idx, :id]),
         {:ok, account} <- parse_account(children, idx),
         {:ok, balances} <- parse_balances(children, idx),
         {:ok, entries} <- parse_entries(children, idx) do
      {:ok,
       %Statement{
         id: id,
         electronic_seq_number: get_integer(children, "ElctrncSeqNb"),
         created_at: get_datetime(children, "CreDtTm"),
         from_to_date: parse_from_to_date(children),
         account: account,
         balances: balances,
         entries: entries
       }}
    end
  end

  defp parse_from_to_date(children) do
    case find_child(children, "FrToDt") do
      nil ->
        nil

      {_, _, dt_children} ->
        %{
          from: get_datetime(dt_children, "FrDtTm"),
          to: get_datetime(dt_children, "ToDtTm")
        }
    end
  end

  # ---------------------------------------------------------------------------
  # Account
  # ---------------------------------------------------------------------------

  defp parse_account(children, idx) do
    case find_child(children, "Acct") do
      nil ->
        {:error, {:missing_required_field, [:statements, idx, :account]}}

      {_, _, acct_children} ->
        id_children =
          case find_child(acct_children, "Id") do
            nil -> []
            {_, _, ch} -> ch
          end

        svcr_bic =
          case find_child(acct_children, "Svcr") do
            nil -> nil
            {_, _, svcr_ch} -> get_nested_text(svcr_ch, ["FinInstnId", "BIC"])
          end

        {:ok,
         %Account{
           iban: get_text(id_children, "IBAN"),
           other_id: get_nested_text(id_children, ["Othr", "Id"]),
           other_scheme: get_nested_text(id_children, ["Othr", "SchmeNm", "Cd"]),
           currency: get_text(acct_children, "Ccy"),
           servicer_bic: svcr_bic,
           name: get_text(acct_children, "Nm")
         }}
    end
  end

  # ---------------------------------------------------------------------------
  # Balances
  # ---------------------------------------------------------------------------

  defp parse_balances(children, idx) do
    bals =
      children
      |> Enum.filter(&match_tag?(&1, "Bal"))
      |> Enum.with_index()
      |> Enum.reduce_while({:ok, []}, fn {{_, _, bal_children}, bal_idx}, {:ok, acc} ->
        case parse_balance(bal_children, idx, bal_idx) do
          {:ok, bal} -> {:cont, {:ok, [bal | acc]}}
          {:error, _} = err -> {:halt, err}
        end
      end)

    case bals do
      {:ok, list} -> {:ok, Enum.reverse(list)}
      err -> err
    end
  end

  defp parse_balance(children, stmt_idx, bal_idx) do
    path = [:statements, stmt_idx, :balances, bal_idx]

    type_code =
      children
      |> find_child("Tp")
      |> then(fn
        nil -> nil
        {_, _, tp_ch} -> get_text(tp_ch, "CdOrPrtry") || get_nested_text(tp_ch, ["CdOrPrtry", "Cd"])
      end)

    with {:ok, {amount, currency}} <- require_amount(children, "Amt", path),
         {:ok, cdt_dbt} <- require_cdt_dbt(children, path),
         {:ok, date} <- require_date_from_dt(children, path) do
      {:ok,
       %Balance{
         type: Balance.parse_type(type_code || ""),
         amount: amount,
         currency: currency,
         credit_debit: cdt_dbt,
         date: date
       }}
    end
  end

  # camt.053 wraps the balance date in <Dt><Dt> or <Dt><DtTm>
  defp require_date_from_dt(children, path) do
    case find_child(children, "Dt") do
      nil ->
        {:error, {:missing_required_field, path ++ [:date]}}

      {_, _, dt_children} ->
        cond do
          (v = get_text(dt_children, "Dt")) != nil ->
            parse_date(v)

          (v = get_text(dt_children, "DtTm")) != nil ->
            case DateTime.from_iso8601(v) do
              {:ok, dt, _} -> {:ok, DateTime.to_date(dt)}
              _ -> {:error, {:invalid_date, v}}
            end

          true ->
            {:error, {:missing_required_field, path ++ [:date]}}
        end
    end
  end

  # ---------------------------------------------------------------------------
  # Entries
  # ---------------------------------------------------------------------------

  defp parse_entries(children, stmt_idx) do
    entries =
      children
      |> Enum.filter(&match_tag?(&1, "Ntry"))
      |> Enum.with_index()
      |> Enum.reduce_while({:ok, []}, fn {{_, _, ntry_children}, entry_idx}, {:ok, acc} ->
        case parse_entry(ntry_children, stmt_idx, entry_idx) do
          {:ok, entry} -> {:cont, {:ok, [entry | acc]}}
          {:error, _} = err -> {:halt, err}
        end
      end)

    case entries do
      {:ok, list} -> {:ok, Enum.reverse(list)}
      err -> err
    end
  end

  defp parse_entry(children, stmt_idx, entry_idx) do
    path = [:statements, stmt_idx, :entries, entry_idx]

    with {:ok, ref} <- require_text(children, "NtryRef", path ++ [:ref]),
         {:ok, {amount, currency}} <- require_amount(children, "Amt", path),
         {:ok, cdt_dbt} <- require_cdt_dbt(children, path) do
      reversal = get_text(children, "RvslInd") == "true"
      bank_tx_code = parse_bank_tx_code(children)
      details = parse_entry_details_list(children)

      {:ok,
       %Entry{
         ref: ref,
         amount: amount,
         currency: currency,
         credit_debit: cdt_dbt,
         reversal: reversal,
         status: :booked,
         booking_date: get_date_nested(children, "BookgDt"),
         value_date: get_date_nested(children, "ValDt"),
         account_servicer_ref: get_text(children, "AcctSvcrRef"),
         bank_transaction_code: bank_tx_code,
         additional_info: get_text(children, "AddtlNtryInf"),
         details: details
       }}
    end
  end

  defp get_date_nested(children, tag) do
    case find_child(children, tag) do
      nil -> nil
      {_, _, dt_ch} ->
        cond do
          (v = get_text(dt_ch, "Dt")) != nil ->
            case parse_date(v) do
              {:ok, d} -> d
              _ -> nil
            end

          (v = get_text(dt_ch, "DtTm")) != nil ->
            case DateTime.from_iso8601(v) do
              {:ok, dt, _} -> DateTime.to_date(dt)
              _ -> nil
            end

          true -> nil
        end
    end
  end

  defp parse_bank_tx_code(children) do
    case find_child(children, "BkTxCd") do
      nil ->
        nil

      {_, _, btc_children} ->
        {domain, family, sub_family} =
          case find_child(btc_children, "Domn") do
            nil ->
              {nil, nil, nil}

            {_, _, domn_ch} ->
              d = get_text(domn_ch, "Cd")

              {fam, sub} =
                case find_child(domn_ch, "Fmly") do
                  nil -> {nil, nil}
                  {_, _, fam_ch} -> {get_text(fam_ch, "Cd"), get_text(fam_ch, "SubFmlyCd")}
                end

              {d, fam, sub}
          end

        {prop_code, prop_issuer} =
          case find_child(btc_children, "Prtry") do
            nil -> {nil, nil}
            {_, _, p_ch} -> {get_text(p_ch, "Cd"), get_text(p_ch, "Issr")}
          end

        %BankTxCode{
          domain: domain,
          family: family,
          sub_family: sub_family,
          proprietary_code: prop_code,
          proprietary_issuer: prop_issuer
        }
    end
  end

  # ---------------------------------------------------------------------------
  # Entry Details
  # ---------------------------------------------------------------------------

  defp parse_entry_details_list(children) do
    children
    |> Enum.filter(&match_tag?(&1, "NtryDtls"))
    |> Enum.map(fn {_, _, nd_children} -> parse_entry_details(nd_children) end)
  end

  defp parse_entry_details(children) do
    batch = parse_batch(children)

    tx_details =
      children
      |> Enum.filter(&match_tag?(&1, "TxDtls"))
      |> Enum.map(fn {_, _, tx_children} -> parse_transaction_details(tx_children) end)

    %EntryDetails{batch: batch, transaction_details: tx_details}
  end

  defp parse_batch(children) do
    case find_child(children, "Btch") do
      nil ->
        nil

      {_, _, btch_ch} ->
        total =
          case get_text(btch_ch, "TtlAmt") do
            nil -> nil
            v -> parse_decimal(v)
          end

        nb =
          case get_text(btch_ch, "NbOfTxs") do
            nil -> nil
            v -> String.to_integer(v)
          end

        %{
          message_id: get_text(btch_ch, "MsgId"),
          payment_info_id: get_text(btch_ch, "PmtInfId"),
          number_of_transactions: nb,
          total_amount: total
        }
    end
  end

  # ---------------------------------------------------------------------------
  # Transaction Details
  # ---------------------------------------------------------------------------

  defp parse_transaction_details(children) do
    {amount, currency} = parse_amount_optional(children, "Amt")

    %TransactionDetails{
      refs: parse_tx_refs(children),
      amount: amount,
      currency: currency,
      credit_debit: parse_cdt_dbt_optional(children),
      related_parties: parse_related_parties(children),
      related_agents: parse_related_agents(children),
      remittance_info: parse_remittance_info(children),
      purpose: get_nested_text(children, ["Purp", "Cd"])
    }
  end

  defp parse_tx_refs(children) do
    case find_child(children, "Refs") do
      nil ->
        nil

      {_, _, ref_ch} ->
        %{
          message_id: get_text(ref_ch, "MsgId"),
          account_servicer_ref: get_text(ref_ch, "AcctSvcrRef"),
          payment_info_id: get_text(ref_ch, "PmtInfId"),
          instruction_id: get_text(ref_ch, "InstrId"),
          end_to_end_id: get_text(ref_ch, "EndToEndId"),
          tx_id: get_text(ref_ch, "TxId"),
          mandate_id: get_text(ref_ch, "MndtId"),
          uetr: get_text(ref_ch, "UETR")
        }
    end
  end

  defp parse_related_parties(children) do
    case find_child(children, "RltdPties") do
      nil ->
        nil

      {_, _, rp_ch} ->
        %{
          debtor: parse_party_with_account(rp_ch, "Dbtr", "DbtrAcct"),
          creditor: parse_party_with_account(rp_ch, "Cdtr", "CdtrAcct"),
          ultimate_debtor: parse_party(rp_ch, "UltmtDbtr"),
          ultimate_creditor: parse_party(rp_ch, "UltmtCdtr")
        }
    end
  end

  defp parse_party(children, tag) do
    case find_child(children, tag) do
      nil -> nil
      {_, _, party_ch} -> %{name: get_text(party_ch, "Nm")}
    end
  end

  defp parse_party_with_account(children, party_tag, acct_tag) do
    party = parse_party(children, party_tag)
    account_id = parse_account_id(children, acct_tag)
    if party || account_id, do: Map.merge(party || %{}, account_id || %{}), else: nil
  end

  defp parse_account_id(children, tag) do
    case find_child(children, tag) do
      nil ->
        nil

      {_, _, acct_ch} ->
        id_children =
          case find_child(acct_ch, "Id") do
            nil -> []
            {_, _, ch} -> ch
          end

        %{
          iban: get_text(id_children, "IBAN"),
          other_id: get_nested_text(id_children, ["Othr", "Id"])
        }
    end
  end

  defp parse_related_agents(children) do
    case find_child(children, "RltdAgts") do
      nil ->
        nil

      {_, _, ra_ch} ->
        %{
          debtor_agent: parse_agent(ra_ch, "DbtrAgt"),
          creditor_agent: parse_agent(ra_ch, "CdtrAgt")
        }
    end
  end

  defp parse_agent(children, tag) do
    case find_child(children, tag) do
      nil ->
        nil

      {_, _, agt_ch} ->
        case find_child(agt_ch, "FinInstnId") do
          nil ->
            nil

          {_, _, fi_ch} ->
            %{
              bic: get_text(fi_ch, "BIC") || get_text(fi_ch, "BICFI"),
              name: get_text(fi_ch, "Nm")
            }
        end
    end
  end

  defp parse_remittance_info(children) do
    case find_child(children, "RmtInf") do
      nil ->
        nil

      {_, _, rmt_ch} ->
        cond do
          (text = get_text(rmt_ch, "Ustrd")) != nil ->
            {:unstructured, text}

          (strd = find_child(rmt_ch, "Strd")) != nil ->
            {_, _, strd_ch} = strd
            {:structured, parse_structured_remittance(strd_ch)}

          true ->
            nil
        end
    end
  end

  defp parse_structured_remittance(children) do
    cdtr_ref_inf = find_child(children, "CdtrRefInf")

    {ref, ref_type} =
      case cdtr_ref_inf do
        nil ->
          {nil, nil}

        {_, _, cri_ch} ->
          r = get_text(cri_ch, "Ref")
          rt = get_nested_text(cri_ch, ["Tp", "CdOrPrtry", "Cd"])
          {r, rt}
      end

    %{
      ref: ref,
      ref_type: ref_type,
      creditor_ref: get_nested_text(children, ["AddtlRmtInf"])
    }
  end

  # ---------------------------------------------------------------------------
  # Low-level XML helpers
  # ---------------------------------------------------------------------------

  # Returns the first child element whose local tag name matches.
  # Handles both bare tag names ("MsgId") and namespace-prefixed ones ("ns2:MsgId").
  @spec find_child([term()], String.t()) :: {String.t(), list(), list()} | nil
  defp find_child(children, local_name) do
    Enum.find(children, fn
      {tag, _attrs, _ch} -> local_name(tag) == local_name
      _ -> false
    end)
  end

  defp match_tag?(node, local_name) do
    case node do
      {tag, _attrs, _ch} -> local_name(tag) == local_name
      _ -> false
    end
  end

  # Strip namespace prefix from a tag: "ns2:Stmt" -> "Stmt", "Stmt" -> "Stmt"
  defp local_name(tag) when is_binary(tag) do
    case String.split(tag, ":", parts: 2) do
      [_prefix, local] -> local
      [local] -> local
    end
  end

  # Returns trimmed text content from the first matching child, or nil.
  defp get_text(children, local_name) do
    case find_child(children, local_name) do
      nil -> nil
      {_, _, text_children} -> collect_text(text_children)
    end
  end

  # Traverses a path of tags to extract text. E.g. ["Othr", "Id"] walks <Othr><Id>.
  defp get_nested_text(_children, []), do: nil

  defp get_nested_text(children, [tag]) do
    get_text(children, tag)
  end

  defp get_nested_text(children, [tag | rest]) do
    case find_child(children, tag) do
      nil -> nil
      {_, _, ch} -> get_nested_text(ch, rest)
    end
  end

  defp collect_text(children) do
    result =
      children
      |> Enum.filter(&is_binary/1)
      |> Enum.join()
      |> String.trim()

    if result == "", do: nil, else: result
  end

  defp find_attr(attrs, name) do
    Enum.find_value(attrs, fn
      {^name, val} -> val
      _ -> nil
    end)
  end

  # ---------------------------------------------------------------------------
  # Typed field extraction helpers
  # ---------------------------------------------------------------------------

  defp require_text(children, tag, path) do
    case get_text(children, tag) do
      nil -> {:error, {:missing_required_field, path}}
      v -> {:ok, v}
    end
  end

  defp require_datetime(children, tag, path) do
    case get_text(children, tag) do
      nil ->
        {:error, {:missing_required_field, path}}

      v ->
        case DateTime.from_iso8601(v) do
          {:ok, dt, _} -> {:ok, dt}
          _ -> {:error, {:invalid_datetime, v, path}}
        end
    end
  end

  defp get_datetime(children, tag) do
    case get_text(children, tag) do
      nil -> nil
      v ->
        case DateTime.from_iso8601(v) do
          {:ok, dt, _} -> dt
          _ -> nil
        end
    end
  end

  defp get_integer(children, tag) do
    case get_text(children, tag) do
      nil -> nil
      v ->
        case Integer.parse(v) do
          {n, _} -> n
          :error -> nil
        end
    end
  end

  defp parse_date(v) do
    case Date.from_iso8601(v) do
      {:ok, d} -> {:ok, d}
      _ -> {:error, {:invalid_date, v}}
    end
  end

  # Parses <Amt Ccy="EUR">100.00</Amt> or just <Amt>100.00</Amt>.
  # Returns {:ok, {decimal, currency_string}} or error.
  defp require_amount(children, tag, path) do
    case find_child(children, tag) do
      nil ->
        {:error, {:missing_required_field, path ++ [:amount]}}

      {_, attrs, text_children} ->
        text = collect_text(text_children) || ""
        ccy = find_attr(attrs, "Ccy")

        case parse_decimal(text) do
          nil -> {:error, {:invalid_amount, text, path}}
          dec -> {:ok, {dec, ccy}}
        end
    end
  end

  defp parse_amount_optional(children, tag) do
    case find_child(children, tag) do
      nil ->
        {nil, nil}

      {_, attrs, text_children} ->
        text = collect_text(text_children) || ""
        ccy = find_attr(attrs, "Ccy")
        {parse_decimal(text), ccy}
    end
  end

  defp parse_decimal(text) when is_binary(text) do
    case Decimal.parse(String.trim(text)) do
      {dec, ""} -> dec
      _ -> nil
    end
  end

  defp parse_decimal(_), do: nil

  defp require_cdt_dbt(children, path) do
    case get_text(children, "CdtDbtInd") do
      nil -> {:error, {:missing_required_field, path ++ [:credit_debit]}}
      v -> {:ok, parse_cdt_dbt(v)}
    end
  end

  defp parse_cdt_dbt_optional(children) do
    case get_text(children, "CdtDbtInd") do
      nil -> nil
      v -> parse_cdt_dbt(v)
    end
  end

  defp parse_cdt_dbt("CRDT"), do: :credit
  defp parse_cdt_dbt("DBIT"), do: :debit
  # Some senders use lowercase
  defp parse_cdt_dbt(v), do: if(String.upcase(v) == "CRDT", do: :credit, else: :debit)
end