"""ERP_ACCOUNTING_V1 — the typed accounting binding carried in PAC-RO `facts` (spec v0.2).

The binding lets the third entry deterministically reconcile an ERP's double-entry posting:
one binding = one whole ERP document (`neoos-stack/proposals/ERP_ACCOUNTING_BINDING_v0_2.md`).
It rides INSIDE `facts` as `facts["accounting_binding"]` with a `binding_schema` self-tag — it is
NOT a `body_schema_ref` (that is the PAC-RO identity and must stay `PAC_RO_V1`).

Fail-closed: a present-but-invalid binding rejects the receipt; an absent binding changes nothing.
Amounts are canonical decimal STRINGS validated per ISO-4217 minor-units (direction lives in
`erp_doc_type`, never in the number); the minor-units table is PINNED here (versioned, tested),
never a runtime lookup. A8: a NATURAL_PERSON counterparty is never inline — commitment only.
"""

from __future__ import annotations

import re

ERP_ACCOUNTING_V1 = "ERP_ACCOUNTING_V1"

# Pinned ISO-4217 minor-units table (spec v0.2 §3 + K5 DoD: versioned constant, never a runtime
# lookup — the amount byte-lock depends on it). Unknown currency = fail-closed reject.
# Source snapshot: ISO 4217 amendment level as of 2026-07 (curated set; extend by PR, bump version).
MINOR_UNITS_VERSION = "ISO4217-2026-07.v1"
MINOR_UNITS = {
    # 2 minor units (the default class)
    "EUR": 2, "USD": 2, "GBP": 2, "CHF": 2, "SEK": 2, "NOK": 2, "DKK": 2, "PLN": 2,
    "CZK": 2, "HUF": 2, "RON": 2, "BGN": 2, "RSD": 2, "BAM": 2, "MKD": 2, "ALL": 2,
    "TRY": 2, "CAD": 2, "AUD": 2, "NZD": 2, "SGD": 2, "HKD": 2, "CNY": 2, "INR": 2,
    "BRL": 2, "MXN": 2, "ZAR": 2, "AED": 2, "SAR": 2, "ILS": 2, "THB": 2, "PHP": 2,
    "MYR": 2, "IDR": 2, "EGP": 2, "KES": 2, "NGN": 2, "GEL": 2, "MDL": 2, "UAH": 2,
    # 0 minor units
    "JPY": 0, "KRW": 0, "VND": 0, "CLP": 0, "ISK": 0, "PYG": 0, "RWF": 0, "UGX": 0,
    "XOF": 0, "XAF": 0, "GNF": 0, "KMF": 0, "DJF": 0, "BIF": 0, "VUV": 0,
    # 3 minor units
    "BHD": 3, "KWD": 3, "OMR": 3, "JOD": 3, "TND": 3, "IQD": 3, "LYD": 3,
}

ERP_DOC_TYPES = ("PO", "GOODS_RECEIPT", "INVOICE", "PAYMENT", "CREDIT_MEMO", "REVERSAL")
COUNTERPARTY_KINDS = ("LEGAL_ENTITY", "NATURAL_PERSON")
AMOUNT_BASES = ("GROSS", "NET")

_REQUIRED_KEYS = {
    "binding_schema", "erp_system", "erp_doc_ref", "erp_doc_type",
    "counterparty_kind", "amount", "currency", "amount_basis", "posting_date",
}
# counterparty_ref / counterparty_commitment: exactly one, selected by counterparty_kind (A8 rule).
_OPTIONAL_KEYS = {"counterparty_ref", "counterparty_commitment", "fiscal_period", "posting_lines"}

_DOC_REF_KEYS = {"company_code", "document_no", "fiscal_year"}
_LINE_KEYS = {"gl_account", "dr_cr", "amount"}

