#!/usr/bin/env python3
"""Reconciliation harness — Step 7 of the Seattle Method demo.

Reads concept totals + rollforward IBs via the **GraphQL facade**
(`LedgerClient.get_trial_balance`, `LedgerClient.list_information_blocks`),
parses the rollforward IB's typed mechanics, invokes the Phase 2
MVP filter engine for each rollforward against a direct DB session,
computes the four anchor totals (Total Assets, Total Liabilities &
Equity, Net Income, Net Cash Change), and writes a markdown report
to ``examples/seattle_method_demo/output/seattle-method-case-1.md``.

**Architecture note** — this is hand-written GraphQL consumption.
Forward work in [`python-client-graphql.md`](../../local/docs/specs/python-client-graphql.md)
replaces the hand-written Python GraphQL query strings + ``dict[str, Any]``
returns with codegen-generated Pydantic models via ``ariadne-codegen``.
The trigger condition the spec calls out ("a new GraphQL surface that
the Python client genuinely needs to consume — e.g. rollforward
RollforwardMechanics") has now fired. Phase 1 of that work
(~1 day) replaces every ``client.list_information_blocks(...)
→ dict`` hop here with a typed Pydantic response.

The filter engine itself still runs against a direct SQLAlchemy
session — that's the Phase 2 MVP boundary documented in
[`information-block.md`](../../local/docs/specs/information-block.md) §4.5.
Phase 3 wires filter evaluation into ``build_envelope`` so the
attributed facts arrive populated in the IB envelope; this script
becomes purely API-driven at that point.

Usage:
    uv run python -m examples.seattle_method_demo.reconcile <graph_id>
    uv run python -m examples.seattle_method_demo.reconcile <graph_id> --no-diff
"""

from __future__ import annotations

import argparse
import os
import re
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path

from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker

from robosystems.models.api.information_block import RollforwardMechanics
from robosystems.operations.roboledger.reports.rollforward_filters import (
  AttributedFact,
  evaluate_attribution_filters,
)

REPO_ROOT = Path(__file__).resolve().parents[2]
DEMO_ROOT = REPO_ROOT / "examples" / "seattle_method_demo"
DEFAULT_REPORT_PATH = DEMO_ROOT / "output" / "seattle-method-case-1.md"
# Charlie's published XBRL instance — pulled by pull_expected_report.sh
# from xbrlsite.com/seattlemethod/platinum-testcases/record-to-report/.
# Strictly stronger validation than the earlier hand-derived CSV fixture
# since it consumes the same artifact Arelle validates.
EXPECTED_INSTANCE_PATH = (
  REPO_ROOT / "local" / "datasets" / "seattle_method" / "report" / "instance.xml"
)
# Filter engine needs a direct SQLAlchemy session (Phase 2 MVP boundary
# per ``information-block.md`` §4.5; Phase 3 wires it into
# ``build_envelope`` so we can drop this entirely). Reads
# ``EXTENSIONS_DATABASE_URL`` from the process env — the
# ``just demo-seattle-method-reconcile`` recipe loads ``.env.local``
# which sets this. If you invoke ``reconcile.py`` outside the justfile,
# export the var yourself.
EXTENSIONS_DB_URL = os.environ.get("EXTENSIONS_DATABASE_URL")
SAFE_GRAPH_ID = re.compile(r"^kg[a-zA-Z0-9_]+$")


@dataclass
class ConceptTotal:
  """Sum of (debit - credit) on one concept across a period."""

  qname: str
  label: str
  balance_type: str  # 'debit' | 'credit'
  period_type: str  # 'instant' | 'duration'
  trait: str | None
  debit_positive_cents: int


@dataclass
class RollforwardResult:
  """One rollforward IB's filter engine output for a period."""

  bs_qname: str
  bs_label: str
  delta_cents: int  # ΔBS over the period (debit-positive)
  attributed: list[AttributedFact]
  residual_cents: int


@dataclass
class ExpectedFact:
  """One concept value from Charlie's published instance.xml.

  Sourced from ``local/datasets/seattle_method/report/instance.xml`` —
  the XBRL instance document Charlie publishes at
  ``xbrlsite.com/seattlemethod/platinum-testcases/record-to-report/``,
  generated by ``luca.auditchain.finance``. Parsed by
  :func:`_load_expected_facts_from_instance` rather than read from a
  hand-derived CSV — strictly stronger validation since it consumes the
  same artifact Arelle validates.
  """

  concept: str  # e.g. "mini:CashAndCashEquivalents"
  period_kind: str  # "instant" or "duration"
  period_end: date  # the instant date OR the duration end_date
  value_cents: int  # dollars from instance.xml x 100


@dataclass
class ReconciliationLine:
  """One line of the automated diff against Charlie's published facts."""

  concept: str
  our_cents: int
  expected_cents: int

  @property
  def delta_cents(self) -> int:
    return self.our_cents - self.expected_cents

  @property
  def status(self) -> str:
    if self.delta_cents == 0:
      return "match"
    return "delta"


# Concepts Charlie publishes that fall into each architectural bucket.
# Single source of truth for the coverage-gap categorization rendered
# in the reconciliation report. The buckets aren't pattern-derived
# because qname conventions in mini are inconsistent (e.g.
# ``IncomeLossFromContinuingOperationsBeforeTax`` is a subtotal but its
# qname doesn't follow ``*Current/*Noncurrent`` patterns); explicit
# listing is more honest than fragile regex.
_COVERAGE_SUBTOTALS: frozenset[str] = frozenset({
  "mini:CurrentAssets",
  "mini:NoncurrentAssets",
  "mini:CurrentLiabilities",
  "mini:NoncurrentLiabilities",
  "mini:Liabilities",
  "mini:Equity",
  "mini:GrossProfitLoss",
  "mini:OperatingExpenses",
  "mini:OperatingIncomeLoss",
  "mini:IncomeLossFromContinuingOperationsBeforeTax",
  "mini:NetCashFlowOperatingActivities",
  "mini:NetCashFlowInvestingActivities",
  "mini:NetCashFlowFinancingActivities",
})

