#!/usr/bin/env python3
"""Reproduce a synthetic invoice-review example; no network or payment actions.

generate_results(dataset, expected) is a pure function returning JSON-compatible
data. The CLI reads the adjacent fixture files and prints deterministic JSON.
"""

import argparse
from collections import Counter
from copy import deepcopy
from decimal import Decimal
import json
from pathlib import Path
import re
import sys

HERE = Path(__file__).resolve().parent
CLASSES = ("NEEDS_REVIEW", "POSSIBLE_DUPLICATE", "NO_MATCH")
FIELDS = {"id", "supplier_id", "invoice_number", "amount", "currency"}
ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z")
NUMBER_PATTERN = re.compile(r"[A-Za-z0-9 -]{1,64}\Z")
AMOUNT_PATTERN = re.compile(r"(?:0|[1-9][0-9]{0,8})(?:\.[0-9]{1,2})?\Z")
CURRENCIES = ("USD", "EUR", "GBP")


def normalized_number(value):
    """Remove only ASCII spaces/hyphens and fold ASCII letter case."""
    return value.replace(" ", "").replace("-", "").upper()


def zero_signature(value):
    """An ambiguity detector, never a duplicate-confirmation key."""
    return re.sub(r"[0-9]+", lambda match: str(int(match.group())), value)


def invoice_issues(record):
    if not isinstance(record, dict):
        return ["invoice must be an object"]
    issues = []
    for field in sorted(FIELDS - record.keys()):
        issues.append("missing " + field)
    for field in sorted(record.keys() - FIELDS):
        issues.append("unsupported field: " + field)
    for field in ("id", "supplier_id"):
        value = record.get(field)
        if not isinstance(value, str) or not ID_PATTERN.fullmatch(value):
            issues.append(field + " must be an ASCII identifier, 1–64 characters")
    number = record.get("invoice_number")
    if (not isinstance(number, str) or not NUMBER_PATTERN.fullmatch(number)
            or not normalized_number(number)):
        issues.append("invoice_number must contain ASCII letters/digits, spaces or hyphens, 1–64 characters")
    amount = record.get("amount")
    if (not isinstance(amount, str) or not AMOUNT_PATTERN.fullmatch(amount)
            or Decimal(amount) <= 0):
        issues.append("amount must be a positive decimal string, at most 999999999.99 and two decimal places")
    if record.get("currency") not in CURRENCIES:
        issues.append("currency must be USD, EUR or GBP in this example")
    return issues


def outcome(classification, reason, explanation, matches=None, issues=None):
    return {
        "classification": classification,
        "reason": reason,
        "explanation": explanation,
        "matches": matches or [],
        "issues": issues or [],
        "reviewRequired": True,
    }


def classify(target, ledger, prior_batch, mode):
    """Classify the target against the supplied ledger and earlier batch only."""
    if mode not in ("baseline", "candidate"):
        raise ValueError("mode must be baseline or candidate")
    references = [("ledger", record) for record in ledger]
    references += [("batch", record) for record in prior_batch]
    all_records = references + [("target", target)]
    issues = [scope + "[" + str(index) + "]: " + issue
              for scope, records in (("ledger", ledger), ("priorBatch", prior_batch))
              for index, record in enumerate(records)
              for issue in invoice_issues(record)]
    issues += ["target: " + issue for issue in invoice_issues(target)]
    if issues:
        return outcome("NEEDS_REVIEW", "invalid_input",
                       "The target or supplied reference data is incomplete, invalid or outside this example's supported format.",
                       issues=issues)

    repeated = sorted(key for key, count in Counter(record["id"] for _, record in all_records).items() if count > 1)
    if repeated:
        return outcome("NEEDS_REVIEW", "repeated_record_id",
                       "A record ID occurs more than once in the supplied context; check the import before interpreting matches.",
                       issues=["repeated id: " + key for key in repeated])

    matches = []
    target_normalized = normalized_number(target["invoice_number"]) if mode == "candidate" else None
    target_zero_signature = zero_signature(target_normalized) if mode == "candidate" else None
    for scope, record in references:
        if target["supplier_id"] != record["supplier_id"]:
            continue
        basis = None
        if target["invoice_number"] == record["invoice_number"]:
            basis = "literal_number"
        elif mode == "candidate":
            reference_normalized = normalized_number(record["invoice_number"])
            if target_normalized == reference_normalized:
                basis = "normalized_number"
            elif target_zero_signature == zero_signature(reference_normalized):
                basis = "leading_zero_ambiguity"
        if basis:
            matches.append({"scope": scope, "id": record["id"], "basis": basis, "record": deepcopy(record)})

    definite_key_matches = [match for match in matches if match["basis"] != "leading_zero_ambiguity"]
    if any(Decimal(target["amount"]) != Decimal(match["record"]["amount"])
           or target["currency"] != match["record"]["currency"] for match in definite_key_matches):
        return outcome("NEEDS_REVIEW", "amount_or_currency_conflict",
                       "At least one matching supplier/number reference has a different amount or currency; inspect the source records.",
                       matches)
    if any(match["basis"] == "leading_zero_ambiguity" for match in matches):
        return outcome("NEEDS_REVIEW", "ambiguous_leading_zeros",
                       "A number matches only after removing leading zeros from digit groups. The example does not assume these numbers are equivalent.",
                       matches)
    if matches:
        return outcome("POSSIBLE_DUPLICATE", "matching_reference",
                       "The supplier, compared invoice number, amount and currency match a supplied reference. A person must decide whether this is a duplicate.",
                       matches)
    return outcome("NO_MATCH", "no_reference_match",
                   "No reference matches within the supplied ledger and earlier batch under this rule. This is not permission to approve or pay.")