_COMPANY_CODE_RE = re.compile(r"^[A-Z0-9]{1,8}$")
_DOCUMENT_NO_RE = re.compile(r"^[A-Za-z0-9_-]{1,20}$")
_FISCAL_YEAR_RE = re.compile(r"^[0-9]{4}$")
_CURRENCY_RE = re.compile(r"^[A-Z]{3}$")
_POSTING_DATE_RE = re.compile(r"^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$")
_FISCAL_PERIOD_RE = re.compile(r"^[0-9]{4}-(0[1-9]|1[0-2])$")
_URN_RE = re.compile(r"^urn:[a-z0-9][a-z0-9-]{0,31}:\S+$")
_COMMITMENT_RE = re.compile(r"^[0-9a-f]{64}$")  # hex of H(...), the ENCRYPTED_EXTERNAL commitment form
_GL_ACCOUNT_RE = re.compile(r"^[A-Za-z0-9]{1,10}$")
# Amount shape before the currency-aware minor-units check: no sign, no separators, no leading zeros.
_AMOUNT_0_RE = re.compile(r"^(0|[1-9][0-9]*)$")


class AccountingBindingInvalid(ValueError):
    """ERP_ACCOUNTING_V1 binding violation — fail-closed (a present-but-invalid binding rejects the receipt)."""


def _amount_pattern(minor_units: int) -> re.Pattern[str]:
    if minor_units == 0:
        return _AMOUNT_0_RE
    return re.compile(r"^(0|[1-9][0-9]*)\.[0-9]{%d}$" % minor_units)


def _validate_amount(value: object, currency: str, where: str) -> int:
    """Validate one canonical amount string for `currency`; return its integer minor-units value
    (exact — no floats anywhere near this module)."""
    if isinstance(value, bool) or not isinstance(value, str):
        raise AccountingBindingInvalid(
            f"{where}: amount must be a canonical decimal STRING, got {type(value).__name__}"
        )
    minor = MINOR_UNITS[currency]
    if not _amount_pattern(minor).fullmatch(value):
        raise AccountingBindingInvalid(
            f"{where}: {value!r} is not canonical for {currency} "
            f"(exactly {minor} minor-unit digits, no sign/separators/leading zeros)"
        )
    if value == "0" or value == "0." + "0" * minor:
        raise AccountingBindingInvalid(f"{where}: amount must be positive (direction lives in erp_doc_type)")
    return int(value.replace(".", ""))


def _require_str(binding: dict, key: str, where: str = "accounting_binding") -> str:
    value = binding.get(key)
    if not isinstance(value, str) or not value:
        raise AccountingBindingInvalid(f"{where}.{key}: must be a non-empty string")
    return value