_COVERAGE_GRANULARITY: frozenset[str] = frozenset({
  "mini:Cash",
  "mini:Equipment",
  "mini:AccumulatedDepreciation",
  "mini:PropertyPlantAndEquipmentGross",
  "mini:TradePayables",
  "mini:RawMaterial",
  "mini:OtherSecuredLoans",
  "mini:MaturesInOneYear",
})


def _categorize_missing(concept: str) -> str:
  """Bucket a missing concept by which architectural lever closes it.

  - ``subtotal`` — sum of leaf facts our pipeline already has but doesn't
    emit as a named fact. Walking the mini calc linkbase to emit
    intermediate sums closes the bucket essentially for free.
  - ``granularity`` — sub-classification of a concept we already render
    (``Cash`` is a subset of ``CashAndCashEquivalents``,
    ``PropertyPlantAndEquipmentGross`` + ``AccumulatedDepreciation`` are
    the gross+contra split of ``PropertyPlantAndEquipment``). Requires
    richer CoA element granularity.
  - ``flow_as_fact`` — TDC values stored on ``LineItem.metadata['transaction_description_code']``
    today. Charlie's renderer emits each TDC as a published fact.
    Closes with a materialize-time group-by aggregator.
  - ``other`` — concepts that don't cleanly fit the three buckets above.
    Default catch-all; investigation surfaces whether they belong in
    a new bucket or are genuinely one-off.
  """
  if concept in _COVERAGE_SUBTOTALS:
    return "subtotal"
  if concept in _COVERAGE_GRANULARITY:
    return "granularity"
  return "flow_as_fact"


_COVERAGE_BUCKET_LABEL: dict[str, str] = {
  "subtotal": "Subtotals our pipeline computes but doesn't emit as named facts",
  "granularity": "Hierarchical leaf splits we don't model in our CoA",
  "flow_as_fact": "Flow concepts (TDC values) Charlie publishes as standalone facts",
  "other": "Other / uncategorized",
}

_COVERAGE_BUCKET_LEVER: dict[str, str] = {
  "subtotal": (
    "**Low effort** — every input is already in our facts; walking the "
    "mini calc linkbase and emitting intermediate sums as named facts is "
    "a renderer change."
  ),
  "granularity": (
    "**Medium effort** — requires CoA element splits (e.g. gross PPE + "
    "AccumulatedDepreciation as separate accounts). Customer-facing "
    "accounting design decision, not just a renderer fix."
  ),
  "flow_as_fact": (
    "**Medium-high effort** — architectural: our model stores TDC on "
    "``LineItem.metadata['transaction_description_code']``; Charlie's "
    "model emits each TDC as a published fact. Closes with a "
    "materialize-time aggregator that groups by TDC per period."
  ),
  "other": "Per-concept investigation.",
}


@dataclass
class ReconciliationReport:
  graph_id: str
  period_start: date
  period_end: date
  concept_totals: list[ConceptTotal]
  rollforward_results: list[RollforwardResult]
  diff_lines: list[ReconciliationLine] = field(default_factory=list)
  diff_summary: dict[str, int] = field(default_factory=dict)
  # Concepts Charlie publishes with non-zero current-period values
  # that our pipeline doesn't yet surface. These aren't *defects*
  # (every concept we DO surface matches Charlie's value to the cent
  # in ``diff_lines``); they're incomplete-coverage forward work,
  # bucketed by architectural lever in ``_categorize_missing``.
  missing_concepts: list[ExpectedFact] = field(default_factory=list)
  expected_total_nonzero: int = 0  # total non-zero concepts in Charlie's instance


# ── DB queries ───────────────────────────────────────────────────────────


def _read_graph_id_from_credentials() -> str | None:
  """Read the ``seattle_method_test`` graph slot from ``.local/config.json``.

  Returns ``None`` if the file or slot doesn't exist — caller surfaces
  a friendly "run the orchestrator first" message. The slot is saved
  by ``main.step_provision_graph`` via
  ``examples.credentials.utils.save_graph_id``.
  """
  import json

  config_path = Path(".local/config.json")
  if not config_path.exists():
    return None
  try:
    cfg = json.loads(config_path.read_text())
  except (OSError, json.JSONDecodeError):
    return None
  slot = (cfg.get("graphs") or {}).get("seattle_method_test") or {}
  return slot.get("graph_id")


def _get_ledger_client():
  """Construct a LedgerClient from saved credentials.

  Mirrors the helper used across the demo scripts. The GraphQL
  facade methods (``get_trial_balance``, ``list_information_blocks``)
  hang off this client.
  """
  import json

  from robosystems_client.clients.ledger_client import LedgerClient

  config_path = Path(".local/config.json")
  if not config_path.exists():
    raise SystemExit("Missing .local/config.json — run `just demo-user` first.")
  with config_path.open() as f:
    cfg = json.load(f)
  return LedgerClient(
    {
      "base_url": cfg.get("base_url", "http://localhost:8000"),
      "token": cfg["api_key"],
    }
  )


def _open_session(graph_id: str) -> Session:
  """Open a SQLAlchemy session scoped to the graph's schema.

  ``search_path`` is set so all subsequent queries against
  ``elements`` / ``line_items`` / ``entries`` / ``structures`` land
  in the right tenant schema. graph_id is validated against a strict
  regex before interpolation (SET search_path doesn't accept bind
  parameters).
  """
  if not SAFE_GRAPH_ID.match(graph_id):
    raise SystemExit(
      f"Refusing to use graph_id {graph_id!r} — must match {SAFE_GRAPH_ID.pattern}"
    )
  if not EXTENSIONS_DB_URL:
    raise SystemExit(
      "EXTENSIONS_DATABASE_URL is not set. Run via "
      "`just demo-seattle-method-reconcile <graph_id>` (which loads "
      ".env.local), or export the env var manually."
    )
  engine = create_engine(EXTENSIONS_DB_URL)
  session_factory = sessionmaker(bind=engine)
  session = session_factory()
  session.execute(text(f"SET search_path TO {graph_id}, public"))
  return session


