"""Canonical transfer matching: deterministic bilateral pairing and event layer.

Immutable bank source rows are observations. This module turns one or two
confirmed observations into one canonical transfer event by appending
decisions to an append-only log and maintaining a rebuildable current
projection. It never modifies ``source_rows``.

Confirmed business rules (B-43/B-114):

- A single observation stays in the unresolved amount bucket even when both
  participants are uniquely identified by approved accounts; only a bilateral
  merge (``intercompany`` + ``paired``) or an administrator confirmation based
  on evidence (``intercompany`` + locked manual single) enters the confirmed
  B-44 balance.
- The economic date of a cross-day event is the payer's outgoing bank posting
  time, never the first-import time.
- Automatic window v1: same non-empty reference + mirrored accounts <= 3
  calendar days (M1); exact account mirror without reference <= 1 day (M2);
  alias/personal-mapping mirror <= 1 day with reference or summary equality
  (M3). Anything over-window or with 2+ same-tier candidates goes to review.
- Personal transit mappings bind a specific account, an effective interval, an
  allowed direction and a represented company, and require administrator
  approval; names are supporting evidence only.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal, InvalidOperation
import json
import re
import sqlite3

from .auth import audit
from .db import utc_now
from .master_data import (
    is_identifiable,
    normalize_account_number,
)
from . import personal_transit

RULE_VERSION = "transfer-match-v1"

CLASSIFICATION_UNRESOLVED = "unresolved"
CLASSIFICATION_NEEDS_REVIEW = "needs_review"
CLASSIFICATION_INTERNAL_SINGLE = "internal_single"
CLASSIFICATION_INTERCOMPANY = "intercompany"
CLASSIFICATION_SAME_COMPANY = "same_company"
CLASSIFICATION_EXTERNAL = "external"

PAIRING_SINGLE = "single"
PAIRING_PAIRED = "paired"
PAIRING_NA = "not_applicable"

MODE_AUTO = "auto"
MODE_MANUAL = "manual"
MODE_REVERSAL = "reversal"

MAX_DATE_DIFF_DAYS = 3
AUTO_TIERS = ("M1", "M2", "M3")
REVIEW_TIERS = ("R1", "R2")

_FULL_WIDTH = str.maketrans(
    "ＡＢＣＤＥＦＧＨＩＪＫＬＭＮＯＰＱＲＳＴＵＶＷＸＹＺ"
    "ａｂｃｄｅｆｇｈｉｊｋｌｍｎｏｐｑｒｓｔｕｖｗｘｙｚ０１２３４５６７８９",
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    "abcdefghijklmnopqrstuvwxyz0123456789",
)
# Bank noise words stripped from summaries before comparison (M3). Only exact
# equality after normalization decides; no fuzzy matching or AI guesses.
_SUMMARY_NOISE = (
    "转账", "汇款", "网上银行", "手机银行", "跨行", "行内", "对公",
    "跨行转账", "行内转账", "网银转账", "速汇", "实时", "普通", "加急",
)


class MatchConflictError(ValueError):
    """A decision/claim/lock conflict (mapped to HTTP 409)."""


class MatchInputError(ValueError):
    """Invalid input for matching (mapped to HTTP 400/422)."""


# ---------------------------------------------------------------------------
# Small value objects
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Resolved:
    """One participant endpoint resolved from approved identifiers."""

    role: str
    company_id: int
    bank_account_id: int | None
    resolve_method: str
    alias_id: int | None = None
    mapping_id: int | None = None
    evidence: dict = field(default_factory=dict)


@dataclass(frozen=True)
class RowResolution:
    direction: str | None
    own: Resolved | None
    own_reason: str | None
    counterparty: Resolved | None
    counterparty_reason: str | None


@dataclass(frozen=True)
class CandidateHit:
    row_id: int
    tier: str
    date_diff_days: int
    account_mirror: str  # exact | alias | none
    reference_match: str | None  # same | conflict | None
    summary_match: bool


# ---------------------------------------------------------------------------
# Parsing and normalization helpers
# ---------------------------------------------------------------------------


def parse_direction(income: object, expense: object) -> str | None:
    """Exactly one positive amount decides the direction; anything else is None."""
    income = Decimal(str(income or "0"))
    expense = Decimal(str(expense or "0"))
    if expense > 0 and income == 0:
        return "outgoing"
    if income > 0 and expense == 0:
        return "incoming"
    return None


def amount_of_row(row: sqlite3.Row) -> Decimal | None:
    income = Decimal(str(row["income"] or "0"))
    expense = Decimal(str(row["expense"] or "0"))
    if income > 0 and expense == 0:
        return income
    if expense > 0 and income == 0:
        return expense
    return None


def normalize_reference(value: object) -> str | None:
    text = str(value or "").translate(_FULL_WIDTH)
    text = re.sub(r"\s+", "", text)
    return text or None


def normalize_summary(row: sqlite3.Row) -> str:
    parts = []
    for key in ("summary", "purpose"):
        text = str(row[key] or "").translate(_FULL_WIDTH)
        text = re.sub(r"[\s\ufeff]+", "", text)
        for noise in _SUMMARY_NOISE:
            text = text.replace(noise, "")
        if text:
            parts.append(text)
    return "|".join(parts)


# ---------------------------------------------------------------------------
# Transaction helpers
# ---------------------------------------------------------------------------


def _ensure_transaction(connection: sqlite3.Connection):
    """Begin an immediate transaction unless one is already open.

    ``reconcile_rows`` / ``apply_manual_decision`` may run standalone (they own
    the transaction) or nested inside the caller's ``with connection:`` block
    (the worksheet-confirm flow); nested calls never start their own commit.
    """
    began = False
    if not connection.in_transaction:
        connection.execute("BEGIN IMMEDIATE")
        began = True
    return began


# ---------------------------------------------------------------------------
# Participant resolution
# ---------------------------------------------------------------------------


def _normalized_account(value: object) -> str | None:
    text = str(value or "").strip()
    if not text:
        return None
    try:
        return normalize_account_number(text)
    except ValueError:
        return None


def _resolve_own(
    connection: sqlite3.Connection,
    row: sqlite3.Row,
    on_date: str,
    upload_account_id: int | None,
) -> tuple[Resolved | None, str | None]:
    """Resolve the owner endpoint: source-row own account first, the approved
    upload account as fallback. A company conflict between the two is a review
    signal, never a guess.
    """
    own_number = _normalized_account(row["own_account"])

    own: Resolved | None = None
    own_reason: str | None = None
    if own_number is not None:
        account = connection.execute(
            "SELECT * FROM bank_accounts WHERE account_number = ?", (own_number,)
        ).fetchone()
        if account is not None and is_identifiable(account, on_date):
            own = Resolved(
                role="", company_id=account["company_id"],
                bank_account_id=account["id"],
                resolve_method="own_exact",
                evidence={"account_number": own_number, "via": "own_account"},
            )
        else:
            own_reason = "本方账号未能匹配到生效期内已批准的账户"

    if upload_account_id is not None:
        upload = connection.execute(
            "SELECT * FROM bank_accounts WHERE id = ?", (upload_account_id,)
        ).fetchone()
        if upload is not None and is_identifiable(upload, on_date):
            if own is not None and own.company_id != upload["company_id"]:
                return None, "本方账号与上传账户归属公司冲突"
            if own is None:
                own = Resolved(
                    role="", company_id=upload["company_id"],
                    bank_account_id=upload["id"],
                    resolve_method="upload_account",
                    evidence={"account_number": upload["account_number"],
                              "via": "upload_bank_account"},
                )
                own_reason = None

    return own, own_reason


def _resolve_counterparty(
    connection: sqlite3.Connection,
    row: sqlite3.Row,
    on_date: str,
    direction: str | None,
) -> tuple[Resolved | None, str | None]:
    """Resolve the counterparty strictly by account identifier tiers.

    Tier order: exact ``bank_accounts`` number -> approved ``account`` alias ->
    approved in-window personal transit mapping (with direction allowed). Names
    never resolve a counterparty; they only supply conflict evidence.
    """
    number = _normalized_account(row["counterparty_account"])
    if number is None:
        return None, "无对方账号"

    exact = connection.execute(
        "SELECT * FROM bank_accounts WHERE account_number = ?", (number,)
    ).fetchone()
    if exact is not None and is_identifiable(exact, on_date):
        return _finish_counterparty(connection, row, exact, "counterparty_exact", None, None)

    alias_rows = connection.execute(
        """
        SELECT a.*, al.id AS alias_row_id, al.effective_from AS alias_from,
               al.effective_to AS alias_to
        FROM account_aliases al
        JOIN bank_accounts a ON a.id = al.bank_account_id
        WHERE al.alias_kind = 'account' AND al.alias_value = ?
        """,
        (number,),
    ).fetchall()
    in_window = [
        alias for alias in alias_rows
        if _alias_in_window(alias, on_date) and is_identifiable(alias, on_date)
    ]
    if len(in_window) == 1:
        alias = in_window[0]
        return _finish_counterparty(
            connection, row, alias, "account_alias", alias["alias_row_id"], None,
        )
    if len(in_window) > 1:
        return None, "对方账号命中多个账号别名，落入人工审核"

    mapping = connection.execute(
        "SELECT * FROM personal_transit_mappings WHERE account_number = ?", (number,)
    ).fetchone()
    if mapping is not None and personal_transit.is_mapping_active(mapping, on_date):
        if direction is not None and not personal_transit.direction_allowed(mapping, direction):
            return None, "个人过账映射方向与该笔交易不符"
        return _finish_counterparty(
            connection, row, mapping, "personal_mapping", None, mapping["id"],
        )

    return None, "对方账号未能匹配到已批准账户、账号别名或个人过账映射"


def _finish_counterparty(
    connection: sqlite3.Connection,
    row: sqlite3.Row,
    target: sqlite3.Row,
    method: str,
    alias_id: int | None,
    mapping_id: int | None,
) -> tuple[Resolved | None, str | None]:
    """Attach a resolved counterparty; a conflicting name alias downgrades it.

    A name alias belonging to a different company than the account evidence is
    a conflict that must go to review. A name alias matching the same company
    is recorded as supporting evidence only.
    """
    company_id = target["company_id"] if "company_id" in target.keys() else target["represented_company_id"]
    bank_account_id = target["id"] if "company_id" in target.keys() else None
    evidence: dict[str, object] = {
        "account_number": _normalized_account(row["counterparty_account"]),
        "via": method,
    }
    if mapping_id is not None:
        evidence["mapping_id"] = mapping_id
    if alias_id is not None:
        evidence["alias_id"] = alias_id

    name = str(row["counterparty_name"] or "").strip()
    if name:
        name_rows = connection.execute(
            """
            SELECT a.company_id, al.bank_account_id
            FROM account_aliases al
            JOIN bank_accounts a ON a.id = al.bank_account_id
            WHERE al.alias_kind = 'name' AND al.alias_value = ?
            """,
            (re.sub(r"\s+", "", name),),
        ).fetchall()
        conflicts = [hit for hit in name_rows if hit["company_id"] != company_id]
        if conflicts:
            return None, "对方户名别名归属与账号证据冲突，落入人工审核"
        if name_rows:
            evidence["name_alias"] = name

    resolved = Resolved(
        role="", company_id=company_id, bank_account_id=bank_account_id,
        resolve_method=method, alias_id=alias_id, mapping_id=mapping_id,
        evidence=evidence,
    )
    return resolved, None


def _alias_in_window(alias: sqlite3.Row, on_date: str) -> bool:
    if alias["alias_from"] and on_date < alias["alias_from"]:
        return False
    if alias["alias_to"] and on_date > alias["alias_to"]:
        return False
    return True


def _load_rows(
    connection: sqlite3.Connection, row_ids: list[int]
) -> list[sqlite3.Row]:
    """Load source rows joined with their batch ownership/upload account."""
    placeholders = ",".join("?" for _ in row_ids)
    return connection.execute(
        f"""
        SELECT r.*, s.sheet_name, rv.review_status,
               b.company_id AS batch_company_id,
               b.upload_bank_account_id AS upload_bank_account_id
        FROM source_rows r
        JOIN sheet_batches s ON s.id = r.sheet_batch_id
        JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
        JOIN import_batches b ON b.id = s.import_batch_id
        WHERE r.id IN ({placeholders})
        ORDER BY r.id
        """,
        row_ids,
    ).fetchall()


def resolve_row(
    connection: sqlite3.Connection, row: sqlite3.Row
) -> RowResolution:
    """Resolve one source row's direction, own and counterparty endpoints."""
    on_date = _date_only(row["transaction_at"])
    direction = parse_direction(row["income"], row["expense"])
    own, own_reason = _resolve_own(
        connection, row, on_date,
        row["upload_bank_account_id"] if "upload_bank_account_id" in row.keys() else None,
    )

    if (
        own is not None
        and row["batch_company_id"] is not None
        and own.resolve_method == "upload_account"
        and own.company_id != row["batch_company_id"]
    ):
        own, own_reason = None, "上传账户与批次公司不一致"

    counterparty, cp_reason = None, None
    if direction is not None and own is not None:
        counterparty, cp_reason = _resolve_counterparty(
            connection, row, on_date, direction
        )
    elif direction is not None:
        cp_reason = "本方未解析，无法解析对方"

    return RowResolution(
        direction, own, own_reason, counterparty, cp_reason,
    )


