#!/usr/bin/env python3
"""Check an Offset Ledger report using Python's standard library only.

Usage: python3 check_report.py report.json

This program independently recomputes the supplied daily-balance model, including
every daily result. It checks a self-digest and internal consistency. It cannot
authenticate bank records, establish that user declarations are true, or detect
someone replacing a report with a different, self-consistent report. It neither
imports nor executes the JavaScript engine. Nothing is uploaded or written.
"""
from datetime import date
from fractions import Fraction
import hashlib
import json
from pathlib import Path
import re
import sys

FORMAT = "aw-offset-ledger"
VERSION = 1
MAX_DAYS = 3660
MAX_REPORT_BYTES = 16 * 1024 * 1024
MAX_DECIMAL_CHARS = 32
MAX_DECIMAL_PLACES = 12
MAX_TREE_NODES = 300000
CONVENTIONS = ("period-end-half-up", "daily-half-up")
DECLARATIONS = (
    "completePeriod", "endOfDayBalances", "effectiveRates", "eligibleOffsets",
    "chargePeriod", "modelAccepted",
)
SCOPE = "arithmetic-and-internal-consistency-only"
LIMITATION = (
    "Checks arithmetic and internal consistency only. User declarations, record "
    "authenticity, completeness, lender conventions, offset linkage and any right "
    "to compensation are not established. The SHA-256 is a self-digest, not a signature."
)


class ReportError(ValueError):
    """An invalid report, with a stable code and a path rather than private data."""

    def __init__(self, code, message):
        super().__init__(message)
        self.code = code


def fail(code, message):
    raise ReportError(code, message)


def shape(value, keys, field):
    if type(value) is not dict:
        fail("SHAPE", f"{field} must be an object.")
    if set(value) != set(keys):
        fail("SHAPE", f"{field} has missing or unsupported fields.")


def check_tree(root):
    """Bound processing even for a directly imported Python caller, not just JSON."""
    count = 0
    active = set()

    def visit(value, depth):
        nonlocal count
        count += 1
        if count > MAX_TREE_NODES or depth > 32:
            fail("RESOURCE_LIMIT", "Report structure exceeds the supported size or depth.")
        if type(value) in (dict, list):
            identity = id(value)
            if identity in active:
                fail("SHAPE", "Cyclic report structures are not JSON.")
            active.add(identity)
            if type(value) is dict:
                for key, child in value.items():
                    if type(key) is not str or not key.isascii() or len(key) > 64:
                        fail("SHAPE", "Report field names must be short ASCII strings.")
                    visit(child, depth + 1)
            else:
                for child in value:
                    visit(child, depth + 1)
            active.remove(identity)
        elif type(value) is str:
            if not value.isascii() or len(value) > 512:
                fail("RESOURCE_LIMIT", "Report strings must be ASCII and at most 512 characters.")
        elif type(value) is int:
            if abs(value) > 999999999:
                fail("RESOURCE_LIMIT", "JSON integers exceed this report format's limit.")
        elif type(value) is not bool:
            fail("SHAPE", "This report format admits no nulls or floating-point JSON numbers.")

    visit(root, 0)


def canonical_digest(report):
    """Hash every field except sha256; this is integrity, never authentication."""
    body = {key: value for key, value in report.items() if key != "sha256"}
    # Match JSON.stringify's literal ASCII DEL (U+007F) spelling as well as its
    # control-character escapes. Schema-valid reports use printable ASCII only.
    encoded = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    return hashlib.sha256(encoded.encode("utf-8")).hexdigest()


def decimal(value, field):
    if (type(value) is not str or len(value) > MAX_DECIMAL_CHARS
            or re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", value) is None):
        fail("DECIMAL", f"{field} must be a bounded, nonnegative decimal string.")
    if "." in value and len(value.split(".")[1]) > MAX_DECIMAL_PLACES:
        fail("DECIMAL", f"{field} exceeds {MAX_DECIMAL_PLACES} decimal places.")
    return Fraction(value)


def calendar_day(value, field):
    if type(value) is not str or re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value) is None:
        fail("DATE", f"{field} must be an ISO calendar date.")
    try:
        return date.fromisoformat(value)
    except ValueError:
        fail("DATE", f"{field} is not a valid Gregorian calendar date.")


def fraction_json(value):
    return {"numerator": str(value.numerator), "denominator": str(value.denominator)}