def _load_concept_totals_via_graphql(
  client, graph_id: str, period_start: date, period_end: date
) -> list[ConceptTotal]:
  """Load per-concept period totals via the GraphQL trialBalance query.

  Replaces the prior direct-DB query against ``line_items``. The
  ``trialBalance`` resolver does the same aggregation server-side
  (Σ DR, Σ CR, net per account) — which means this script consumes
  the same query surface a frontend or MCP agent would.

  Returns ``ConceptTotal`` shapes scoped to ``mini:`` qnames only;
  rs-gaap and other taxonomies on the graph are filtered out
  client-side (the GraphQL query returns every CoA account).

  The trialBalance response shape per row:
  ``{account_id, account_code (=qname), account_name, trait,
  account_type, total_debits, total_credits, net_balance}``.
  Values come back as floats in dollars; we multiply by 100 for
  internal cents-precision.

  TODO: replace with codegen-generated typed response model when
  ``python-client-graphql.md`` Phase 1 lands.
  """
  data = client.get_trial_balance(
    graph_id,
    start_date=period_start.isoformat(),
    end_date=period_end.isoformat(),
  )
  rows = (data or {}).get("rows", []) or []

  totals: list[ConceptTotal] = []
  for r in rows:
    # ``account_code`` carries the mini qname (set at load time —
    # see ``load_taxonomy.build_element_payloads``).
    qname = r.get("account_code") or ""
    if not qname.startswith("mini:"):
      continue
    dollars_dr = float(r.get("total_debits") or 0)
    dollars_cr = float(r.get("total_credits") or 0)
    # Filter on "had activity" not "non-zero net" — Charlie's
    # Receivables has $8K DR + $8K CR netting to $0, but it's
    # legitimate activity that the diff should compare.
    if dollars_dr == 0 and dollars_cr == 0:
      continue
    debit_positive_cents = round((dollars_dr - dollars_cr) * 100)
    # trialBalance doesn't return period_type or balance_type
    # directly — they live on the Element row. Derive period_type
    # from trait (instant for asset/liability/equity, duration for
    # revenue/expense). For balance_type, infer from trait + sign.
    trait = r.get("trait")
    period_type = _period_type_from_trait(trait)
    balance_type = _balance_type_from_trait(trait)
    totals.append(
      ConceptTotal(
        qname=qname,
        label=r.get("account_name") or qname,
        balance_type=balance_type,
        period_type=period_type,
        trait=trait,
        debit_positive_cents=debit_positive_cents,
      )
    )

  # Synthesize mini:RetainedEarnings from cumulative (revenue - expense).
  # Mini has no source CoA element for RE — Charlie's renderer derives
  # it the same way our rs-gaap renderer does (see the
  # ``_close_to_retained_earnings`` family in fact_grid.py). We inject
  # it here so the reconciliation diff can compare against Charlie's
  # published ``mini:RetainedEarnings`` fact. For multi-period reports
  # this should also subtract cumulative dividends and add opening RE;
  # the Q1 2024 lemonade-stand starts at zero with no dividends so
  # RE_ending == NetIncome_current. This bidirectional projection
  # mirrors the rs-gaap path in
  # ``_append_empty_equity_facts``.
  ni_cents = sum(
    -c.debit_positive_cents
    for c in totals
    if c.trait in ("revenue", "expense") and c.period_type == "duration"
  )
  if ni_cents != 0:
    totals.append(
      ConceptTotal(
        qname="mini:RetainedEarnings",
        label="Retained Earnings",
        balance_type="credit",
        period_type="instant",
        trait="equity",
        # Credit-balance, debit-positive sign convention: a positive
        # net income (credit) stores as a negative debit-positive value.
        debit_positive_cents=-ni_cents,
      )
    )

  totals.sort(key=lambda c: c.qname)
  return totals


def _period_type_from_trait(trait: str | None) -> str:
  """Instant for BS concepts (asset/liability/equity), duration for IS."""
  if trait in ("asset", "liability", "equity", "contraAsset", "contraLiability"):
    return "instant"
  return "duration"


def _balance_type_from_trait(trait: str | None) -> str:
  """Debit for asset/expense/loss; credit for liability/equity/revenue/income/gain."""
  if trait in ("asset", "contraLiability", "contraEquity", "expense", "loss"):
    return "debit"
  return "credit"