def validate_fixtures(dataset, expected):
    if not isinstance(dataset, dict) or dataset.get("schemaVersion") != 1 or dataset.get("kind") != "synthetic":
        raise ValueError("input must be a schemaVersion 1 synthetic dataset")
    if not isinstance(dataset.get("id"), str) or not dataset["id"]:
        raise ValueError("dataset id must be a nonempty string")
    cases = dataset.get("cases")
    if not isinstance(cases, list) or not 1 <= len(cases) <= 100:
        raise ValueError("input must contain 1–100 scenarios")
    ids = []
    for case in cases:
        if not isinstance(case, dict) or not isinstance(case.get("id"), str) or not case["id"]:
            raise ValueError("each scenario needs a nonempty id")
        if not isinstance(case.get("title"), str) or not case["title"]:
            raise ValueError("each scenario needs a title")
        if not isinstance(case.get("ledger"), list) or not isinstance(case.get("batch"), list) or not case["batch"]:
            raise ValueError("each scenario needs a ledger list and a nonempty batch list")
        if len(case["ledger"]) + len(case["batch"]) > 100:
            raise ValueError("each scenario supports at most 100 total records")
        ids.append(case["id"])
    if len(set(ids)) != len(ids):
        raise ValueError("scenario IDs must be unique")
    if not isinstance(expected, dict) or expected.get("schemaVersion") != 1 or not isinstance(expected.get("labels"), dict):
        raise ValueError("expected labels must use schemaVersion 1")
    if set(expected["labels"]) != set(ids):
        raise ValueError("expected labels must exactly match the scenario IDs")
    if any(label not in CLASSES for label in expected["labels"].values()):
        raise ValueError("expected labels must be NEEDS_REVIEW, POSSIBLE_DUPLICATE or NO_MATCH")


def generate_results(dataset, expected):
    """Pure, deterministic evaluation of already parsed fixtures and labels."""
    validate_fixtures(dataset, expected)
    rows = []
    for case in dataset["cases"]:
        target, prior_batch = case["batch"][-1], case["batch"][:-1]
        rows.append({
            "id": case["id"],
            "title": case["title"],
            "expected": expected["labels"][case["id"]],
            "input": {"target": deepcopy(target), "ledger": deepcopy(case["ledger"]), "priorBatch": deepcopy(prior_batch)},
            "baseline": classify(target, case["ledger"], prior_batch, "baseline"),
            "candidate": classify(target, case["ledger"], prior_batch, "candidate"),
        })
    summary = {}
    for version in ("baseline", "candidate"):
        summary[version] = {
            "correct": sum(row[version]["classification"] == row["expected"] for row in rows),
            "total": len(rows),
            "counts": {label: sum(row[version]["classification"] == label for row in rows) for label in CLASSES},
        }
    return {
        "schemaVersion": 1,
        "dataset": {"id": dataset["id"], "kind": "synthetic", "scenarioCount": len(rows)},
        "policy": {
            "baseline": "Compare supplier_id and the literal invoice_number; validate all supplied records and check amount/currency conflicts.",
            "normalization": "Candidate ignores ASCII letter case, spaces and hyphens in invoice numbers only. Supplier IDs remain exact. Leading zeros are preserved; zero-only collisions require review.",
            "matchScope": "Each independent scenario compares its final batch record with the supplied ledger and earlier batch records. Nothing is remembered across scenarios.",
            "actions": {"humanReviewRequired": True, "automaticApproval": False, "automaticRejection": False, "automaticPayment": False},
        },
        "summary": summary,
        "measurementLimit": "Counts show agreement with manually assigned expectations on " + str(len(rows)) + " deliberately chosen synthetic scenarios, not accuracy on real invoices or evidence of savings.",
        "cases": rows,
    }


def serialize(result):
    return json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n"


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input", type=Path, default=HERE / "input.json")
    parser.add_argument("--expected", type=Path, default=HERE / "expected.json")
    destination = parser.add_mutually_exclusive_group()
    destination.add_argument("--output", type=Path, help="write the generated report to this local file")
    destination.add_argument("--check", nargs="?", const=HERE / "results.json", type=Path,
                             help="compare the generated report with this file (default: results.json)")
    args = parser.parse_args()
    try:
        dataset = json.loads(args.input.read_text(encoding="utf-8"))
        expected = json.loads(args.expected.read_text(encoding="utf-8"))
        generated = serialize(generate_results(dataset, expected))
        if args.check:
            if generated != args.check.read_text(encoding="utf-8"):
                print("Generated report does not match " + str(args.check), file=sys.stderr)
                return 1
            print("Results match " + args.check.name + " (" + str(len(dataset["cases"])) + " synthetic scenarios).")
        elif args.output:
            args.output.write_text(generated, encoding="utf-8")
        else:
            sys.stdout.write(generated)
    except (OSError, ValueError, TypeError, KeyError) as error:
        print("Cannot run invoice example: " + str(error), file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    sys.exit(main())