def _date_only(value: object) -> str:
    text = str(value or "")[:10]
    return text


# ---------------------------------------------------------------------------
# Candidate search
# ---------------------------------------------------------------------------


def _confirmed_rows(connection: sqlite3.Connection) -> list[sqlite3.Row]:
    rows = connection.execute(
        """
        SELECT r.*, s.sheet_name, rv.review_status,
               b.company_id AS batch_company_id,
               b.upload_bank_account_id AS upload_bank_account_id
        FROM source_rows r
        JOIN sheet_batches s ON s.id = r.sheet_batch_id
        JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id
        JOIN import_batches b ON b.id = s.import_batch_id
        WHERE rv.review_status = 'confirmed'
        ORDER BY r.id
        """
    ).fetchall()
    locked = connection.execute(
        """
        SELECT c.source_row_id FROM transfer_observation_claims c
        JOIN transfer_match_decisions d ON d.id = c.decision_id
        WHERE d.locked = 1
        """
    ).fetchall()
    locked_ids = {item["source_row_id"] for item in locked}
    return [row for row in rows if row["id"] not in locked_ids]


def _find_candidates(
    connection: sqlite3.Connection,
    row: sqlite3.Row,
    res: RowResolution,
    pool: list[sqlite3.Row],
    resolutions: dict[int, RowResolution],
) -> tuple[list[CandidateHit], str | None]:
    """Deterministic candidate search over confirmed rows.

    Returns ``(hits, None)`` when a unique auto-tier candidate decides the
    match, ``(hits, reason)`` with reason ``r1_review``/``r2_ambiguous``/None
    when the outcome needs review. Only hits passing the hard gates are kept.
    """
    row_amount = amount_of_row(row)
    row_currency = str(row["currency"] or "").strip()
    if res.own is None or res.counterparty is None:
        return [], None
    if row_amount is None or not row_currency:
        return [], None

    row_date = _parse_datetime(row["transaction_at"]).date()

    # Rows already awaiting review may not be grabbed by another automatic
    # match: pairing with one of several same-tier candidates would be a
    # processing-order tie-break, which is never allowed.
    blocked = {
        item["source_row_id"]
        for item in connection.execute(
            """
            SELECT c.source_row_id FROM transfer_observation_claims c
            JOIN transfer_match_decisions d ON d.id = c.decision_id
            WHERE d.classification = ?
            """,
            (CLASSIFICATION_NEEDS_REVIEW,),
        )
    }

    hits: list[CandidateHit] = []
    for other in pool:
        if other["id"] == row["id"] or other["id"] in blocked:
            continue
        other_res = resolutions[other["id"]]
        if other_res.direction is None or other_res.own is None or other_res.counterparty is None:
            continue
        if other_res.direction == res.direction:
            continue
        other_amount = amount_of_row(other)
        other_currency = str(other["currency"] or "").strip()
        if other_amount != row_amount or not other_currency or other_currency != row_currency:
            continue
        other_date = _parse_datetime(other["transaction_at"]).date()
        date_diff = abs((row_date - other_date).days)
        # Over-window pairs stay as R1 review candidates: the hard gate only
        # filters money/direction/company reciprocity, never the date window.
        if not _reciprocal(row, other, res, other_res):
            continue
        tier = _classify_tier(row, other, res, other_res)
        if tier is None:
            continue
        mirror = _mirror_level(row, other, res, other_res) or "none"
        ref_match, summary_match = _tier_evidence(row, other)
        hits.append(
            CandidateHit(
                row_id=other["id"], tier=tier, date_diff_days=date_diff,
                account_mirror=mirror, reference_match=ref_match,
                summary_match=summary_match,
            )
        )

    if not hits:
        return [], None

    best_tier = min(hits, key=lambda hit: AUTO_TIERS.index(hit.tier) if hit.tier in AUTO_TIERS else len(AUTO_TIERS)).tier
    if best_tier in AUTO_TIERS:
        same_tier = [hit for hit in hits if hit.tier == best_tier]
        if len(same_tier) == 1:
            return same_tier, None
        return hits, "r2_ambiguous"
    return hits, "r1_review"