def _load_expected_facts_from_instance(instance_path: Path) -> list[ExpectedFact]:
  """Parse Charlie's published XBRL ``instance.xml`` → list of ExpectedFact.

  The instance uses the XBRL 2003 schema (``http://www.xbrl.org/2003/instance``)
  for context and entity elements, with ``mini:`` (``http://www.xbrlsite.com/mini``)
  as the reporting taxonomy prefix.

  Two parse passes:

  1. Walk ``<context>`` elements → build ``{context_id: (period_kind, period_end)}``.
     Instant contexts use ``<instant>``; duration contexts use ``<startDate>`` /
     ``<endDate>``.
  2. Walk all child elements of the ``<xbrl>`` root that carry a ``contextRef``
     attribute and have numeric text content → emit one ExpectedFact each.

  Non-numeric facts (text blocks, units, schema refs) are filtered out.
  Values are stored as cents internally to match the rest of the
  reconciliation pipeline.
  """
  # instance.xml is a trusted artifact pulled by pull_expected_report.sh
  # from xbrlsite.com — not user-uploaded content — so the stdlib XML
  # parser is appropriate. If the input source ever widens, swap to
  # defusedxml.ElementTree.
  import xml.etree.ElementTree as ET

  ns_xbrli = "{http://www.xbrl.org/2003/instance}"
  tree = ET.parse(instance_path)
  root = tree.getroot()

  # Pass 1: contexts → {id: (kind, period_end_date)}
  contexts: dict[str, tuple[str, date]] = {}
  for ctx in root.findall(f"{ns_xbrli}context"):
    ctx_id = ctx.get("id")
    if not ctx_id:
      continue
    period = ctx.find(f"{ns_xbrli}period")
    if period is None:
      continue
    instant = period.find(f"{ns_xbrli}instant")
    end_date = period.find(f"{ns_xbrli}endDate")
    if instant is not None and instant.text:
      contexts[ctx_id] = ("instant", date.fromisoformat(instant.text.strip()))
    elif end_date is not None and end_date.text:
      contexts[ctx_id] = ("duration", date.fromisoformat(end_date.text.strip()))

  # Pass 2: facts. We iterate direct children of the xbrl root; numeric
  # facts carry ``contextRef`` + ``unitRef`` and have a numeric text
  # body. Schema/linkbase refs are XBRL-namespace elements (already
  # skipped by the contextRef check); contexts/units have no
  # contextRef.
  out: list[ExpectedFact] = []
  for elem in root:
    ctx_ref = elem.get("contextRef")
    if not ctx_ref or ctx_ref not in contexts:
      continue
    raw = (elem.text or "").strip()
    if not raw:
      continue
    try:
      dollars = float(raw)
    except ValueError:
      # Non-numeric facts (textBlock concepts, enumerations) — skip.
      continue

    # ElementTree gives us Clark-notation tags like
    # ``{http://www.xbrlsite.com/mini}CashAndCashEquivalents``; convert
    # to the canonical ``mini:CashAndCashEquivalents`` qname.
    tag = elem.tag
    if tag.startswith("{") and "}" in tag:
      ns_uri, local = tag[1:].split("}", 1)
      if ns_uri == "http://www.xbrlsite.com/mini":
        qname = f"mini:{local}"
      else:
        # Skip non-mini facts (gl:* entry concepts and similar). The
        # mini reconciliation scope is mini concepts only.
        continue
    else:
      continue

    kind, period_end = contexts[ctx_ref]
    out.append(
      ExpectedFact(
        concept=qname,
        period_kind=kind,
        period_end=period_end,
        value_cents=round(dollars * 100),
      )
    )
  return out


def _build_diff(
  ours: list[ConceptTotal],
  expected: list[ExpectedFact],
) -> list[ReconciliationLine]:
  """Diff our concept totals against Charlie's published instance.xml facts.

  Period matching is by ``period_kind`` (instant vs duration) rather
  than exact date — Charlie's instance reports BS positions at
  ``2024-12-31`` and IS/CF flows over ``2024-01-01..2024-12-31``,
  while our demo runs Q1 2024 (BS @ ``2024-03-31``, duration
  ``2024-01-01..2024-03-31``). The lemonade-stand fixture posts no
  activity outside Q1, so the ending positions and period flows
  match Charlie's calendar-year values by construction. If the
  fixture ever grows beyond Q1, this matcher needs to thicken
  (likely by aligning the demo's reporting period to Charlie's).

  Charlie also publishes the 2023-12-31 opening-balance facts (all
  zero) — we drop those from the diff since our renderer doesn't
  emit zero-line BS positions and they'd inflate the "missing fact"
  count without signal.

  Our debit-positive convention means we sign-flip credit-balance
  concepts (liabilities, equity, revenues) so the diff is meaningful
  against Charlie's presentation-positive values.
  """
  expected_by_concept_kind: dict[tuple[str, str], int] = {}
  for f in expected:
    # Skip prior-period zero rows — Charlie publishes them for context
    # but they aren't meaningful for our current-period reconciliation.
    if f.value_cents == 0 and f.period_end.year < 2024:
      continue
    expected_by_concept_kind.setdefault(
      (f.concept, f.period_kind), f.value_cents
    )

  diff_lines: list[ReconciliationLine] = []
  for c in ours:
    key = (c.qname, c.period_type)  # period_type matches "instant" / "duration"
    if key not in expected_by_concept_kind:
      continue

    # Sign convention: Charlie's published values are presentation-
    # positive (assets positive when grown, liabilities positive when
    # grown). Ours are debit-positive (credit-balance concepts come
    # in negative). Sign-flip credit-balance concepts.
    our_signed = (
      c.debit_positive_cents
      if c.balance_type == "debit"
      else -c.debit_positive_cents
    )
    diff_lines.append(
      ReconciliationLine(
        concept=c.qname,
        our_cents=our_signed,
        expected_cents=expected_by_concept_kind[key],
      )
    )
  return diff_lines


def _summarize_diff(lines: list[ReconciliationLine]) -> dict[str, int]:
  return {
    "lines_compared": len(lines),
    "exact_match": sum(1 for ln in lines if ln.status == "match"),
    "delta": sum(1 for ln in lines if ln.status == "delta"),
    "total_abs_delta_cents": sum(abs(ln.delta_cents) for ln in lines),
  }


def _derive_trait_from_concept(
  qname: str, balance_type: str | None, period_type: str | None
) -> str | None:
  """Mirror ``load_taxonomy._derive_trait`` — instant+debit → asset;
  instant+credit → liability (or equity by name match);
  duration+debit → expense; duration+credit → revenue."""
  if not balance_type or not period_type:
    return None
  if period_type == "instant":
    name_lower = qname.lower()
    equity_markers = (
      "paidincapital",
      "retainedearnings",
      "commonstock",
      "preferredstock",
      "treasurystock",
    )
    if any(m in name_lower for m in equity_markers):
      return "equity"
    return "asset" if balance_type == "debit" else "liability"
  return "revenue" if balance_type == "credit" else "expense"