def half_up(value):
    # divmod gives quotient and remainder without sharing the engine's formula.
    quotient, remainder = divmod(value.numerator, value.denominator)
    return quotient + int(2 * remainder >= value.denominator)


def recompute(request):
    """Independent complete equivalent of the explicit daily model, not a bank audit."""
    shape(request, ("start", "end", "rows", "observedCharge", "conventions"), "input")
    start = calendar_day(request["start"], "input.start")
    end = calendar_day(request["end"], "input.end")
    days = (end - start).days + 1
    if not 1 <= days <= MAX_DAYS:
        fail("PERIOD", f"The inclusive period must contain 1 to {MAX_DAYS} days.")
    conventions = request["conventions"]
    if (type(conventions) is not list or not 1 <= len(conventions) <= 2
            or any(type(x) is not str or x not in CONVENTIONS for x in conventions)
            or len(set(conventions)) != len(conventions)):
        fail("CONVENTION", "Select one or both distinct supported rounding conventions.")
    observed = decimal(request["observedCharge"], "input.observedCharge") * 100
    if observed.denominator != 1:
        fail("OBSERVED_CENTS", "The observed charge must be a whole number of cents.")
    rows = request["rows"]
    if type(rows) is not list or len(rows) != days:
        fail("COVERAGE", "Exactly one row is required for each day in the inclusive period.")

    parsed = []
    seen = set()
    for index, row in enumerate(rows):
        field = f"input.rows[{index}]"
        shape(row, ("date", "loan", "offset", "annualRatePercent"), field)
        day = calendar_day(row["date"], f"{field}.date")
        if day < start or day > end or day in seen:
            fail("COVERAGE", f"{field}.date is duplicated or outside the period.")
        seen.add(day)
        parsed.append((day, decimal(row["loan"], f"{field}.loan"),
                       decimal(row["offset"], f"{field}.offset"),
                       decimal(row["annualRatePercent"], f"{field}.annualRatePercent")))

    total_net = Fraction(0)
    total_gross = Fraction(0)
    rounded_net = 0
    rounded_gross = 0
    daily = []
    for day, loan, offset, annual_percent in sorted(parsed):
        balance = max(Fraction(0), loan - offset)
        daily_rate = annual_percent / 100 / 365
        net = balance * daily_rate * 100
        gross = loan * daily_rate * 100
        net_cent = half_up(net)
        gross_cent = half_up(gross)
        total_net += net
        total_gross += gross
        rounded_net += net_cent
        rounded_gross += gross_cent
        daily.append({
            "date": day.isoformat(),
            "netLoanDollars": fraction_json(balance),
            "exactCents": {"withOffset": fraction_json(net), "noOffset": fraction_json(gross),
                           "savings": fraction_json(gross - net)},
            "halfUpCents": {"withOffset": str(net_cent), "noOffset": str(gross_cent)},
        })

    candidates = []
    for convention in conventions:
        charge = half_up(total_net) if convention == CONVENTIONS[0] else rounded_net
        baseline = half_up(total_gross) if convention == CONVENTIONS[0] else rounded_gross
        candidates.append({
            "convention": convention,
            "chargeCents": str(charge), "noOffsetChargeCents": str(baseline),
            "savingsCents": str(baseline - charge), "observedChargeCents": str(int(observed)),
            "differenceCents": str(int(observed) - charge), "compatible": int(observed) == charge,
        })
    return {
        "schemaVersion": 1,
        "model": {
            "annualDayDivisor": 365,
            "rateBasis": "annual percentage divided by 100",
            "balanceBasis": "supplied daily balances; timing is not inferred",
            "offsetRule": "max(loan minus offset, zero)",
            "acceptedConventions": conventions[:],
            "compatibilityMeaning": "exact equality with a selected rounding model only",
        },
        "period": {"start": request["start"], "end": request["end"], "days": days},
        "exactCents": {"withOffset": fraction_json(total_net), "noOffset": fraction_json(total_gross),
                       "savings": fraction_json(total_gross - total_net)},
        "candidates": candidates, "daily": daily,
    }