def _reciprocal(
    row: sqlite3.Row,
    other: sqlite3.Row,
    res: RowResolution,
    other_res: RowResolution,
) -> bool:
    return (
        res.own.company_id == other_res.counterparty.company_id
        and res.counterparty.company_id == other_res.own.company_id
    )


def _mirror_level(
    row: sqlite3.Row,
    other: sqlite3.Row,
    res: RowResolution,
    other_res: RowResolution,
) -> str | None:
    row_own = _normalized_account(row["own_account"])
    row_cp = _normalized_account(row["counterparty_account"])
    other_own = _normalized_account(other["own_account"])
    other_cp = _normalized_account(other["counterparty_account"])

    exact_pairs = 0
    if row_own and row_own == other_cp:
        exact_pairs += 1
    if row_cp and row_cp == other_own:
        exact_pairs += 1
    if exact_pairs == 2:
        return "exact"
    if exact_pairs == 1:
        methods = {
            res.counterparty.resolve_method,
            other_res.counterparty.resolve_method,
        }
        if methods & {"account_alias", "personal_mapping"}:
            return "alias"
    return None


def _reference_same(row: sqlite3.Row, other: sqlite3.Row) -> bool:
    """Positive reference evidence: both sides carry one equal reference.

    ``None == None`` must never count as reference agreement; only "both
    reference numbers exist and are equal" satisfies the reference layer of the
    M1/M3 evidence. A single-sided reference is not equality either.
    """
    ref_row = normalize_reference(row["reference"])
    ref_other = normalize_reference(other["reference"])
    return bool(ref_row and ref_other and ref_row == ref_other)


def _reference_conflict(row: sqlite3.Row, other: sqlite3.Row) -> bool:
    ref_row = normalize_reference(row["reference"])
    ref_other = normalize_reference(other["reference"])
    return bool(ref_row and ref_other and ref_row != ref_other)


def _classify_tier(
    row: sqlite3.Row,
    other: sqlite3.Row,
    res: RowResolution,
    other_res: RowResolution,
) -> str | None:
    mirror = _mirror_level(row, other, res, other_res)
    if mirror is None:
        return "R1"
    row_date = _parse_datetime(row["transaction_at"]).date()
    other_date = _parse_datetime(other["transaction_at"]).date()
    date_diff = abs((row_date - other_date).days)
    ref_same = _reference_same(row, other)
    ref_conflict = _reference_conflict(row, other)

    if mirror == "exact":
        if ref_same and date_diff <= 3:
            return "M1"
        if date_diff <= 1 and not ref_conflict:
            return "M2"
        return "R1"
    # alias / personal-mapping mirror: only equal references (both present) or a
    # deterministically equal summary is positive evidence; an absent reference
    # is not.
    if date_diff <= 1 and not ref_conflict and (ref_same or _summary_equal(row, other)):
        return "M3"
    return "R1"


def _tier_evidence(
    row: sqlite3.Row, other: sqlite3.Row
) -> tuple[str | None, bool]:
    if _reference_same(row, other):
        ref_match = "same"
    elif _reference_conflict(row, other):
        ref_match = "conflict"
    else:
        ref_match = None
    return ref_match, _summary_equal(row, other)


def _summary_equal(row: sqlite3.Row, other: sqlite3.Row) -> bool:
    left = normalize_summary(row)
    right = normalize_summary(other)
    return bool(left) and left == right


def _parse_datetime(value: object) -> datetime:
    text = str(value)
    try:
        return datetime.fromisoformat(text)
    except ValueError:
        return datetime.fromisoformat(text.replace("Z", "+00:00"))