def validate_accounting_binding(binding: object) -> None:
    """Validate a `facts.accounting_binding` value against ERP_ACCOUNTING_V1 (spec v0.2 §2–§4).
    Raises `AccountingBindingInvalid` on any violation; returns None when the binding conforms."""
    if not isinstance(binding, dict):
        raise AccountingBindingInvalid("accounting_binding must be an object")

    unknown = set(binding.keys()) - _REQUIRED_KEYS - _OPTIONAL_KEYS
    if unknown:
        raise AccountingBindingInvalid(f"unknown accounting_binding fields: {sorted(unknown)}")
    missing = _REQUIRED_KEYS - set(binding.keys())
    if missing:
        raise AccountingBindingInvalid(f"missing accounting_binding fields: {sorted(missing)}")

    if binding["binding_schema"] != ERP_ACCOUNTING_V1:
        raise AccountingBindingInvalid(
            f"binding_schema must be {ERP_ACCOUNTING_V1!r}, got {binding['binding_schema']!r}"
        )
    _require_str(binding, "erp_system")

    doc_ref = binding["erp_doc_ref"]
    if not isinstance(doc_ref, dict):
        raise AccountingBindingInvalid("erp_doc_ref must be a typed object")
    if set(doc_ref.keys()) != _DOC_REF_KEYS:
        raise AccountingBindingInvalid(
            f"erp_doc_ref must have exactly {sorted(_DOC_REF_KEYS)}, got {sorted(doc_ref.keys())}"
        )
    for key, pattern in (
        ("company_code", _COMPANY_CODE_RE),
        ("document_no", _DOCUMENT_NO_RE),
        ("fiscal_year", _FISCAL_YEAR_RE),
    ):
        value = doc_ref[key]
        if not isinstance(value, str) or not pattern.fullmatch(value):
            raise AccountingBindingInvalid(f"erp_doc_ref.{key}: {value!r} does not match {pattern.pattern}")

    if binding["erp_doc_type"] not in ERP_DOC_TYPES:
        raise AccountingBindingInvalid(f"unknown erp_doc_type: {binding['erp_doc_type']!r}")
    if binding["amount_basis"] not in AMOUNT_BASES:
        raise AccountingBindingInvalid(f"amount_basis must be one of {AMOUNT_BASES}")

    currency = binding["currency"]
    if not isinstance(currency, str) or not _CURRENCY_RE.fullmatch(currency):
        raise AccountingBindingInvalid(f"currency must be ISO-4217 alpha-3, got {currency!r}")
    if currency not in MINOR_UNITS:
        raise AccountingBindingInvalid(
            f"currency {currency!r} not in the pinned minor-units table ({MINOR_UNITS_VERSION}) — fail-closed"
        )

    _validate_amount(binding["amount"], currency, "accounting_binding.amount")

    posting_date = binding["posting_date"]
    if not isinstance(posting_date, str) or not _POSTING_DATE_RE.fullmatch(posting_date):
        raise AccountingBindingInvalid(f"posting_date must be YYYY-MM-DD, got {posting_date!r}")
    if "fiscal_period" in binding:
        fp = binding["fiscal_period"]
        if not isinstance(fp, str) or not _FISCAL_PERIOD_RE.fullmatch(fp):
            raise AccountingBindingInvalid(f"fiscal_period must be YYYY-MM, got {fp!r}")

    # A8 counterparty rule (§4): the kind selects EXACTLY ONE carrier — inline URN for a legal
    # entity; salted commitment (never inline) for a natural person.
    kind = binding["counterparty_kind"]
    if kind not in COUNTERPARTY_KINDS:
        raise AccountingBindingInvalid(f"unknown counterparty_kind: {kind!r}")
    if kind == "LEGAL_ENTITY":
        if "counterparty_commitment" in binding:
            raise AccountingBindingInvalid("LEGAL_ENTITY carries counterparty_ref, not a commitment")
        ref = _require_str(binding, "counterparty_ref")
        if not _URN_RE.fullmatch(ref):
            raise AccountingBindingInvalid(f"counterparty_ref must be a URN, got {ref!r}")
    else:  # NATURAL_PERSON
        if "counterparty_ref" in binding:
            raise AccountingBindingInvalid(
                "NATURAL_PERSON: inline counterparty_ref is FORBIDDEN (A8) — use counterparty_commitment"
            )
        commitment = _require_str(binding, "counterparty_commitment")
        if not _COMMITMENT_RE.fullmatch(commitment):
            raise AccountingBindingInvalid("counterparty_commitment must be lowercase 64-hex")

    if "posting_lines" in binding:
        _validate_posting_lines(binding["posting_lines"], currency)


def _validate_posting_lines(lines: object, currency: str) -> None:
    if not isinstance(lines, list) or not lines:
        raise AccountingBindingInvalid("posting_lines: must be a non-empty list when present")
    totals = {"DR": 0, "CR": 0}
    for i, line in enumerate(lines):
        where = f"posting_lines[{i}]"
        if not isinstance(line, dict) or set(line.keys()) != _LINE_KEYS:
            raise AccountingBindingInvalid(f"{where}: must have exactly {sorted(_LINE_KEYS)}")
        gl = line["gl_account"]
        if not isinstance(gl, str) or not _GL_ACCOUNT_RE.fullmatch(gl):
            raise AccountingBindingInvalid(f"{where}.gl_account: {gl!r} invalid")
        if line["dr_cr"] not in ("DR", "CR"):
            raise AccountingBindingInvalid(f"{where}.dr_cr: must be DR or CR")
        # All line amounts are implicitly in the header currency (D5: multi-currency lines forbidden in V1).
        totals[line["dr_cr"]] += _validate_amount(line["amount"], currency, where)
    if totals["DR"] != totals["CR"]:
        raise AccountingBindingInvalid(
            f"posting_lines unbalanced: sum(DR)={totals['DR']} != sum(CR)={totals['CR']} (minor units)"
        )


def validate_facts_accounting_binding(facts: dict) -> None:
    """The PAC-RO hook: an ABSENT binding changes nothing; a PRESENT binding must conform (fail-closed)."""
    if isinstance(facts, dict) and "accounting_binding" in facts:
        validate_accounting_binding(facts["accounting_binding"])