def expected_interpretation(context, result):
    shape(context, ("source", "inputKind", "declarations"), "context")
    if type(context["source"]) is not str or context["source"] not in ("synthetic", "user"):
        fail("PROVENANCE", "context.source must explicitly name synthetic or user inputs.")
    if type(context["inputKind"]) is not str or context["inputKind"] != "daily-snapshots":
        fail("PROVENANCE", "context.inputKind is unsupported.")
    declarations = context["declarations"]
    shape(declarations, DECLARATIONS, "context.declarations")
    if any(type(value) is not bool for value in declarations.values()):
        fail("DECLARATION", "Each user declaration must be a JSON boolean.")
    complete = all(declarations.values())
    matching = [entry["convention"] for entry in result["candidates"] if entry["compatible"]]
    if context["source"] == "synthetic":
        status = "synthetic-example"
    elif not complete:
        status = "insufficient-evidence"
    elif matching:
        status = "reconciles-under-selected-model"
    else:
        status = "differs-under-selected-model"
    return {"status": status, "matchingConventions": matching,
            "declarationsComplete": complete, "scope": SCOPE}


def compare(actual, expected, field):
    """Exact structure/types matter: Python's True == 1 must never hide a defect."""
    if type(actual) is not type(expected):
        fail("RESULT_MISMATCH", f"{field} has the wrong JSON type.")
    if type(expected) is dict:
        if set(actual) != set(expected):
            fail("RESULT_MISMATCH", f"{field} has missing or unsupported fields.")
        for key in expected:
            compare(actual[key], expected[key], f"{field}.{key}")
    elif type(expected) is list:
        if len(actual) != len(expected):
            fail("RESULT_MISMATCH", f"{field} has the wrong number of items.")
        for index, (got, wanted) in enumerate(zip(actual, expected)):
            compare(got, wanted, f"{field}[{index}]")
    elif actual != expected:
        fail("RESULT_MISMATCH", f"{field} does not match the independent calculation.")


def verify_report(report):
    check_tree(report)
    shape(report, ("format", "version", "input", "context", "result", "interpretation", "sha256"), "report")
    if report["format"] != FORMAT or type(report["version"]) is not int or report["version"] != VERSION:
        fail("VERSION", "Unsupported report format or version.")
    digest = report["sha256"]
    if type(digest) is not str or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
        fail("DIGEST", "sha256 must be a lowercase SHA-256 digest.")
    if canonical_digest(report) != digest:
        fail("DIGEST", "Report contents do not match its recorded self-digest.")
    result = recompute(report["input"])
    compare(report["result"], result, "result")
    interpretation = expected_interpretation(report["context"], result)
    compare(report["interpretation"], interpretation, "interpretation")
    return {
        "verified": True, "scope": SCOPE, "daysChecked": result["period"]["days"],
        "status": interpretation["status"],
        "declarationsComplete": interpretation["declarationsComplete"],
        "matchingConventions": interpretation["matchingConventions"],
        "sha256": digest, "limitation": LIMITATION,
    }


def reject_number(_value):
    fail("JSON", "Floating-point or nonfinite JSON numbers are not supported.")


def bounded_integer(value):
    if len(value.lstrip("-")) > 9:
        fail("RESOURCE_LIMIT", "JSON integers exceed this format's limit.")
    return int(value)


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            fail("JSON", "Duplicate JSON object keys are not permitted.")
        result[key] = value
    return result


def parse_report(raw):
    if type(raw) is not bytes or len(raw) > MAX_REPORT_BYTES:
        fail("RESOURCE_LIMIT", f"Report must be UTF-8 bytes, at most {MAX_REPORT_BYTES} bytes.")
    try:
        text = raw.decode("utf-8", errors="strict")
        result = json.loads(text, object_pairs_hook=unique_object, parse_float=reject_number,
                            parse_int=bounded_integer, parse_constant=reject_number)
    except (UnicodeDecodeError, json.JSONDecodeError, RecursionError, ValueError) as error:
        if isinstance(error, ReportError):
            raise
        fail("JSON", "Report is not supported, strictly encoded JSON.")
    check_tree(result)
    return result


def main(argv=None):
    args = sys.argv[1:] if argv is None else argv
    if len(args) != 1:
        print("Usage: python3 check_report.py report.json", file=sys.stderr)
        return 2
    try:
        with Path(args[0]).open("rb") as source:
            raw = source.read(MAX_REPORT_BYTES + 1)
        result = verify_report(parse_report(raw))
    except (ReportError, OSError) as error:
        code = getattr(error, "code", "READ_ERROR")
        # Do not print an OSError's path: the reader's file name may be private.
        detail = str(error) if isinstance(error, ReportError) else "Could not read the report file."
        print(json.dumps({"verified": False, "code": code, "message": detail}), file=sys.stderr)
        return 1
    print(json.dumps(result, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