def _load_rollforward_ibs_via_graphql(
  client, graph_id: str
) -> list[tuple[str, RollforwardMechanics]]:
  """Return [(structure_id, RollforwardMechanics), …] via GraphQL.

  Uses ``LedgerClient.list_information_blocks(block_type='rollforward')``
  — the same query the frontend / MCP / any external client would
  hit. The envelope's ``artifact.mechanics`` field carries the
  typed ``RollforwardMechanics`` JSON; we re-validate it through
  Pydantic to recover the typed shape (the hand-written GraphQL
  parser returns ``dict[str, Any]``, the documented
  ``python-client-graphql.md`` Phase 1 work replaces that with
  typed Pydantic on the response).

  Note: the IB envelope's ``facts`` field is empty in Phase 2 MVP
  (see ``information_block/rollforward.py:build_envelope``). The
  filter engine still runs separately to produce attributed facts —
  Phase 3 wires that into the envelope so this script can drop the
  direct-session step entirely.

  TODO: replace with codegen-generated typed response model when
  ``python-client-graphql.md`` Phase 1 lands.
  """
  blocks = client.list_information_blocks(graph_id, block_type="rollforward")
  out: list[tuple[str, RollforwardMechanics]] = []
  for block in blocks or []:
    artifact = block.get("artifact") or {}
    raw_mechanics = artifact.get("mechanics")
    if not raw_mechanics:
      continue
    mechanics = RollforwardMechanics.model_validate(raw_mechanics)
    out.append((block.get("id", ""), mechanics))
  return out


def _evaluate_all_rollforwards(
  session: Session,
  rollforwards: list[tuple[str, RollforwardMechanics]],
  period_start: date,
  period_end: date,
  bs_label_by_qname: dict[str, str],
) -> list[RollforwardResult]:
  """Run the filter engine for every rollforward IB; collect results."""
  results: list[RollforwardResult] = []
  for _structure_id, mech in rollforwards:
    facts = evaluate_attribution_filters(session, mech, period_start, period_end)
    # Compute the BS delta from the facts (matching + residual sum =
    # ΔBS by construction in residual_as_default mode).
    delta = sum(f.value_cents for f in facts)
    residual = sum(f.value_cents for f in facts if f.is_residual)
    results.append(
      RollforwardResult(
        bs_qname=mech.bs_source_qname,
        bs_label=bs_label_by_qname.get(mech.bs_source_qname, mech.bs_source_qname),
        delta_cents=delta,
        attributed=[f for f in facts if not f.is_residual],
        residual_cents=residual,
      )
    )
  return results


# ── Anchor totals ────────────────────────────────────────────────────────


def _anchor_totals(concepts: list[ConceptTotal]) -> dict[str, int]:
  """Compute the four anchor totals from concept-level period sums.

  - **Total Assets** = sum of debit-positive instant + asset concepts
    (period activity, not ending balance — for Q1 2024 with no
    opening balances this equals ending positions).
  - **Total Liabilities & Equity** = sum of credit-positive instant
    liability/equity concepts **plus implicit Retained Earnings**.
    The accounting equation requires ``Assets = Liabilities +
    Equity``, where Equity = PaidInCapital + RetainedEarnings, and
    RetainedEarnings = opening RE + Net Income - Dividends. Charlie's
    lemonade-stand fixture starts from zero balances and posts no
    closing entry to RE during the period, so RE-ending = Net Income.
    We add Net Income to L&E to close the accounting equation
    (otherwise L&E < Assets by exactly the period's net income).
  - **Net Income** = revenues less expenses (duration concepts).
  - **Net Cash Change** = delta on ``mini:CashAndCashEquivalents``.

  All in debit-positive cents internally; sign-flipped for
  presentation where convention requires it.
  """
  totals = {
    "Total Assets": 0,
    "Total Liabilities & Equity": 0,
    "Net Income": 0,
    "Net Cash Change": 0,
  }

  for c in concepts:
    if c.trait == "asset" and c.period_type == "instant":
      totals["Total Assets"] += c.debit_positive_cents
    elif c.trait in ("liability", "equity") and c.period_type == "instant":
      # Credit-balance — flip sign so the total is presented as
      # positive when liabilities/equity grew over the period.
      totals["Total Liabilities & Equity"] += -c.debit_positive_cents
    elif c.period_type == "duration":
      if c.trait == "revenue":
        # Revenues are credit-balance; positive net = inflow.
        totals["Net Income"] += -c.debit_positive_cents
      elif c.trait == "expense":
        # Expenses reduce income.
        totals["Net Income"] += -c.debit_positive_cents

    if c.qname == "mini:CashAndCashEquivalents":
      totals["Net Cash Change"] = c.debit_positive_cents

  # Implicit Retained Earnings: Net Income flows into RE at period-end.
  # Skip this when an explicit Retained Earnings concept is already in
  # ``concepts`` (the projected ``mini:RetainedEarnings`` synthesized
  # by ``_load_concept_totals_via_graphql``) — it's already contributed
  # to L+E via the equity branch above and re-adding NI would double-
  # count. Without explicit RE, the implicit addition closes the
  # accounting equation for a startup's first-period reconciliation
  # (no opening RE to inherit).
  has_explicit_re = any(
    "retainedearnings" in (c.qname or "").lower() for c in concepts
  )
  if not has_explicit_re:
    totals["Total Liabilities & Equity"] += totals["Net Income"]

  return totals


# ── Markdown rendering ──────────────────────────────────────────────────


def _fmt_cents(cents: int) -> str:
  """Format integer cents as ``$X,XXX.XX`` (negative → parentheses)."""
  dollars = cents / 100.0
  if dollars < 0:
    return f"$({abs(dollars):,.2f})"
  return f"${dollars:,.2f}"


