"""The canonical grammar — one closed field set per document kind, and the shared keys.

NEOedge runs on both sides of a trade, on hardware neither of us controls, against ERPs
that name the same field five different ways. Everything here exists to make two edges,
starting from two different ERPs, produce records that can be **compared byte for byte**.

Three ideas carry the whole design:

  * **Fields ARE the record.** A record's identity is SHA-256 over its canonical bytes.
    There is no document text, no PDF, no narrative. NEOos never receives a document.
  * **Closed field sets.** An unknown field is REFUSED, never ignored. This is what stops
    an adapter from silently smuggling one ERP's vocabulary into the shared grammar.
  * **Shared keys are derived only from what both sides read off the same paper.**
    A's ERP calls the order `A-145`; B's ERP calls the same order `B-882`. Neither number
    identifies the event. What identifies it is the contract both signed, the kind of
    document, and the number the **originator** wrote on it — and both sides can see all
    three. `relation_ref` and `event_key` are computed from exactly those, so two edges
    that never talk to each other still agree on what to call the same business event.

Both sides record every event. The buyer records the delivery note it received; the seller
records the delivery note it issued. That is the third entry: two independent books, and a
neutral record of each, from each.
"""

from __future__ import annotations

import hashlib
import re
from decimal import Decimal

from .canonical import canonical_bytes
from .money import check_amount, check_currency, minor_units, to_minor

RECORD_SCHEMA = "NEOEDGES_RECORD_V1"

DOMAIN_RECORD = b"NEOEDGES:RECORD:V1"
DOMAIN_RELATION = b"NEOEDGES:RELATION:V1"
DOMAIN_EVENT = b"NEOEDGES:EVENT:V1"

BUYER = "BUYER"
SELLER = "SELLER"
ROLES = (BUYER, SELLER)

# Who writes the document. This is what makes the shared event key derivable on both sides:
# whoever is NOT the originator read the originator's number off the document they received.
ORIGINATOR: dict[str, str | None] = {
    "CONTRACT": None,            # both sign it; the contract number is on the paper
    "PURCHASE_ORDER": BUYER,
    "ORDER_CONFIRMATION": SELLER,
    "DELIVERY_NOTE": SELLER,
    "GOODS_RECEIPT": BUYER,
    "INVOICE": SELLER,
    "PAYMENT": BUYER,
    "CREDIT_MEMO": SELLER,
}
DOC_KINDS = tuple(ORIGINATOR)

# Deliberately NOT in V1, so that neither is smuggled in mid-build:
#   REVERSAL      — a same-side correction; its originator is not derivable from the kind
#   partial settlement — one PAYMENT covering part of a document (Ledger spec D4 defers it too)

REFUSED_UNKNOWN_FIELD = "REFUSED_UNKNOWN_FIELD"
REFUSED_MISSING_FIELD = "REFUSED_MISSING_FIELD"
REFUSED_BAD_FIELD = "REFUSED_BAD_FIELD"
REFUSED_UNKNOWN_KIND = "REFUSED_UNKNOWN_KIND"
REFUSED_CONSERVATION = "REFUSED_CONSERVATION"
REFUSED_ROLE_MISMATCH = "REFUSED_ROLE_MISMATCH"


class GrammarError(Exception):
    """A record does not satisfy the grammar. Carries a named code — never a bare message."""

    def __init__(self, code: str, detail: str):
        super().__init__(detail)
        self.code = code
        self.detail = detail


# ── field checkers ────────────────────────────────────────────────────────────────────

_URN = re.compile(r"^urn:neoedge:member:[a-z0-9][a-z0-9._-]{0,62}$")
_DATE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$")
_PERIOD = re.compile(r"^[0-9]{4}-[0-9]{2}$")
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
_DOC_NO = re.compile(r"^[A-Za-z0-9][A-Za-z0-9/_-]{0,34}$")
_COMPANY_CODE = re.compile(r"^[A-Z0-9]{1,8}$")
_FISCAL_YEAR = re.compile(r"^[0-9]{4}$")
_SYSTEM = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,31}$")
_QTY = re.compile(r"^(0|[1-9][0-9]*)(\.[0-9]{1,6})?$")
_SKU = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,39}$")
_UNIT = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,7}$")


