"""Matching and absence — the part that does not exist anywhere else in the stack.

Reconciliation compares what IS there. The dispute every company actually has every week is
about what is NOT there: *"we shipped it"* — *"we never received it"*. A record that should
exist and does not cannot be found by comparing records, only by measuring the record set
against an **obligation**. That is what this module does, and it is one engine for three
problems that look different: a missing goods receipt, an edge that went offline, and a
counterparty that goes silent.

**Absence is measured against the chain, never against a clock.** NEOedge runs on hardware
the customer controls, so its clock is not evidence — a deadline in wall-clock time could be
moved by the party it binds. Instead: a step is ABSENT when the party that owes it has
already moved further down the chain without it. B invoiced, so B cannot claim it never got
round to recording the delivery. That is provable from the record set alone, on any machine,
with no session and no live process.

Statuses, and what each one means to a Chamber:

  AGREED      both members recorded the event and every compared field matches
  DIFFERS     both recorded it and they disagree — the differing fields are named
  ABSENT      one side owes a record and has demonstrably moved past it
  UNILATERAL  one side has it, the other has not got there yet — NOT a fault
  SHORTFALL   what was delivered and what was received do not agree in quantity

Not in V1, deliberately: partial settlement (one payment covering part of a document).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from decimal import Decimal

from .grammar import BUYER, ORIGINATOR, SELLER, event_key_of

AGREED = "AGREED"
DIFFERS = "DIFFERS"
ABSENT = "ABSENT"
UNILATERAL = "UNILATERAL"
SHORTFALL = "SHORTFALL"

# The order of the procure-to-pay chain. Position in this list is the clock: a member that
# has recorded step N cannot claim it has not yet reached step N-1.
CHAIN = (
    "PURCHASE_ORDER",
    "ORDER_CONFIRMATION",
    "DELIVERY_NOTE",
    "GOODS_RECEIPT",
    "INVOICE",
    "PAYMENT",
)
_POSITION = {kind: i for i, kind in enumerate(CHAIN)}

# Fields both members must agree on for one event. Each side reads them off the same piece
# of paper, so a difference is a real disagreement and not a vocabulary problem.
COMPARED = ("amount", "currency", "amount_basis", "doc_date", "lines")


@dataclass
class Finding:
    status: str
    doc_kind: str
    originator_doc_no: str
    event_key: str
    detail: str
    owed_by: str | None = None
    fields: list[str] = field(default_factory=list)

    @property
    def is_disagreement(self) -> bool:
        return self.status in (DIFFERS, ABSENT, SHORTFALL)

    def __str__(self) -> str:
        who = f" owed by {self.owed_by}" if self.owed_by else ""
        return f"{self.status:<10} {self.doc_kind:<19} {self.originator_doc_no:<12} {self.detail}{who}"


@dataclass
class Report:
    relation_ref: str
    findings: list[Finding] = field(default_factory=list)

    @property
    def disagreements(self) -> list[Finding]:
        return [f for f in self.findings if f.is_disagreement]

    @property
    def agreed(self) -> bool:
        return not self.disagreements

    def __str__(self) -> str:
        head = f"relation {self.relation_ref[:16]}… — {len(self.findings)} events, {len(self.disagreements)} disputed"
        return "\n".join([head, *(f"  {f}" for f in self.findings)])


def compare(left: dict, right: dict) -> list[str]:
    """The compared fields on which two records of one event disagree.

    A field present on one side and absent on the other counts as a difference: an invoice
    with an amount and an invoice without one are not the same invoice.
    """
    differing = []
    for name in COMPARED:
        if name not in left and name not in right:
            continue
        if left.get(name) != right.get(name):
            differing.append(name)
    return differing


def _furthest_position(records: list[dict]) -> int:
    """How far down the chain this member has demonstrably got."""
    positions = [_POSITION[r["doc_kind"]] for r in records if r["doc_kind"] in _POSITION]
    return max(positions) if positions else -1


def _quantities(record: dict) -> dict[str, Decimal]:
    return {line["sku"]: Decimal(line["quantity"]) for line in record.get("lines", [])}


def review(records_a: list[dict], records_b: list[dict]) -> Report:
    """Compare two members' records of the same relation. Pure: no clock, no network, no
    state. The same inputs produce the same report on any machine — which is the property a
    Chamber needs, because the ruling has to be reproducible by anyone holding the evidence.
    """
    records_a, records_b = list(records_a), list(records_b)
    relations = {r["relation_ref"] for r in records_a + records_b}
    if len(relations) != 1:
        raise ValueError(f"review covers exactly one relation, got {len(relations)}")
    relation = relations.pop()

    member_a = records_a[0]["member_id"] if records_a else records_b[0]["counterparty_id"]
    member_b = records_b[0]["member_id"] if records_b else records_a[0]["counterparty_id"]
    role_of = {member_a: _role_of(records_a, records_b, member_a),
               member_b: _role_of(records_b, records_a, member_b)}

    by_a = {event_key_of(r): r for r in records_a}
    by_b = {event_key_of(r): r for r in records_b}
    reached = {member_a: _furthest_position(records_a), member_b: _furthest_position(records_b)}
    # How far the RELATION has demonstrably progressed — the measure absence is taken against.
    relation_position = max(reached.values())

    findings: list[Finding] = []

    # 1. every event either side recorded — do the two views agree?
    for key in sorted(set(by_a) | set(by_b), key=lambda k: _sort_key(by_a.get(k) or by_b[k])):
        left, right = by_a.get(key), by_b.get(key)
        sample = left or right
        kind, doc_no = sample["doc_kind"], sample["originator_doc_no"]

        if left is not None and right is not None:
            differing = compare(left, right)
            findings.append(
                Finding(AGREED if not differing else DIFFERS, kind, doc_no, key,
                        "both books match" if not differing
                        else "the two books disagree on " + ", ".join(differing),
                        fields=differing)
            )
            continue

        missing = member_b if left is not None else member_a
        position = _POSITION.get(kind, -1)
        if relation_position > position:
            # The relation itself has moved past this step — a buyer does not pay an invoice
            # that was never issued — so the missing record is owed, not merely pending. This
            # holds even for a member with no records at all, which is the "counterparty goes
            # silent" case: its own progress cannot be the measure, the relation's is.
            findings.append(
                Finding(ABSENT, kind, doc_no, key,
                        f"{missing} has no record of it, yet this relation has already reached "
                        f"{CHAIN[relation_position]}"
                        + (f" in {missing}'s own book" if reached[missing] == relation_position else ""),
                        owed_by=missing)
            )
        else:
            findings.append(
                Finding(UNILATERAL, kind, doc_no, key,
                        f"recorded by one side only; the relation has not moved past this step, "
                        f"so {missing} is not yet late",
                        owed_by=missing)
            )

    # 2. steps nobody recorded, but which the chain says must have happened
    findings.extend(_skipped_steps(records_a + records_b, role_of, relation))

    # 3. delivered versus received
    findings.extend(_shortfalls(records_a + records_b))

    return Report(relation, findings)


def _role_of(own: list[dict], other: list[dict], member_id: str) -> str:
    for record in own:
        if record["member_id"] == member_id:
            return record["role"]
    for record in other:
        if record["counterparty_id"] == member_id:
            return BUYER if record["role"] == SELLER else SELLER
    return BUYER


def _sort_key(record: dict) -> tuple:
    # The contract is not a step in the chain; it is what the chain hangs from, so it reads first.
    return (_POSITION.get(record["doc_kind"], -1), record["originator_doc_no"])


def _skipped_steps(records: list[dict], role_of: dict[str, str], relation: str) -> list[Finding]:
    """A step in the chain that NOBODY recorded, while a later one exists.

    This is the "we invoiced goods we never recorded shipping" case: the gap is visible from
    ordering alone, so it needs no deadline and no clock to be provable.
    """
    seen = {r["doc_kind"] for r in records}
    furthest = max((_POSITION[k] for k in seen if k in _POSITION), default=-1)
    if furthest < 0:
        return []
    by_role = {role: member for member, role in role_of.items()}
    findings = []
    for position in range(furthest):
        kind = CHAIN[position]
        if kind in seen:
            continue
        responsible_role = ORIGINATOR[kind]
        findings.append(
            Finding(ABSENT, kind, "—",
                    "", f"no {kind} exists in either book, yet {CHAIN[furthest]} does",
                    owed_by=by_role.get(responsible_role))
        )
    return findings


def _shortfalls(records: list[dict]) -> list[Finding]:
    """What the seller says it sent against what the buyer says it got.

    Compared across two different documents, so it is not a disagreement about one event —
    it is a partial delivery, and both books can be internally consistent while it happens.
    """
    notes = {event_key_of(r): r for r in records
             if r["doc_kind"] == "DELIVERY_NOTE" and r["role"] == SELLER}
    receipts = [r for r in records if r["doc_kind"] == "GOODS_RECEIPT" and r["role"] == BUYER]
    findings = []
    for receipt in receipts:
        note = notes.get(receipt.get("fulfills_event", "")) or _sole(notes)
        if note is None:
            continue
        sent, got = _quantities(note), _quantities(receipt)
        short = {sku: (qty, got.get(sku, Decimal(0))) for sku, qty in sent.items()
                 if got.get(sku, Decimal(0)) != qty}
        extra = {sku: qty for sku, qty in got.items() if sku not in sent}
        if not short and not extra:
            continue
        detail = "; ".join(
            [f"{sku}: sent {sent_qty}, received {got_qty}" for sku, (sent_qty, got_qty) in sorted(short.items())]
            + [f"{sku}: received {qty} that was never sent" for sku, qty in sorted(extra.items())]
        )
        findings.append(
            Finding(SHORTFALL, "GOODS_RECEIPT", receipt["originator_doc_no"],
                    event_key_of(receipt),
                    f"against delivery note {note['originator_doc_no']} — {detail}")
        )
    return findings


def _sole(mapping: dict):
    """The only delivery note, when a receipt does not name which one it answers."""
    return next(iter(mapping.values())) if len(mapping) == 1 else None