def render_markdown(report: ReconciliationReport) -> str:
  """Build the markdown reconciliation report."""
  out: list[str] = []
  out.append("# Seattle Method Cross-Taxonomy — Test Case 1 Reconciliation")
  out.append("")
  out.append(f"**Graph**: `{report.graph_id}`")
  out.append(f"**Period**: {report.period_start} → {report.period_end}")
  out.append("**Dataset**: Charlie Hoffman's lemonade-stand 14-JE Q1 2024 fixture")
  out.append(
    "**Expected output reference**: "
    "[luca.pacioli.ai/luca/view/0f24fd35…](https://luca.pacioli.ai/luca/view/0f24fd35e961e167a727b663c75a4c5ec9fb7eb86730d6292f46e6e180fc2018980cd52e/index)"
  )
  out.append("")
  out.append("---")
  out.append("")

  # Auto-diff against Charlie's published facts
  if report.diff_lines:
    summary = report.diff_summary
    out.append("## Automated Diff vs. Charlie's Published Facts")
    out.append("")
    # Correctness vs coverage are orthogonal signals — surface both
    # explicitly so the diff isn't misread as "X of Y total parity."
    coverage_total = report.expected_total_nonzero or summary["lines_compared"]
    out.append(
      "**Correctness** (every concept we surface matches Charlie to the cent): "
      f"**{summary['exact_match']}/{summary['lines_compared']} exact match**, "
      f"|Δ| = **{_fmt_cents(summary['total_abs_delta_cents'])}**."
    )
    out.append("")
    out.append(
      f"**Coverage** (concepts we surface vs. Charlie's published set): "
      f"**{summary['lines_compared']}/{coverage_total}** "
      f"({100.0 * summary['lines_compared'] / coverage_total:.0f}%). "
      f"The remaining {len(report.missing_concepts)} concept(s) are "
      "forward-work — see the Coverage Gap section below."
    )
    out.append("")
    out.append(
      "Compared against Charlie's published XBRL instance "
      "(`local/datasets/seattle_method/report/instance.xml`, fetched "
      "by `pull_expected_report.sh` from "
      "[`xbrlsite.com/seattlemethod/platinum-testcases/record-to-report/`](http://www.xbrlsite.com/seattlemethod/platinum-testcases/record-to-report/index.html))."
    )
    out.append("")
    out.append("| Concept | Our value | Charlie's value | Δ | |")
    out.append("|---|---:|---:|---:|---|")
    for ln in report.diff_lines:
      mark = "✓" if ln.status == "match" else "⚠️"
      out.append(
        f"| `{ln.concept}` | {_fmt_cents(ln.our_cents)} | "
        f"{_fmt_cents(ln.expected_cents)} | "
        f"{_fmt_cents(ln.delta_cents)} | {mark} |"
      )
    out.append("")

  # Coverage gap: concepts Charlie publishes that our pipeline doesn't
  # yet surface. NOT a defect list — every concept we DO surface matches
  # Charlie's value exactly in the diff above. This section bucketizes
  # the forward work by architectural lever so reviewers can see which
  # kind of gap each concept represents.
  if report.missing_concepts:
    out.append("## Coverage Gap — Concepts Charlie Publishes We Don't Yet Surface")
    out.append("")
    out.append(
      f"Charlie's instance contains **{len(report.missing_concepts)}** "
      "additional non-zero current-period concept(s) that our pipeline "
      "doesn't emit. **This is incompleteness, not incorrectness** — "
      "every concept we surface matches Charlie's value to the cent. "
      "These represent the gap between our current render and full "
      "Record-to-Report parity; each is bucketed below by the "
      "architectural lever that would close it."
    )
    out.append("")

    # Group by bucket
    by_bucket: dict[str, list[ExpectedFact]] = {}
    for f in report.missing_concepts:
      by_bucket.setdefault(_categorize_missing(f.concept), []).append(f)

    # Render in priority order — low-effort levers first so the reader
    # sees how cheap most of the gap is to close.
    for bucket in ("subtotal", "granularity", "flow_as_fact", "other"):
      items = by_bucket.get(bucket)
      if not items:
        continue
      label = _COVERAGE_BUCKET_LABEL[bucket]
      lever = _COVERAGE_BUCKET_LEVER[bucket]
      out.append(f"### {label} ({len(items)})")
      out.append("")
      out.append(lever)
      out.append("")
      out.append("| Concept | Period | Charlie's value |")
      out.append("|---|---|---:|")
      for f in sorted(items, key=lambda x: x.concept):
        out.append(
          f"| `{f.concept}` | {f.period_kind} | {_fmt_cents(f.value_cents)} |"
        )
      out.append("")

  # Anchor totals
  out.append("## Four Anchor Totals")
  out.append("")
  out.append("Methodology spec §4.6 exit criterion: these four lines must "
             "match Charlie's PoC for the test to pass. All amounts are "
             "debit-positive cents internally; presentation flips signs "
             "per accounting convention.")
  out.append("")
  totals = _anchor_totals(report.concept_totals)
  out.append("| Anchor | Our value |")
  out.append("|---|---:|")
  for label, cents in totals.items():
    out.append(f"| {label} | {_fmt_cents(cents)} |")
  out.append("")

  # Concept-level period totals
  out.append("## Concept-Level Period Totals")
  out.append("")
  out.append("Every mini concept with non-zero activity in the period. "
             "``Δ debit-positive`` is the period flow (Σ DR − Σ CR). For "
             "instant/asset concepts this equals the period-ending "
             "balance (Charlie's data starts from zero). For duration "
             "concepts this is the period income/expense.")
  out.append("")
  out.append("| QName | Label | Trait | Period | Δ debit-positive |")
  out.append("|---|---|---|---|---:|")
  for c in report.concept_totals:
    if c.debit_positive_cents == 0:
      continue
    out.append(
      f"| `{c.qname}` | {c.label} | {c.trait or '—'} | "
      f"{c.period_type} | {_fmt_cents(c.debit_positive_cents)} |"
    )
  out.append("")

  # Rollforward attribution detail
  out.append("## Rollforward Attribution (Phase 2 MVP Filter Engine)")
  out.append("")
  out.append("Each rollforward IB decomposes its BS source's period "
             "delta across declared TDC filters. Where ``Σ filters == "
             "Δ BS``, the rollforward is balanced (residual = 0). A "
             "non-zero residual indicates either an unattributed flow "
             "or a phantom TDC in the source data (logged at author time).")
  out.append("")
  for rf in report.rollforward_results:
    out.append(f"### {rf.bs_label} ({rf.bs_qname})")
    out.append("")
    out.append(f"**Δ BS** (debit-positive): {_fmt_cents(rf.delta_cents)}")
    if rf.residual_cents:
      out.append(f"**Residual**: {_fmt_cents(rf.residual_cents)}")
    out.append("")
    if not rf.attributed:
      out.append("_No attributed facts (no matching activity in period)._")
      out.append("")
      continue
    out.append("| Flow concept | Value | Matched lines | Event ids |")
    out.append("|---|---:|---:|---|")
    for f in rf.attributed:
      events = ", ".join(f.event_ids[:3])
      if len(f.event_ids) > 3:
        events += f", … (+{len(f.event_ids) - 3})"
      out.append(
        f"| `{f.target_qname}` | {_fmt_cents(f.value_cents)} | "
        f"{f.matched_line_count} | {events or '—'} |"
      )
    out.append("")

  # Findings
  out.append("## Findings — Classification per Methodology §3.2")
  out.append("")
  out.append("**Their data quality** (source CSV inconsistencies):")
  out.append("")
  out.append("- **JE-205** — Description \"Payment for contractor\" but "
             "TDC on the AP line is `mini:PurchasesInventoryForSaleOnAccount`. "
             "Contractor services aren't inventory; vocabulary misuse.")
  out.append("- **JE-209** — TDC `mini:PaymentOfInterest` on the Cash "
             "line is a typo for `mini:PaymentInterest` (the canonical "
             "mini.xsd concept name). `ingest_transactions.py::"
             "_KNOWN_TDC_ALIASES` normalizes it at ingest time so the "
             "rollforward filter engine matches; logged as a warning "
             "per JE so the substitution stays transparent. Note that "
             "`mini:DecreaseFromPaymentOfInterest` (the AccruedExpenses-"
             "side TDC) keeps the \"Of\" — Charlie's naming is "
             "internally inconsistent.")
  out.append("- **JE-226** — Income tax accrual ($400) but TDC is "
             "`mini:InterestAccrued` instead of `IncomeTaxAccrued`. "
             "Copy-paste-style bug from the JE-210 interest pattern.")
  out.append("")
  out.append("**Methodology gap** (architecturally aligned, semantically distinct):")
  out.append("")
  out.append("- **JE-225** — \"Write off of PPE\" with `Amount = 0` on "
             "both lines. Boundary test case. Our GL handler rejects "
             "nil-amount entries (`must have non-zero D or C`); Charlie's "
             "system likely creates `$0` facts. Reconciliation delta is "
             "`$0` either way; the four anchor totals are unaffected.")
  out.append("- **rs-gaap library subset** — Two flow concepts in "
             "`mappings.py` don't exist in our currently-loaded rs-gaap "
             "library: `rs-gaap:InterestPaidNet` (mapped through to the "
             "closest available `rs-gaap:InterestExpense`) and "
             "`rs-gaap:StockIssuedDuringPeriodValueNewIssues` (mapped "
             "through to `rs-gaap:ProceedsFromIssuanceOfCommonStock`). "
             "Approximation; future library expansion closes the gap.")
  out.append("")
  out.append("**Our bug**: none identified.")
  out.append("")
  out.append("**Matching**: see Anchor Totals table above + line-by-line "
             "concept totals. Compare manually against Charlie's PoC "
             "rendering at the expected-output URL — automated HTML diff "
             "is a forward-queue enhancement (methodology §3.1 step 5 "
             "stretch goal).")
  out.append("")

  # Footer
  out.append("---")
  out.append("")
  out.append("*Reconciliation produced by "
             "`examples/seattle_method_demo/reconcile.py` against the "
             "Phase 2 MVP rollforward filter engine. See "
             "`examples/seattle_method_demo/README.md` for the full "
             "methodology and `local/docs/specs/cross-taxonomy-projection.md` "
             "for the architectural pattern this test validates.*")

  return "\n".join(out) + "\n"