def _re(pattern: re.Pattern, name: str):
    def check(value, where: str):
        if not isinstance(value, str) or not pattern.match(value):
            raise GrammarError(REFUSED_BAD_FIELD, f"{where}: not a {name}: {value!r}")
        return value
    return check


def _enum(*allowed: str):
    def check(value, where: str):
        if value not in allowed:
            raise GrammarError(REFUSED_BAD_FIELD, f"{where}: must be one of {allowed}, got {value!r}")
        return value
    return check


def _doc_ref(value, where: str) -> dict:
    if not isinstance(value, dict):
        raise GrammarError(REFUSED_BAD_FIELD, f"{where}: must be a typed object")
    expected = {"system", "company_code", "document_no", "fiscal_year"}
    if set(value) != expected:
        raise GrammarError(
            REFUSED_BAD_FIELD,
            f"{where}: must have exactly {sorted(expected)}, got {sorted(value)}",
        )
    return {
        "system": _re(_SYSTEM, "system id")(value["system"], f"{where}.system"),
        "company_code": _re(_COMPANY_CODE, "company code")(value["company_code"], f"{where}.company_code"),
        "document_no": _re(_DOC_NO, "document number")(value["document_no"], f"{where}.document_no"),
        "fiscal_year": _re(_FISCAL_YEAR, "fiscal year")(value["fiscal_year"], f"{where}.fiscal_year"),
    }


def _lines(value, where: str) -> list:
    if not isinstance(value, list) or not value:
        raise GrammarError(REFUSED_BAD_FIELD, f"{where}: must be a non-empty array of line items")
    out = []
    for i, line in enumerate(value):
        at = f"{where}[{i}]"
        if not isinstance(line, dict):
            raise GrammarError(REFUSED_BAD_FIELD, f"{at}: must be an object")
        required = {"sku", "quantity", "unit"}
        unknown = set(line) - required - {"unit_price"}
        if unknown:
            raise GrammarError(REFUSED_UNKNOWN_FIELD, f"{at}: unknown line field(s) {sorted(unknown)}")
        missing = required - set(line)
        if missing:
            raise GrammarError(REFUSED_MISSING_FIELD, f"{at}: missing {sorted(missing)}")
        clean = {
            "sku": _re(_SKU, "sku")(line["sku"], f"{at}.sku"),
            "quantity": _re(_QTY, "quantity decimal string")(line["quantity"], f"{at}.quantity"),
            "unit": _re(_UNIT, "unit")(line["unit"], f"{at}.unit"),
        }
        if "unit_price" in line:
            clean["unit_price"] = _re(_QTY, "unit price decimal string")(line["unit_price"], f"{at}.unit_price")
        out.append(clean)
    return out


_MEMBER = _re(_URN, "member urn")
_DATE_C = _re(_DATE, "YYYY-MM-DD date")
_PERIOD_C = _re(_PERIOD, "YYYY-MM fiscal period")
_HEX_C = _re(_HEX64, "sha-256 hex digest")
_DOC_NO_C = _re(_DOC_NO, "document number")
_BASIS = _enum("GROSS", "NET")


def _guard(fn, value, where: str):
    try:
        return fn(value)
    except Exception as exc:  # money errors carry their own detail
        raise GrammarError(REFUSED_BAD_FIELD, f"{where}: {exc}") from exc


def _CURRENCY_C(value, where: str) -> str:
    return _guard(check_currency, value, where)


# ── the schema, per kind ──────────────────────────────────────────────────────────────

# On every kind. `originator_doc_no` is the shared handle: the number the originator wrote
# on the document. The originator repeats its own number here; the receiver copies what it
# read. That is the only field both sides can be relied on to hold identically.
_ENVELOPE_REQUIRED = {
    "record_schema": _enum(RECORD_SCHEMA),
    "member_id": _MEMBER,
    "role": _enum(*ROLES),
    "counterparty_id": _MEMBER,
    "relation_ref": _HEX_C,
    "doc_kind": _enum(*DOC_KINDS),
    "own_doc_ref": _doc_ref,
    "originator_doc_no": _DOC_NO_C,
    "doc_date": _DATE_C,
}
_ENVELOPE_OPTIONAL = {
    "counterparty_doc_ref": _doc_ref,   # their number, once we know it — often only later
    "fulfills_event": _HEX_C,           # the event_key this answers (shared, not a record hash)
    "fiscal_period": _PERIOD_C,
    "lines": _lines,
}