# ---------------------------------------------------------------------------
# Derivation and application
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class DerivedDecision:
    classification: str
    pairing: str
    amount: str
    currency: str | None
    effective_at: str
    observations: tuple[tuple[int, str], ...]  # (row_id, role)
    participants: tuple[dict[str, object], ...]
    reason: str | None
    candidates: tuple[dict[str, object], ...] = ()


def _derive(
    connection: sqlite3.Connection,
    row: sqlite3.Row,
    res: RowResolution,
    pool: list[sqlite3.Row],
    resolutions: dict[int, RowResolution],
) -> DerivedDecision:
    amount = amount_of_row(row)
    amount_text = str(amount) if amount is not None else None
    currency = str(row["currency"] or "").strip() or None
    row_time = _parse_datetime(row["transaction_at"]).isoformat()

    if res.direction is None:
        return DerivedDecision(
            CLASSIFICATION_UNRESOLVED, PAIRING_NA, amount_text, currency,
            row_time, ((row["id"], "outgoing"),), (), "金额方向不满足一出一入",
        )
    if res.own is None:
        classification = (
            CLASSIFICATION_NEEDS_REVIEW if _own_is_conflict(res)
            else CLASSIFICATION_UNRESOLVED
        )
        role = "outgoing" if res.direction == "outgoing" else "incoming"
        return DerivedDecision(
            classification, PAIRING_NA, amount_text, currency, row_time,
            ((row["id"], role),), (), res.own_reason,
        )
    role = "outgoing" if res.direction == "outgoing" else "incoming"
    counter_role = "payee" if role == "outgoing" else "payer"

    if res.counterparty is None:
        classification = (
            CLASSIFICATION_NEEDS_REVIEW
            if _cp_is_ambiguous(res)
            else CLASSIFICATION_UNRESOLVED
        )
        return DerivedDecision(
            classification, PAIRING_NA, amount_text, currency,
            row_time, ((row["id"], role),),
            (_participant(res.own, "payer" if role == "outgoing" else "payee"),),
            res.counterparty_reason,
        )

    own_participant = _participant(res.own, "payer" if role == "outgoing" else "payee")
    cp_participant = _participant(res.counterparty, counter_role)
    if res.own.company_id == res.counterparty.company_id:
        # Same-company transfers pair when a mirror candidate exists; cash
        # trail is kept but they never enter the intercompany balance.
        hits, decision = _find_candidates(connection, row, res, pool, resolutions)
        if decision == "r2_ambiguous":
            return DerivedDecision(
                CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency,
                row_time, ((row["id"], role),), (own_participant, cp_participant),
                "同层多候选，落入人工审核", tuple(_candidate_rows(hits)),
            )
        if decision == "r1_review":
            return DerivedDecision(
                CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency,
                row_time, ((row["id"], role),), (own_participant, cp_participant),
                "端点可确认但账号镜像或参考号证据不足，落入人工审核",
                tuple(_candidate_rows(hits)),
            )
        if hits:
            other = next(item for item in pool if item["id"] == hits[0].row_id)
            return _derive_paired(row, res, other, resolutions[other["id"]], hits[0])
        return DerivedDecision(
            CLASSIFICATION_SAME_COMPANY, PAIRING_SINGLE, amount_text, currency,
            row_time, ((row["id"], role),),
            (own_participant, cp_participant), "本方与对方同属一家公司",
        )

    hits, decision = _find_candidates(connection, row, res, pool, resolutions)
    if decision == "r2_ambiguous":
        candidates = _candidate_rows(hits)
        return DerivedDecision(
            CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency,
            row_time, ((row["id"], role),), (own_participant, cp_participant),
            "同层多候选，落入人工审核", tuple(candidates),
        )
    if decision == "r1_review":
        candidates = _candidate_rows(hits)
        return DerivedDecision(
            CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency,
            row_time, ((row["id"], role),), (own_participant, cp_participant),
            "端点可确认但账号镜像或参考号证据不足，落入人工审核",
            tuple(candidates),
        )
    if hits:
        other = next(item for item in pool if item["id"] == hits[0].row_id)
        other_res = resolutions[other["id"]]
        return _derive_paired(row, res, other, other_res, hits[0])
    return DerivedDecision(
        CLASSIFICATION_INTERNAL_SINGLE, PAIRING_SINGLE, amount_text, currency,
        row_time, ((row["id"], role),), (own_participant, cp_participant),
        "单边观察，等待另一方到账或人工确认",
    )


def _own_is_conflict(res: RowResolution) -> bool:
    return res.own_reason in (
        "本方账号与上传账户归属公司冲突",
        "上传账户与批次公司不一致",
    )


def _cp_is_ambiguous(res: RowResolution) -> bool:
    return res.counterparty_reason in (
        "对方账号命中多个账号别名，落入人工审核",
        "对方户名别名归属与账号证据冲突，落入人工审核",
    )


def _participant(resolved: Resolved, role: str) -> dict[str, object]:
    return {
        "role": role,
        "company_id": resolved.company_id,
        "bank_account_id": resolved.bank_account_id,
        "resolve_method": resolved.resolve_method,
        "alias_id": resolved.alias_id,
        "mapping_id": resolved.mapping_id,
        "evidence": json.dumps(resolved.evidence, ensure_ascii=False),
    }


def _candidate_rows(hits: list[CandidateHit]) -> list[dict[str, object]]:
    return [
        {
            "source_row_id": hit.row_id,
            "rule_tier": hit.tier,
            "date_diff_days": hit.date_diff_days,
            "account_mirror": 1 if hit.account_mirror != "none" else 0,
            "reference_match": hit.reference_match,
            "summary_match": 1 if hit.summary_match else 0,
            "accepted": None,
            "reason": None,
        }
        for hit in hits
    ]


def _derive_paired(
    row: sqlite3.Row,
    res: RowResolution,
    other: sqlite3.Row,
    other_res: RowResolution,
    hit: CandidateHit,
) -> DerivedDecision:
    row_role = "outgoing" if res.direction == "outgoing" else "incoming"
    other_role = "incoming" if row_role == "outgoing" else "outgoing"
    if row_role == "outgoing":
        payer = _participant(res.own, "payer")
        payee = _participant(other_res.own, "payee")
        effective_at = _parse_datetime(row["transaction_at"]).isoformat()
    else:
        payer = _participant(other_res.own, "payer")
        payee = _participant(res.own, "payee")
        effective_at = _parse_datetime(other["transaction_at"]).isoformat()

    classification = (
        CLASSIFICATION_SAME_COMPANY
        if payer["company_id"] == payee["company_id"]
        else CLASSIFICATION_INTERCOMPANY
    )
    observations = (
        (row["id"], row_role),
        (other["id"], other_role),
    )
    return DerivedDecision(
        classification, PAIRING_PAIRED,
        str(amount_of_row(row)), str(row["currency"] or "").strip() or None,
        effective_at, observations, (payer, payee), "双边镜像归并",
        (_candidate_row(hit, accepted=1),),
    )