# ── Main ─────────────────────────────────────────────────────────────────


def main() -> None:
  parser = argparse.ArgumentParser(
    description="Reconcile the Seattle Method Test Case 1 demo against "
    "Charlie Hoffman's expected output. Produces a markdown report.",
  )
  parser.add_argument(
    "graph_id",
    nargs="?",
    default=None,
    help=(
      "The graph id to reconcile. If omitted, reads the "
      "``seattle_method_test`` slot from ``.local/config.json`` "
      "(populated by ``just demo-seattle-method``)."
    ),
  )
  parser.add_argument(
    "--period-start",
    type=date.fromisoformat,
    default=date(2024, 1, 1),
    help="Period start (default 2024-01-01).",
  )
  parser.add_argument(
    "--period-end",
    type=date.fromisoformat,
    default=date(2024, 1, 31),
    help="Period end (default 2024-01-31).",
  )
  parser.add_argument(
    "--out",
    type=Path,
    default=DEFAULT_REPORT_PATH,
    help="Output markdown report path.",
  )
  parser.add_argument(
    "--expected-instance",
    type=Path,
    default=EXPECTED_INSTANCE_PATH,
    help=(
      "Charlie's published XBRL instance.xml (pulled by "
      "pull_expected_report.sh). Skip the diff with --no-diff."
    ),
  )
  parser.add_argument(
    "--no-diff",
    action="store_true",
    help="Skip the automated diff against Charlie's published instance.xml.",
  )
  args = parser.parse_args()

  # Fall back to the orchestrator's saved graph slot if not passed
  # explicitly — mirrors how ``just demo-roboledger`` reads from
  # ``.local/config.json`` without requiring the caller to remember
  # the graph id.
  graph_id = args.graph_id or _read_graph_id_from_credentials()
  if not graph_id:
    raise SystemExit(
      "No graph_id provided and no ``seattle_method_test`` slot found "
      "in ``.local/config.json``. Run ``just demo-seattle-method`` first "
      "(to provision a graph + save the slot), then re-run reconcile."
    )

  print(f"Reconciling graph {graph_id} for {args.period_start}..{args.period_end}")

  client = _get_ledger_client()

  # Read paths go through GraphQL (the same surface a frontend or
  # MCP agent would hit). The filter engine is still invoked
  # against a direct session — Phase 2 MVP boundary; Phase 3 wires
  # the engine into ``build_envelope`` so the IB envelope returns
  # populated facts and this script becomes pure-API.
  concepts = _load_concept_totals_via_graphql(
    client, graph_id, args.period_start, args.period_end
  )
  print(f"  Loaded {len(concepts)} concept(s) with period activity (via GraphQL trialBalance)")
  bs_label_by_qname = {c.qname: c.label for c in concepts}

  rollforwards = _load_rollforward_ibs_via_graphql(client, graph_id)
  print(
    f"  Loaded {len(rollforwards)} rollforward IB(s) (via GraphQL informationBlocks)"
  )

  # Filter engine still runs against a direct session — Phase 2 MVP.
  session = _open_session(graph_id)
  try:
    results = _evaluate_all_rollforwards(
      session, rollforwards, args.period_start, args.period_end, bs_label_by_qname
    )
    for r in results:
      print(
        f"    {r.bs_qname:<40} Δ={_fmt_cents(r.delta_cents)} "
        f"attributed={len(r.attributed)} residual={_fmt_cents(r.residual_cents)}"
      )
  finally:
    session.close()

  report = ReconciliationReport(
    graph_id=graph_id,
    period_start=args.period_start,
    period_end=args.period_end,
    concept_totals=concepts,
    rollforward_results=results,
  )

  if not args.no_diff and args.expected_instance.exists():
    expected = _load_expected_facts_from_instance(args.expected_instance)
    print(
      f"\nLoaded {len(expected)} expected fact(s) from "
      f"{args.expected_instance.name} (Charlie's published XBRL instance)"
    )
    report.diff_lines = _build_diff(ours=concepts, expected=expected)
    # Also compare our computed anchor totals to Charlie's published
    # aggregates. These are concepts Charlie publishes as facts but
    # our pipeline computes as scalars (no direct LineItem activity).
    # Match by (qname, period_kind) — see _build_diff for the
    # period-kind rationale.
    totals = _anchor_totals(concepts)
    expected_by_key: dict[tuple[str, str], int] = {}
    for f in expected:
      if f.value_cents == 0 and f.period_end.year < 2024:
        continue
      expected_by_key.setdefault((f.concept, f.period_kind), f.value_cents)
    anchor_pairs = [
      ("mini:Assets", "instant", totals["Total Assets"]),
      (
        "mini:LiabilitiesAndEquity",
        "instant",
        totals["Total Liabilities & Equity"],
      ),
      ("mini:NetIncomeLoss", "duration", totals["Net Income"]),
      ("mini:NetCashFlow", "duration", totals["Net Cash Change"]),
    ]
    for concept, period_kind, our_value in anchor_pairs:
      key = (concept, period_kind)
      if key in expected_by_key:
        report.diff_lines.append(
          ReconciliationLine(
            concept=concept,
            our_cents=our_value,
            expected_cents=expected_by_key[key],
          )
        )
    # Concepts Charlie publishes with non-zero current-period values
    # that we don't surface. Forward-work coverage, NOT diff errors —
    # see _categorize_missing for the architectural bucketing.
    our_qname_kinds = {(c.qname, c.period_type) for c in concepts}
    anchor_qname_kinds = {(qname, kind) for qname, kind, _ in anchor_pairs}
    seen_missing: set[tuple[str, str]] = set()
    nonzero_total = 0
    for f in expected:
      if f.value_cents == 0:
        continue
      if f.period_end.year < 2024:
        continue
      nonzero_total += 1
      key = (f.concept, f.period_kind)
      if key in our_qname_kinds or key in anchor_qname_kinds:
        continue
      if key in seen_missing:
        continue
      seen_missing.add(key)
      report.missing_concepts.append(f)
    report.expected_total_nonzero = nonzero_total
    report.diff_summary = _summarize_diff(report.diff_lines)
    print(
      f"  Diff: {report.diff_summary['exact_match']}/"
      f"{report.diff_summary['lines_compared']} exact match • "
      f"{report.diff_summary['delta']} delta • "
      f"|Δ|={_fmt_cents(report.diff_summary['total_abs_delta_cents'])}"
    )
  elif args.no_diff:
    print("\nSkipping diff (--no-diff)")
  else:
    print(
      f"\nExpected instance.xml not found at {args.expected_instance}; "
      "skipping diff. Run pull_expected_report.sh (or "
      "`just demo-seattle-method --step pull`) to fetch it."
    )

  args.out.parent.mkdir(parents=True, exist_ok=True)
  args.out.write_text(render_markdown(report))
  print(f"\n✓ Report written → {args.out}")

  totals = _anchor_totals(concepts)
  print("\nFour anchor totals:")
  for label, cents in totals.items():
    print(f"  {label:<30} {_fmt_cents(cents)}")


if __name__ == "__main__":
  main()