_MONEY_REQUIRED = {"amount": None, "currency": _CURRENCY_C, "amount_basis": _BASIS}
_MONEY_OPTIONAL = dict(_MONEY_REQUIRED)

# per kind: (extra required, extra optional)
SCHEMAS: dict[str, tuple[dict, dict]] = {
    "CONTRACT": ({}, dict(_MONEY_OPTIONAL)),
    "PURCHASE_ORDER": (dict(_MONEY_REQUIRED), {}),
    "ORDER_CONFIRMATION": (dict(_MONEY_REQUIRED), {}),
    "DELIVERY_NOTE": ({}, dict(_MONEY_OPTIONAL)),
    "GOODS_RECEIPT": ({}, dict(_MONEY_OPTIONAL)),
    "INVOICE": (dict(_MONEY_REQUIRED), {}),
    "PAYMENT": (dict(_MONEY_REQUIRED, settles_originator_doc_no=_DOC_NO_C), {}),
    "CREDIT_MEMO": (dict(_MONEY_REQUIRED), {}),
}

# Kinds whose meaning is the goods that moved: a line set is required, because "what was
# delivered" versus "what was received" is the disagreement these two documents exist to expose.
_LINES_REQUIRED = ("DELIVERY_NOTE", "GOODS_RECEIPT")


def validate(record: dict) -> dict:
    """Validate a record against its kind. Returns the clean, ordered field set, or raises
    `GrammarError` with a named code. Fail-closed: nothing partial is ever returned."""
    if not isinstance(record, dict):
        raise GrammarError(REFUSED_BAD_FIELD, "record must be an object")
    kind = record.get("doc_kind")
    if kind not in SCHEMAS:
        raise GrammarError(REFUSED_UNKNOWN_KIND, f"unknown doc_kind {kind!r} — one of {sorted(SCHEMAS)}")

    extra_required, extra_optional = SCHEMAS[kind]
    required = {**_ENVELOPE_REQUIRED, **extra_required}
    optional = {**_ENVELOPE_OPTIONAL, **extra_optional}

    unknown = set(record) - set(required) - set(optional)
    if unknown:
        raise GrammarError(REFUSED_UNKNOWN_FIELD, f"{kind}: unknown field(s) {sorted(unknown)}")
    missing = set(required) - set(record)
    if missing:
        raise GrammarError(REFUSED_MISSING_FIELD, f"{kind}: missing required field(s) {sorted(missing)}")
    if kind in _LINES_REQUIRED and "lines" not in record:
        raise GrammarError(REFUSED_MISSING_FIELD, f"{kind}: missing required field 'lines'")

    clean: dict = {}
    for name, value in record.items():
        if name == "amount":
            continue  # checked below, against the currency
        checker = required.get(name) or optional.get(name)
        clean[name] = checker(value, f"{kind}.{name}")

    if "amount" in record:
        if "currency" not in record:
            raise GrammarError(REFUSED_MISSING_FIELD, f"{kind}: 'amount' without 'currency'")
        clean["amount"] = _guard(lambda a: check_amount(a, record["currency"]), record["amount"], f"{kind}.amount")
    if "currency" in record and "amount" not in record:
        raise GrammarError(REFUSED_MISSING_FIELD, f"{kind}: 'currency' without 'amount'")

    if record["member_id"] == record["counterparty_id"]:
        raise GrammarError(REFUSED_BAD_FIELD, f"{kind}: a member cannot trade with itself")

    origin_role = ORIGINATOR[kind]
    if origin_role is not None and record["role"] == origin_role:
        # The originator's own number IS the shared handle; a mismatch would break pairing
        # silently, so it is refused here rather than discovered at reconciliation time.
        if record["own_doc_ref"]["document_no"] != record["originator_doc_no"]:
            raise GrammarError(
                REFUSED_ROLE_MISMATCH,
                f"{kind}: this member is the originator, so own_doc_ref.document_no "
                f"({record['own_doc_ref']['document_no']!r}) must equal originator_doc_no "
                f"({record['originator_doc_no']!r})",
            )

    _check_conservation(kind, clean)
    return {key: clean[key] for key in sorted(clean)}