def _candidate_row(hit: CandidateHit, *, accepted: int | None) -> dict[str, object]:
    return {
        "source_row_id": hit.row_id,
        "rule_tier": hit.tier,
        "date_diff_days": hit.date_diff_days,
        "account_mirror": 1 if hit.account_mirror != "none" else 0,
        "reference_match": hit.reference_match,
        "summary_match": 1 if hit.summary_match else 0,
        "accepted": accepted,
        "reason": None,
    }


def _derive_for_row(
    connection: sqlite3.Connection,
    row: sqlite3.Row,
    res: RowResolution,
    pool: list[sqlite3.Row],
    resolutions: dict[int, RowResolution],
) -> DerivedDecision:
    return _derive(connection, row, res, pool, resolutions)


# ---------------------------------------------------------------------------
# Current claim helpers
# ---------------------------------------------------------------------------


def _current_claim(connection: sqlite3.Connection, row_id: int) -> sqlite3.Row | None:
    return connection.execute(
        """
        SELECT c.event_id, c.decision_id, d.locked, d.mode, d.classification,
               d.pairing, d.revision, d.rule_version
        FROM transfer_observation_claims c
        JOIN transfer_match_decisions d ON d.id = c.decision_id
        WHERE c.source_row_id = ?
        """,
        (row_id,),
    ).fetchone()


def _current_decision_for_event(
    connection: sqlite3.Connection, event_id: int
) -> sqlite3.Row | None:
    return connection.execute(
        """
        SELECT d.* FROM current_transfer_decisions c
        JOIN transfer_match_decisions d ON d.id = c.decision_id
        WHERE c.event_id = ?
        """,
        (event_id,),
    ).fetchone()


def _decision_observations(
    connection: sqlite3.Connection, decision_id: int
) -> list[sqlite3.Row]:
    return connection.execute(
        "SELECT * FROM transfer_decision_observations WHERE decision_id = ? ORDER BY id",
        (decision_id,),
    ).fetchall()


def _decision_participants(
    connection: sqlite3.Connection, decision_id: int
) -> list[sqlite3.Row]:
    return connection.execute(
        "SELECT * FROM transfer_decision_participants WHERE decision_id = ? ORDER BY role",
        (decision_id,),
    ).fetchall()


def _same_decision(
    connection: sqlite3.Connection,
    decision_id: int,
    derived: DerivedDecision,
) -> bool:
    """Idempotency check: derived outcome equals the current decision."""
    current = connection.execute(
        "SELECT * FROM transfer_match_decisions WHERE id = ?", (decision_id,)
    ).fetchone()
    if current is None:
        return False
    if current["classification"] != derived.classification:
        return False
    if current["pairing"] != derived.pairing:
        return False
    if (current["amount"] or None) != derived.amount:
        return False
    if (current["currency"] or None) != derived.currency:
        return False
    if (current["effective_at"] or None) != derived.effective_at:
        return False

    observed = {
        (row["source_row_id"], row["role"])
        for row in _decision_observations(connection, decision_id)
    }
    if observed != set(derived.observations):
        return False
    current_participants = {
        (
            row["role"], row["company_id"], row["bank_account_id"],
            row["resolve_method"], row["alias_id"], row["mapping_id"],
        )
        for row in _decision_participants(connection, decision_id)
    }
    derived_participants = {
        (
            str(p["role"]), p["company_id"], p["bank_account_id"],
            str(p["resolve_method"]), p["alias_id"], p["mapping_id"],
        )
        for p in derived.participants
    }
    return current_participants == derived_participants


# ---------------------------------------------------------------------------
# Applying decisions
# ---------------------------------------------------------------------------


def _apply(
    connection: sqlite3.Connection,
    derived: DerivedDecision,
    *,
    mode: str,
    locked: int,
    reason: str | None,
    actor: sqlite3.Row | None,
    rule_version: str,
    supersede_decision_id: int | None = None,
    idempotency_key: str | None = None,
) -> dict[str, object]:
    """Write one decision + projection update inside the open transaction."""
    now = utc_now()
    superseded_events: set[int] = set()

    claim_events: dict[int, sqlite3.Row] = {}
    for row_id, _role in derived.observations:
        claim = _current_claim(connection, row_id)
        if claim is not None:
            claim_events[row_id] = claim

    if claim_events:
        event_id = min(item["event_id"] for item in claim_events.values())
    else:
        cursor = connection.execute(
            "INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
            (now,),
        )
        event_id = int(cursor.lastrowid)

    previous = _current_decision_for_event(connection, event_id)
    if supersede_decision_id is None and previous is not None:
        supersede_decision_id = previous["id"]

    revision_row = connection.execute(
        "SELECT COALESCE(MAX(revision), 0) AS m FROM transfer_match_decisions WHERE event_id = ?",
        (event_id,),
    ).fetchone()
    revision = int(revision_row["m"]) + 1

    cursor = connection.execute(
        """
        INSERT INTO transfer_match_decisions (
            event_id, revision, classification, pairing, amount, currency,
            effective_at, mode, rule_version, locked, reason, idempotency_key,
            actor_user_id, actor_username, supersedes_decision_id, created_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
        (
            event_id, revision, derived.classification, derived.pairing,
            derived.amount, derived.currency, derived.effective_at,
            mode, rule_version, locked, reason, idempotency_key,
            actor["id"] if actor is not None else None,
            actor["username"] if actor is not None else None,
            supersede_decision_id,
            now,
        ),
    )
    decision_id = int(cursor.lastrowid)

    for row_id, role in derived.observations:
        connection.execute(
            """
            INSERT INTO transfer_decision_observations (decision_id, source_row_id, role, created_at)
            VALUES (?, ?, ?, ?)
            """,
            (decision_id, row_id, role, now),
        )
    for participant in derived.participants:
        connection.execute(
            """
            INSERT INTO transfer_decision_participants (
                decision_id, role, company_id, bank_account_id, resolve_method,
                alias_id, mapping_id, evidence, created_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                decision_id, participant["role"], participant["company_id"],
                participant["bank_account_id"], participant["resolve_method"],
                participant["alias_id"], participant["mapping_id"],
                participant["evidence"], now,
            ),
        )
    for candidate in derived.candidates:
        connection.execute(
            """
            INSERT INTO transfer_match_candidates (
                decision_id, source_row_id, rule_tier, date_diff_days,
                account_mirror, reference_match, summary_match, accepted,
                reason, rule_version, created_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                decision_id, candidate["source_row_id"], candidate["rule_tier"],
                candidate["date_diff_days"], candidate["account_mirror"],
                candidate["reference_match"], candidate["summary_match"],
                candidate["accepted"], candidate["reason"], rule_version, now,
            ),
        )

    # Projection: point every involved observation's claim at the new decision.
    for row_id, _role in derived.observations:
        connection.execute(
            """
            INSERT OR REPLACE INTO transfer_observation_claims (source_row_id, event_id, decision_id)
            VALUES (?, ?, ?)
            """,
            (row_id, event_id, decision_id),
        )
    for row_id in claim_events:
        if row_id not in {rid for rid, _ in derived.observations}:
            connection.execute(
                "DELETE FROM transfer_observation_claims WHERE source_row_id = ?",
                (row_id,),
            )

    # Other involved events lose their current pointer and become superseded.
    for other_event in {item["event_id"] for item in claim_events.values()}:
        if other_event != event_id:
            connection.execute(
                "DELETE FROM current_transfer_decisions WHERE event_id = ?",
                (other_event,),
            )
            connection.execute(
                "UPDATE canonical_transfer_events SET lifecycle = 'superseded' WHERE id = ?",
                (other_event,),
            )
            superseded_events.add(other_event)
    connection.execute(
        """
        INSERT OR REPLACE INTO current_transfer_decisions (event_id, decision_id)
        VALUES (?, ?)
        """,
        (event_id, decision_id),
    )

    return {
        "event_id": event_id,
        "decision_id": decision_id,
        "revision": revision,
        "superseded_events": sorted(superseded_events),
    }


# ---------------------------------------------------------------------------
# Public reconcile API
# ---------------------------------------------------------------------------


def reconcile_rows(
    connection: sqlite3.Connection,
    source_row_ids: list[int],
    *,
    rule_version: str = RULE_VERSION,
    actor: sqlite3.Row | None = None,
) -> dict[str, object]:
    """Reconcile confirmed source rows into canonical events, idempotently.

    Rows claimed by a locked (manual) decision are never touched. Rows whose
    derived outcome already equals their current decision produce zero writes.
    Runs inside the caller's transaction when one is open, otherwise in its
    own ``BEGIN IMMEDIATE`` transaction.
    """
    began = _ensure_transaction(connection)
    try:
        ids = sorted({int(row_id) for row_id in source_row_ids})
        if not ids:
            return {
                "created_events": 0, "updated_events": 0, "unchanged": 0,
                "skipped_locked": 0, "rows": 0,
            }
        stats = {
            "created_events": 0, "updated_events": 0, "unchanged": 0,
            "skipped_locked": 0, "rows": 0,
        }
        pool = _confirmed_rows(connection)
        pool_by_id = {row["id"]: row for row in pool}
        resolutions = {
            row["id"]: resolve_row(connection, row)
            for row in pool
        }
        input_rows = _load_rows(connection, ids)

        for row in input_rows:
            stats["rows"] += 1
            claim = _current_claim(connection, row["id"])
            if claim is not None and claim["locked"]:
                stats["skipped_locked"] += 1
                continue
            if row["id"] not in pool_by_id:
                stats["skipped_unconfirmed"] += 1
                continue
            derived = _derive_for_row(connection, row, resolutions[row["id"]], pool, resolutions)
            if claim is not None and _same_decision(connection, claim["decision_id"], derived):
                stats["unchanged"] += 1
                continue
            outcome = _apply(
                connection, derived, mode=MODE_AUTO, locked=0,
                reason=derived.reason, actor=actor, rule_version=rule_version,
            )
            if outcome["revision"] == 1:
                stats["created_events"] += 1
            else:
                stats["updated_events"] += 1
    except Exception:
        if began:
            connection.rollback()
        raise
    else:
        if began:
            connection.commit()
        return stats


# ---------------------------------------------------------------------------
# Manual decisions
# ---------------------------------------------------------------------------


def apply_manual_decision(
    connection: sqlite3.Connection,
    event_id: int,
    action: str,
    *,
    reason: str,
    expected_revision: int | None,
    request_key: str | None,
    actor: sqlite3.Row,
    source_row_ids: list[int] | None = None,
    participant: dict[str, object] | None = None,
) -> dict[str, object]:
    """Apply an administrator decision on an event (link_rows/assign_participant
    /mark_external/reverse). Replaces the current decision with a locked manual
    one; corrections must go through ``reverse``, never in-place editing.
    """
    reason = (reason or "").strip()
    if not reason:
        raise MatchInputError("必须填写操作原因。")
    began = _ensure_transaction(connection)
    try:
        # Idempotency: a replayed request key returns the earlier decision
        # without re-validating the (now changed) revision.
        if request_key:
            existing = connection.execute(
                "SELECT id FROM transfer_match_decisions WHERE idempotency_key = ? AND event_id = ?",
                (request_key, event_id),
            ).fetchone()
            if existing is not None:
                if began:
                    connection.commit()
                return _decision_payload(connection, event_id, existing["id"])

        current = _current_decision_for_event(connection, event_id)
        if current is None:
            raise MatchConflictError("该事件当前没有有效决定。")
        if expected_revision is not None and int(expected_revision) != current["revision"]:
            raise MatchConflictError("事件已发生变更，请刷新后重试。")

        if action == "link_rows":
            derived = _manual_link(
                connection, event_id, current, source_row_ids, reason, actor
            )
        elif action == "assign_participant":
            derived = _manual_assign_participant(
                connection, event_id, current, participant, reason, actor
            )
        elif action == "mark_external":
            derived = _manual_external(connection, event_id, current, reason)
        elif action == "reverse":
            derived = None
        else:
            raise MatchInputError("未知的人工决定类型。")

        if action == "reverse":
            outcome = _apply_reversal(
                connection, event_id, current, reason, actor, request_key
            )
        else:
            outcome = _apply(
                connection, derived, mode=MODE_MANUAL, locked=1,
                reason=reason, actor=actor,
                rule_version=current["rule_version"] or RULE_VERSION,
                idempotency_key=request_key,
            )
        audit(
            connection,
            f"transfer_{action}",
            actor=actor,
            target=f"transfer_event:{event_id}",
            detail=f"decision:{outcome['decision_id']};reason:{reason}",
        )
    except Exception:
        if began:
            connection.rollback()
        raise
    else:
        if began:
            connection.commit()
        return _decision_payload(connection, event_id, outcome["decision_id"])


def _manual_link(
    connection: sqlite3.Connection,
    event_id: int,
    current: sqlite3.Row,
    source_row_ids: list[int] | None,
    reason: str,
    actor: sqlite3.Row,
) -> DerivedDecision:
    if not source_row_ids or len(source_row_ids) != 2:
        raise MatchInputError("人工关联必须且只能提供两条源行。")
    rows = _load_rows(connection, [int(row_id) for row_id in source_row_ids])
    if len(rows) != 2:
        raise MatchInputError("存在无效的源行。")
    for row in rows:
        claim = _current_claim(connection, row["id"])
        if claim is not None and claim["decision_id"] != current["id"] and claim["locked"]:
            raise MatchConflictError(f"源行 {row['id']} 已被锁定的人工决定占用。")
    resolutions = {row["id"]: resolve_row(connection, row) for row in rows}
    directions = {row["id"]: resolutions[row["id"]].direction for row in rows}
    if any(value is None for value in directions.values()):
        raise MatchInputError("人工关联的源行金额方向必须一出一入。")
    if directions[rows[0]["id"]] == directions[rows[1]["id"]]:
        raise MatchInputError("人工关联的源行金额方向必须相反。")
    outgoing_row = rows[0] if directions[rows[0]["id"]] == "outgoing" else rows[1]
    incoming_row = rows[1] if outgoing_row is rows[0] else rows[0]
    out_res = resolutions[outgoing_row["id"]]
    in_res = resolutions[incoming_row["id"]]
    if out_res.own is None or in_res.own is None:
        raise MatchInputError("人工关联的源行未能唯一确认本方归属，不能定案。")
    payer = _participant(out_res.own, "payer")
    payee = _participant(in_res.own, "payee")
    classification = (
        CLASSIFICATION_SAME_COMPANY
        if payer["company_id"] == payee["company_id"]
        else CLASSIFICATION_INTERCOMPANY
    )
    amount = amount_of_row(outgoing_row)
    return DerivedDecision(
        classification, PAIRING_PAIRED, str(amount),
        str(outgoing_row["currency"] or "").strip() or None,
        _parse_datetime(outgoing_row["transaction_at"]).isoformat(),
        (
            (outgoing_row["id"], "outgoing"),
            (incoming_row["id"], "incoming"),
        ),
        (payer, payee), "管理员人工关联",
    )


def _manual_assign_participant(
    connection: sqlite3.Connection,
    event_id: int,
    current: sqlite3.Row,
    participant: dict[str, object] | None,
    reason: str,
    actor: sqlite3.Row,
) -> DerivedDecision:
    if not participant or not isinstance(participant, dict):
        raise MatchInputError("assign_participant 必须提供 participant。")
    role = str(participant.get("role") or "")
    if role not in ("payer", "payee"):
        raise MatchInputError("participant.role 必须是 payer 或 payee。")
    try:
        company_id = int(participant["company_id"])
    except (KeyError, TypeError, ValueError):
        raise MatchInputError("participant.company_id 必须是有效的公司 id。") from None
    company = connection.execute(
        "SELECT id FROM companies WHERE id = ?", (company_id,)
    ).fetchone()
    if company is None:
        raise MatchInputError("participant.company_id 指向的公司不存在。")

    observations = _decision_observations(connection, current["id"])
    if len(observations) != 1:
        raise MatchInputError("assign_participant 只适用于单边事件。")
    observation = observations[0]
    row = _load_rows(connection, [observation["source_row_id"]])[0]
    resolved = resolve_row(connection, row)
    if resolved.own is None or resolved.direction is None:
        raise MatchInputError("assign_participant 需要本方归属已确认。")
    own_role = "payer" if resolved.direction == "outgoing" else "payee"
    if role == own_role:
        raise MatchInputError("assign_participant 只能指定对方参与方角色。")
    own_participant = _participant(resolved.own, own_role)
    assigned = {
        "role": role,
        "company_id": company_id,
        "bank_account_id": (
            int(participant["bank_account_id"])
            if participant.get("bank_account_id")
            else None
        ),
        "resolve_method": "manual",
        "alias_id": None,
        "mapping_id": None,
        "evidence": json.dumps(
            {"via": "manual", "company_id": company_id}, ensure_ascii=False
        ),
    }
    participants = (own_participant, assigned)
    classification = (
        CLASSIFICATION_SAME_COMPANY
        if own_participant["company_id"] == company_id
        else CLASSIFICATION_INTERCOMPANY
    )
    amount = amount_of_row(row)
    return DerivedDecision(
        classification, PAIRING_SINGLE, str(amount),
        str(row["currency"] or "").strip() or None,
        _parse_datetime(row["transaction_at"]).isoformat(),
        ((row["id"], observation["role"]),),
        participants, "管理员按证据确认参与方",
    )


def _manual_external(
    connection: sqlite3.Connection, event_id: int, current: sqlite3.Row, reason: str
) -> DerivedDecision:
    observations = _decision_observations(connection, current["id"])
    participants = _decision_participants(connection, current["id"])
    if len(observations) != 1:
        raise MatchInputError("mark_external 只适用于单边事件。")
    observation = observations[0]
    row = _load_rows(connection, [observation["source_row_id"]])[0]
    amount = amount_of_row(row)
    participant_rows = [
        {
            "role": p["role"], "company_id": p["company_id"],
            "bank_account_id": p["bank_account_id"],
            "resolve_method": p["resolve_method"], "alias_id": p["alias_id"],
            "mapping_id": p["mapping_id"], "evidence": p["evidence"],
        }
        for p in participants
    ]
    return DerivedDecision(
        CLASSIFICATION_EXTERNAL, PAIRING_NA, str(amount),
        str(row["currency"] or "").strip() or None,
        _parse_datetime(row["transaction_at"]).isoformat(),
        ((row["id"], observation["role"]),),
        tuple(participant_rows), "管理员确认外部交易",
    )


def _apply_reversal(
    connection: sqlite3.Connection,
    event_id: int,
    current: sqlite3.Row,
    reason: str,
    actor: sqlite3.Row | None,
    idempotency_key: str | None,
) -> dict[str, object]:
    now = utc_now()
    revision_row = connection.execute(
        "SELECT COALESCE(MAX(revision), 0) AS m FROM transfer_match_decisions WHERE event_id = ?",
        (event_id,),
    ).fetchone()
    revision = int(revision_row["m"]) + 1
    cursor = connection.execute(
        """
        INSERT INTO transfer_match_decisions (
            event_id, revision, classification, pairing, amount, currency,
            effective_at, mode, rule_version, locked, reason, idempotency_key,
            actor_user_id, actor_username, supersedes_decision_id, created_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
        """,
        (
            event_id, revision, current["classification"], current["pairing"],
            current["amount"], current["currency"], current["effective_at"],
            MODE_REVERSAL, current["rule_version"], reason, idempotency_key,
            actor["id"] if actor is not None else None,
            actor["username"] if actor is not None else None,
            current["id"], now,
        ),
    )
    decision_id = int(cursor.lastrowid)
    connection.execute(
        "DELETE FROM transfer_observation_claims WHERE event_id = ?", (event_id,)
    )
    connection.execute(
        "DELETE FROM current_transfer_decisions WHERE event_id = ?", (event_id,)
    )
    return {"decision_id": decision_id, "event_id": event_id}


# ---------------------------------------------------------------------------
# Projection rebuild and B-44 read contract
# ---------------------------------------------------------------------------


def rebuild_current_projection(connection: sqlite3.Connection) -> int:
    """Rebuild current decisions and row claims from the append-only log.

    For every active event, the current decision is its highest-revision
    decision — unless that decision is a reversal, in which case the event has
    no current pointer and no claims. Returns the number of current decisions
    rebuilt. Intended as a recovery/consistency entry point.
    """
    connection.execute("DELETE FROM transfer_observation_claims")
    connection.execute("DELETE FROM current_transfer_decisions")
    events = connection.execute(
        """
        SELECT e.id AS event_id,
               (SELECT d2.id FROM transfer_match_decisions d2
                 WHERE d2.event_id = e.id
                 ORDER BY d2.revision DESC LIMIT 1) AS latest_id
        FROM canonical_transfer_events e
        WHERE e.lifecycle = 'active'
        """
    ).fetchall()
    rebuilt = 0
    for event in events:
        if event["latest_id"] is None:
            continue
        latest = connection.execute(
            "SELECT mode FROM transfer_match_decisions WHERE id = ?",
            (event["latest_id"],),
        ).fetchone()
        if latest is None or latest["mode"] == MODE_REVERSAL:
            continue
        observations = connection.execute(
            """
            SELECT source_row_id FROM transfer_decision_observations
            WHERE decision_id = ? ORDER BY id
            """,
            (event["latest_id"],),
        ).fetchall()
        with connection:
            connection.execute(
                """
                INSERT OR REPLACE INTO current_transfer_decisions (event_id, decision_id)
                VALUES (?, ?)
                """,
                (event["event_id"], event["latest_id"]),
            )
            for observation in observations:
                connection.execute(
                    """
                    INSERT OR REPLACE INTO transfer_observation_claims (source_row_id, event_id, decision_id)
                    VALUES (?, ?, ?)
                    """,
                    (observation["source_row_id"], event["event_id"], event["latest_id"]),
                )
        rebuilt += 1
    return rebuilt


def eligible_intercompany_events(connection: sqlite3.Connection) -> list[sqlite3.Row]:
    return connection.execute(
        "SELECT * FROM eligible_intercompany_events ORDER BY event_id"
    ).fetchall()


def unresolved_amounts(
    connection: sqlite3.Connection,
    company_id: int,
    cutoff: str | None = None,
) -> list[sqlite3.Row]:
    """Unresolved amounts a company is exposed to as of ``cutoff``.

    Only current decisions in ``unresolved``/``needs_review``/``internal_single``
    count as unresolved; paired intercompany, same-company and external events
    are resolved classifications and never appear here.
    """
    cutoff_where = "AND substr(d.effective_at, 1, 10) <= ?" if cutoff else ""
    params: list[object] = []
    if cutoff:
        params.append(cutoff)
    rows = connection.execute(
        f"""
        SELECT d.id AS decision_id, d.event_id, d.classification, d.amount,
               d.currency, d.effective_at, o.role AS direction
        FROM current_transfer_decisions c
        JOIN transfer_match_decisions d ON d.id = c.decision_id
        JOIN transfer_decision_participants p ON p.decision_id = d.id
        JOIN transfer_decision_observations o ON o.decision_id = d.id
        WHERE d.classification IN ('unresolved', 'needs_review', 'internal_single')
          AND p.company_id = ?
          {cutoff_where}
        ORDER BY d.id
        """,
        (company_id, *params),
    ).fetchall()
    return rows


def exposed_status(decision: sqlite3.Row | dict) -> str:
    classification = decision["classification"]
    pairing = decision["pairing"]
    if classification == CLASSIFICATION_INTERCOMPANY and pairing == PAIRING_PAIRED:
        return "matched"
    if classification == CLASSIFICATION_INTERCOMPANY:
        return "confirmed_single"
    if classification == CLASSIFICATION_SAME_COMPANY:
        return "same_company_transfer"
    if classification == CLASSIFICATION_EXTERNAL:
        return "external"
    if classification == CLASSIFICATION_NEEDS_REVIEW:
        return "needs_review"
    if classification == CLASSIFICATION_INTERNAL_SINGLE:
        return "internal_single"
    return "unresolved"


def company_pending_unilaterals(
    connection: sqlite3.Connection, company_id: int
) -> list[sqlite3.Row]:
    """Authoritative pending unilateral queue for a company workspace.

    Same set as company match-exceptions: active events whose current decision
    is ``unresolved`` / ``needs_review`` and the company participates.
    Parentheses on the company filter avoid OR/AND precedence bugs.
    """
    return connection.execute(
        """
        SELECT c.event_id, d.id AS decision_id, d.revision, d.classification,
               d.pairing, d.amount, d.currency, d.effective_at, d.mode,
               d.locked, d.rule_version, d.created_at,
               payer.company_id AS payer_company_id,
               payee.company_id AS payee_company_id,
               payer.bank_account_id AS payer_account_id,
               payee.bank_account_id AS payee_account_id,
               cpayer.name AS payer_company_name,
               cpayee.name AS payee_company_name,
               (SELECT COUNT(*) FROM transfer_decision_observations o
                WHERE o.decision_id = d.id) AS evidence_count
        FROM current_transfer_decisions c
        JOIN transfer_match_decisions d ON d.id = c.decision_id
        JOIN canonical_transfer_events e ON e.id = c.event_id
        LEFT JOIN transfer_decision_participants payer
             ON payer.decision_id = d.id AND payer.role = 'payer'
        LEFT JOIN transfer_decision_participants payee
             ON payee.decision_id = d.id AND payee.role = 'payee'
        LEFT JOIN companies cpayer ON cpayer.id = payer.company_id
        LEFT JOIN companies cpayee ON cpayee.id = payee.company_id
        WHERE e.lifecycle = 'active'
          AND d.classification IN ('unresolved', 'needs_review')
          AND (payer.company_id = ? OR payee.company_id = ?)
        ORDER BY d.id DESC
        """,
        (company_id, company_id),
    ).fetchall()


def company_workspace_payload(
    connection: sqlite3.Connection, company_id: int
) -> dict[str, object]:
    """Workspace counts + list share one authoritative unilateral queue."""
    rows = company_pending_unilaterals(connection, company_id)
    events = [
        {
            "event_id": row["event_id"],
            "decision_id": row["decision_id"],
            "revision": row["revision"],
            "classification": row["classification"],
            "pairing": row["pairing"],
            "status": exposed_status(row),
            "amount": row["amount"],
            "currency": row["currency"],
            "effective_at": row["effective_at"],
            "mode": row["mode"],
            "locked": bool(row["locked"]),
            "own_company_id": company_id,
            "counterparty_company_id": (
                row["payee_company_id"]
                if row["payer_company_id"] == company_id
                else row["payer_company_id"]
            ),
            "counterparty_company_name": (
                row["payee_company_name"]
                if row["payer_company_id"] == company_id
                else row["payer_company_name"]
            ),
            "evidence_count": row["evidence_count"],
        }
        for row in rows
    ]
    count = len(events)
    return {
        "pending_unilateral": count,
        "pending_total": count,
        "unilateral_events": events,
    }


# ---------------------------------------------------------------------------
# API payload helpers
# ---------------------------------------------------------------------------


def _decision_payload(
    connection: sqlite3.Connection, event_id: int, decision_id: int
) -> dict[str, object]:
    decision = connection.execute(
        "SELECT * FROM transfer_match_decisions WHERE id = ?", (decision_id,)
    ).fetchone()
    observations = _decision_observations(connection, decision_id)
    participants = _decision_participants(connection, decision_id)
    return {
        "event_id": event_id,
        "decision_id": decision_id,
        "revision": decision["revision"],
        "classification": decision["classification"],
        "pairing": decision["pairing"],
        "status": exposed_status(decision),
        "amount": decision["amount"],
        "currency": decision["currency"],
        "effective_at": decision["effective_at"],
        "mode": decision["mode"],
        "locked": bool(decision["locked"]),
        "rule_version": decision["rule_version"],
        "source_row_ids": [o["source_row_id"] for o in observations],
        "participants": [
            {
                "role": p["role"], "company_id": p["company_id"],
                "bank_account_id": p["bank_account_id"],
                "resolve_method": p["resolve_method"],
            }
            for p in participants
        ],
    }