def _check_conservation(kind: str, clean: dict) -> None:
    """Where lines carry prices and the amount is NET, the two must agree exactly.

    Only NET: a GROSS amount includes tax, which the line set does not carry in V1, so
    checking it would refuse correct documents. Silence beats a wrong check.
    """
    lines = clean.get("lines")
    if not lines or "amount" not in clean or clean.get("amount_basis") != "NET":
        return
    if any("unit_price" not in line for line in lines):
        return
    currency = clean["currency"]
    total = sum((Decimal(line["quantity"]) * Decimal(line["unit_price"]) for line in lines), Decimal("0"))
    total_minor = total.scaleb(minor_units(currency))
    if total_minor != total_minor.to_integral_value():
        raise GrammarError(
            REFUSED_CONSERVATION,
            f"{kind}: the priced lines sum to {total}, which {currency} cannot express exactly",
        )
    if to_minor(clean["amount"], currency) != int(total_minor):
        raise GrammarError(
            REFUSED_CONSERVATION,
            f"{kind}: NET amount {clean['amount']} does not equal the sum of its priced lines ({total})",
        )


# ── the shared keys ───────────────────────────────────────────────────────────────────


def relation_ref(handshake_ref: str, member_a: str, member_b: str) -> str:
    """The thread both sides share, derived from the handshake reference — the contract
    number both signed — and the two members.

    Not a hash of anyone's record: A's and B's records of the same contract differ (each
    names itself), so a record hash could never be the shared handle. The reference is
    on the paper both signed, and the member set is symmetric — so both compute this
    independently and get the same 64 hex characters. The JCS body key is
    `handshake_ref`, as the NEOf1 lock pins it — one grammar across every edge repo, or
    the same relation would carry two names on the wire.
    """
    _DOC_NO_C(handshake_ref, "handshake_ref")
    _MEMBER(member_a, "member_a")
    _MEMBER(member_b, "member_b")
    if member_a == member_b:
        raise GrammarError(REFUSED_BAD_FIELD, "a relation needs two distinct members")
    body = {"handshake_ref": handshake_ref, "members": sorted([member_a, member_b])}
    return hashlib.sha256(DOMAIN_RELATION + canonical_bytes(body)).hexdigest()


def event_key(relation: str, doc_kind: str, originator_doc_no: str) -> str:
    """The name of one business event, computable by both sides without talking.

    Two records carry the same `event_key` exactly when they are two views of the same
    document — the buyer's copy of delivery note DN-311 and the seller's own DN-311.
    Matching, absence and the whole dispute path are built on this one function.
    """
    _HEX_C(relation, "relation")
    if doc_kind not in DOC_KINDS:
        raise GrammarError(REFUSED_UNKNOWN_KIND, f"unknown doc_kind {doc_kind!r}")
    _DOC_NO_C(originator_doc_no, "originator_doc_no")
    body = {"relation_ref": relation, "doc_kind": doc_kind, "originator_doc_no": originator_doc_no}
    return hashlib.sha256(DOMAIN_EVENT + canonical_bytes(body)).hexdigest()


def event_key_of(record: dict) -> str:
    return event_key(record["relation_ref"], record["doc_kind"], record["originator_doc_no"])


def record_hash(record: dict) -> str:
    """A record's identity: SHA-256 over its canonical bytes, domain-separated.

    Two edges recording the same event produce DIFFERENT record hashes — each names itself
    as `member_id`. That is intended: the record hash proves what one member asserted; the
    `event_key` says which event both are talking about.
    """
    return hashlib.sha256(DOMAIN_RECORD + canonical_bytes(record)).hexdigest()
