"""FinanceLedger — SQLite-backed transaction store.

SQLite is the queryable index; the markdown files under ``finance/transactions/``
are the source of truth (Rule #1: vault content survives the database). The
service can be reindexed from markdown via ``rebuild_from_markdown()`` (wired
in Phase 1.3) when the SQLite file is corrupted, deleted, or out of sync.

Design parity with [activity/decisions.py](../activity/decisions.py):

- Single ``aiosqlite.Connection`` injected at construction; ``None`` triggers
  the in-memory fallback (used by tests).
- Idempotent ``_SCHEMA`` + ``_MIGRATIONS`` — fresh installs and upgrades both
  hit the same code path.
- Every write inside an ``asyncio.Lock``; reads bypass the lock (SQLite WAL
  handles concurrent reads).
- Never raises on routine I/O — a broken ledger must not break the agent loop.
  Errors are recorded in ``_record_failures`` + last_error fields, surfaced
  via ``health()`` for the dashboard.

Decimal arithmetic uses ``Decimal`` exclusively. Strings cross the wire/DB
boundary; ``Decimal`` is used internally for sum/compare operations. Float is
banned from this module — see ``money.py`` (Phase 1.2) for parsing helpers.
"""

from __future__ import annotations

import asyncio
import json
import logging
import uuid
from dataclasses import asdict
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from typing import TYPE_CHECKING

from agntspace.finance.types import (
    LineItem,
    TaxLine,
    Transaction,
    TransactionStatus,
)

if TYPE_CHECKING:
    import aiosqlite

log = logging.getLogger(__name__)


def _now_iso() -> str:
    return datetime.now(UTC).isoformat(timespec="seconds")


def _normalize_amount(raw: str | int | float | Decimal) -> str:
    """Coerce an amount to a canonical decimal string.

    Accepts the inputs that flow through the ingest pipeline (LLM-extracted
    strings, numeric DB values from legacy data). Always returns a 2-or-more
    decimal-place string. Returns ``"0.00"`` on any parse failure — the auditor
    catches the discrepancy at reconcile time, never silently dropped.
    """
    if isinstance(raw, Decimal):
        return f"{raw:.2f}"
    if isinstance(raw, (int, float)):
        return f"{Decimal(str(raw)):.2f}"
    text = str(raw or "0").strip()
    if not text:
        return "0.00"
    # Common LLM artifacts: "$42.50", "42,50", "EUR 12.99", "-3.50".
    text = text.replace("$", "").replace("€", "").replace("£", "").replace("kr", "")
    text = text.replace(",", ".").strip()
    # Strip leading currency code letters ("EUR 12.99" → "12.99"); keep digits + sign + dot.
    cleaned = "".join(ch for ch in text if ch.isdigit() or ch in ".-")
    try:
        return f"{Decimal(cleaned or '0'):.2f}"
    except InvalidOperation:
        return "0.00"


def _new_txn_id() -> str:
    """Short, URL-safe transaction ID. uuid4 base32 truncated."""
    return uuid.uuid4().hex[:12]


class FinanceLedger:
    """SQLite-backed ledger for transactions, line items, budgets, merchants.

    Phase 1 implements the transaction + line_item + merchant tables. Phase 2
    adds the budget + budget_consumed tables (the schema is here from day one
    so the migration is one ``ALTER`` away — but consumption is a no-op until
    the budget service is wired).
    """

    # Base schema. New columns / tables go in ``_MIGRATIONS`` — never edit
    # this string after the first release; it's the v1 baseline that older
    # vaults still have on disk.
    _SCHEMA = """
    CREATE TABLE IF NOT EXISTS finance_transactions (
        txn_id          TEXT PRIMARY KEY,
        txn_date        TEXT    NOT NULL,
        merchant_slug   TEXT    NOT NULL DEFAULT '',
        currency        TEXT    NOT NULL DEFAULT '',
        total           TEXT    NOT NULL DEFAULT '0.00',
        tax             TEXT    NOT NULL DEFAULT '0.00',
        project_slug    TEXT    NOT NULL DEFAULT '',
        status          TEXT    NOT NULL DEFAULT 'verified',
        payment_method  TEXT    NOT NULL DEFAULT '',
        source_file     TEXT    NOT NULL DEFAULT '',
        content_hash    TEXT    NOT NULL DEFAULT '',
        correlation_id  TEXT    NOT NULL DEFAULT '',
        notes           TEXT    NOT NULL DEFAULT '',
        created_at      TEXT    NOT NULL,
        verified_at     TEXT    NOT NULL DEFAULT ''
    );
    CREATE INDEX IF NOT EXISTS idx_finance_txn_date     ON finance_transactions(txn_date);
    CREATE INDEX IF NOT EXISTS idx_finance_txn_merchant ON finance_transactions(merchant_slug);
    CREATE INDEX IF NOT EXISTS idx_finance_txn_project  ON finance_transactions(project_slug);
    CREATE INDEX IF NOT EXISTS idx_finance_txn_status   ON finance_transactions(status);
    CREATE INDEX IF NOT EXISTS idx_finance_txn_hash     ON finance_transactions(content_hash);

    CREATE TABLE IF NOT EXISTS finance_line_items (
        item_id         TEXT PRIMARY KEY,
        txn_id          TEXT NOT NULL REFERENCES finance_transactions(txn_id) ON DELETE CASCADE,
        description     TEXT NOT NULL DEFAULT '',
        quantity        TEXT NOT NULL DEFAULT '',
        unit_price      TEXT NOT NULL DEFAULT '',
        amount          TEXT NOT NULL DEFAULT '0.00',
        category_slug   TEXT NOT NULL DEFAULT '',
        project_slug    TEXT NOT NULL DEFAULT '',
        tax_deductible  INTEGER NOT NULL DEFAULT 0,
        notes           TEXT NOT NULL DEFAULT '',
        position        INTEGER NOT NULL DEFAULT 0
    );
    CREATE INDEX IF NOT EXISTS idx_finance_item_txn      ON finance_line_items(txn_id);
    CREATE INDEX IF NOT EXISTS idx_finance_item_category ON finance_line_items(category_slug);
    CREATE INDEX IF NOT EXISTS idx_finance_item_project  ON finance_line_items(project_slug);

    CREATE TABLE IF NOT EXISTS finance_merchants (
        merchant_slug    TEXT PRIMARY KEY,
        canonical_name   TEXT NOT NULL,
        aliases          TEXT NOT NULL DEFAULT '[]',
        country          TEXT NOT NULL DEFAULT '',
        default_category TEXT NOT NULL DEFAULT '',
        notes            TEXT NOT NULL DEFAULT '',
        last_seen        TEXT NOT NULL DEFAULT ''
    );
    """

    # Idempotent migrations. Each statement individually try/excepts at
    # apply time so an "already applied" error doesn't break the boot.
    _MIGRATIONS: list[str] = [  # arch:deterministic — schema migrations, not strategy
        # Phase 2 budget tables — created from day one so Phase 2 just turns
        # them on. Empty rows == no budget = no behavior change.
        """CREATE TABLE IF NOT EXISTS finance_budgets (
            budget_id      TEXT PRIMARY KEY,
            period         TEXT NOT NULL,
            scope_type     TEXT NOT NULL,
            scope_slug     TEXT NOT NULL DEFAULT '',
            category_slug  TEXT NOT NULL DEFAULT '',
            amount         TEXT NOT NULL DEFAULT '0.00',
            currency       TEXT NOT NULL DEFAULT '',
            notes          TEXT NOT NULL DEFAULT '',
            created_at     TEXT NOT NULL,
            updated_at     TEXT NOT NULL DEFAULT ''
        )""",
        "CREATE INDEX IF NOT EXISTS idx_finance_budget_period ON finance_budgets(period)",
        "CREATE INDEX IF NOT EXISTS idx_finance_budget_scope  ON finance_budgets(scope_type, scope_slug)",
        # Idempotent consumption ledger — one row per (budget, txn) pairing.
        # Phase 2 populates this on commit; Phase 1 leaves it empty.
        """CREATE TABLE IF NOT EXISTS finance_budget_consumed (
            budget_id   TEXT NOT NULL REFERENCES finance_budgets(budget_id) ON DELETE CASCADE,
            txn_id      TEXT NOT NULL REFERENCES finance_transactions(txn_id) ON DELETE CASCADE,
            amount      TEXT NOT NULL DEFAULT '0.00',
            currency    TEXT NOT NULL DEFAULT '',
            ts          TEXT NOT NULL,
            PRIMARY KEY (budget_id, txn_id)
        )""",
        # Phase 1.5 accounting fields. Each ALTER is wrapped in a try/except
        # at apply-time so a re-run of an already-migrated DB is a no-op.
        # Pre-1.5 rows default to empty strings, distinguishable from
        # extractions where the field was actually empty (the LLM either
        # populates with content or leaves blank).
        "ALTER TABLE finance_transactions ADD COLUMN receipt_number  TEXT NOT NULL DEFAULT ''",
        "ALTER TABLE finance_transactions ADD COLUMN document_type   TEXT NOT NULL DEFAULT ''",
        "ALTER TABLE finance_transactions ADD COLUMN subtotal        TEXT NOT NULL DEFAULT ''",
        "ALTER TABLE finance_transactions ADD COLUMN vendor_tax_id   TEXT NOT NULL DEFAULT ''",
        "ALTER TABLE finance_transactions ADD COLUMN vendor_address  TEXT NOT NULL DEFAULT ''",
        "ALTER TABLE finance_transactions ADD COLUMN due_date        TEXT NOT NULL DEFAULT ''",
        "CREATE INDEX IF NOT EXISTS idx_finance_txn_due_date ON finance_transactions(due_date)",
        # Persistent vendor tax_id on the merchant record so subsequent
        # receipts confirm/correct the merchant identity.
        "ALTER TABLE finance_merchants ADD COLUMN tax_id          TEXT NOT NULL DEFAULT ''",
        "CREATE INDEX IF NOT EXISTS idx_finance_merchant_tax_id ON finance_merchants(tax_id)",
        # Phase 1.5 — discounts + tip + item codes. The reconciliation
        # equation is now: sum(items.amount) - discount_total + tax + tip ≈ total.
        # All optional; pre-1.5 rows default to empty (zero) and the
        # reconciliation math treats empty as 0.
        "ALTER TABLE finance_transactions ADD COLUMN discount_total       TEXT NOT NULL DEFAULT ''",
        "ALTER TABLE finance_transactions ADD COLUMN discount_description TEXT NOT NULL DEFAULT ''",
        "ALTER TABLE finance_transactions ADD COLUMN tip                  TEXT NOT NULL DEFAULT ''",
        "ALTER TABLE finance_line_items   ADD COLUMN item_code            TEXT NOT NULL DEFAULT ''",
        "ALTER TABLE finance_line_items   ADD COLUMN discount             TEXT NOT NULL DEFAULT ''",
        # Index on item_code for future "what did I buy with this SKU?" queries.
        "CREATE INDEX IF NOT EXISTS idx_finance_item_code ON finance_line_items(item_code)",
        # Phase 1.5 — VAT breakdown. tax_breakdown_json is a JSON-encoded
        # list of {rate, amount, taxable_base} entries; one entry per VAT
        # rate present on the receipt. Empty list "[]" is the default for
        # consumer receipts that print only a single VAT line, OR for
        # reverse-charge invoices (which also flip reverse_charge=1).
        "ALTER TABLE finance_transactions ADD COLUMN tax_breakdown_json   TEXT NOT NULL DEFAULT '[]'",
        "ALTER TABLE finance_transactions ADD COLUMN buyer_tax_id         TEXT NOT NULL DEFAULT ''",
        "ALTER TABLE finance_transactions ADD COLUMN reverse_charge       INTEGER NOT NULL DEFAULT 0",
        "ALTER TABLE finance_line_items   ADD COLUMN tax_rate             TEXT NOT NULL DEFAULT ''",
    ]

    def __init__(self, db: aiosqlite.Connection | None = None) -> None:
        self._db = db
        self._lock = asyncio.Lock()
        self._initialized = False

        # In-memory fallback when no DB (test fixtures, degraded mode).
        # Same shape as the SQLite tables — keys are the primary-key columns.
        self._mem_txns: dict[str, dict] = {}
        self._mem_items: dict[str, list[dict]] = {}   # txn_id → [item dict, ...]
        self._mem_merchants: dict[str, dict] = {}

        # Health counters (mirrors DecisionLogger.health()).
        self._record_attempts = 0
        self._record_failures = 0
        self._last_error = ""
        self._last_error_ts = ""

    # ────────────────────────────────────────────────────────────────────
    # Lifecycle
    # ────────────────────────────────────────────────────────────────────
    async def initialize(self) -> None:
        """Create tables. Safe to call multiple times."""
        if self._initialized:
            return
        if self._db is None:
            self._initialized = True
            return
        async with self._lock:
            try:
                await self._db.executescript(self._SCHEMA)
                for stmt in self._MIGRATIONS:
                    try:
                        await self._db.execute(stmt)
                    except Exception:  # pragma: no cover — migration already applied
                        pass
                await self._db.commit()
                self._initialized = True
            except Exception as exc:
                self._last_error = f"init: {type(exc).__name__}: {exc}"
                self._last_error_ts = _now_iso()
                log.exception("Failed to initialize finance schema")

    # ────────────────────────────────────────────────────────────────────
    # Transactions — write
    # ────────────────────────────────────────────────────────────────────
    async def commit_transaction(self, txn: Transaction) -> str:
        """Write a verified transaction. Returns the txn_id (generated if blank).

        Idempotent on ``content_hash`` when set: a duplicate ``content_hash``
        returns the existing ``txn_id`` without writing. This is the
        belt-and-braces for the receipt ingest pipeline, which already
        short-circuits on hash but might race with manual API calls.
        """
        self._record_attempts += 1
        if not self._initialized:
            await self.initialize()

        txn_id = txn.txn_id or _new_txn_id()
        created_at = txn.created_at or _now_iso()
        verified_at = (
            txn.verified_at
            if txn.verified_at
            else (created_at if txn.status == TransactionStatus.VERIFIED else "")
        )
        normalized = Transaction(
            txn_id=txn_id,
            txn_date=txn.txn_date,
            merchant_slug=txn.merchant_slug,
            currency=(txn.currency or "").upper(),
            total=_normalize_amount(txn.total),
            tax=_normalize_amount(txn.tax),
            project_slug=txn.project_slug,
            status=txn.status,
            items=tuple(
                LineItem(
                    item_id=item.item_id or uuid.uuid4().hex[:12],
                    description=item.description,
                    quantity=item.quantity,
                    unit_price=item.unit_price,
                    amount=_normalize_amount(item.amount),
                    category_slug=item.category_slug,
                    project_slug=item.project_slug,
                    tax_deductible=item.tax_deductible,
                    notes=item.notes,
                    item_code=item.item_code,
                    discount=item.discount,
                    tax_rate=item.tax_rate,
                )
                for item in (txn.items or ())
            ),
            payment_method=txn.payment_method,
            source_file=txn.source_file,
            content_hash=txn.content_hash,
            correlation_id=txn.correlation_id,
            notes=txn.notes,
            created_at=created_at,
            verified_at=verified_at,
            # Phase 1.5 — accounting fields must round-trip through the
            # normaliser so commit → fetch preserves everything.
            receipt_number=txn.receipt_number,
            document_type=txn.document_type,
            subtotal=txn.subtotal,
            vendor_tax_id=txn.vendor_tax_id,
            vendor_address=txn.vendor_address,
            due_date=txn.due_date,
            discount_total=txn.discount_total,
            discount_description=txn.discount_description,
            tip=txn.tip,
            tax_breakdown=txn.tax_breakdown,
            buyer_tax_id=txn.buyer_tax_id,
            reverse_charge=txn.reverse_charge,
        )

        if self._db is None:
            return self._commit_in_memory(normalized)

        try:
            async with self._lock:
                # Hash-based dedup
                if normalized.content_hash:
                    cur = await self._db.execute(
                        "SELECT txn_id FROM finance_transactions "
                        "WHERE content_hash = ? AND status != 'archived' LIMIT 1",
                        (normalized.content_hash,),
                    )
                    row = await cur.fetchone()
                    await cur.close()
                    if row:
                        return row[0]

                await self._db.execute(
                    "INSERT INTO finance_transactions ("
                    "txn_id, txn_date, merchant_slug, currency, total, tax, "
                    "project_slug, status, payment_method, source_file, "
                    "content_hash, correlation_id, notes, created_at, verified_at, "
                    "receipt_number, document_type, subtotal, "
                    "vendor_tax_id, vendor_address, due_date, "
                    "discount_total, discount_description, tip, "
                    "tax_breakdown_json, buyer_tax_id, reverse_charge"
                    ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                    (
                        normalized.txn_id,
                        normalized.txn_date,
                        normalized.merchant_slug,
                        normalized.currency,
                        normalized.total,
                        normalized.tax,
                        normalized.project_slug,
                        normalized.status.value,
                        normalized.payment_method,
                        normalized.source_file,
                        normalized.content_hash,
                        normalized.correlation_id,
                        normalized.notes,
                        normalized.created_at,
                        normalized.verified_at,
                        normalized.receipt_number,
                        normalized.document_type,
                        normalized.subtotal,
                        normalized.vendor_tax_id,
                        normalized.vendor_address,
                        normalized.due_date,
                        normalized.discount_total,
                        normalized.discount_description,
                        normalized.tip,
                        json.dumps([asdict(tl) for tl in normalized.tax_breakdown], ensure_ascii=False),
                        normalized.buyer_tax_id,
                        1 if normalized.reverse_charge else 0,
                    ),
                )
                for position, item in enumerate(normalized.items):
                    await self._db.execute(
                        "INSERT INTO finance_line_items ("
                        "item_id, txn_id, description, quantity, unit_price, "
                        "amount, category_slug, project_slug, tax_deductible, "
                        "notes, position, item_code, discount, tax_rate"
                        ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                        (
                            item.item_id,
                            normalized.txn_id,
                            item.description,
                            item.quantity,
                            item.unit_price,
                            item.amount,
                            item.category_slug,
                            item.project_slug,
                            1 if item.tax_deductible else 0,
                            item.notes,
                            position,
                            item.item_code,
                            item.discount,
                            item.tax_rate,
                        ),
                    )
                await self._db.commit()
            return normalized.txn_id
        except Exception as exc:
            self._record_failures += 1
            self._last_error = f"commit: {type(exc).__name__}: {exc}"
            self._last_error_ts = _now_iso()
            log.exception("Failed to commit transaction %s", txn_id)
            return ""

    def _commit_in_memory(self, txn: Transaction) -> str:
        """Test-mode write — same idempotency contract as the SQLite path."""
        if txn.content_hash:
            for existing in self._mem_txns.values():
                if (
                    existing.get("content_hash") == txn.content_hash
                    and existing.get("status") != "archived"
                ):
                    return existing["txn_id"]
        self._mem_txns[txn.txn_id] = {
            "txn_id": txn.txn_id,
            "txn_date": txn.txn_date,
            "merchant_slug": txn.merchant_slug,
            "currency": txn.currency,
            "total": txn.total,
            "tax": txn.tax,
            "project_slug": txn.project_slug,
            "status": txn.status.value,
            "payment_method": txn.payment_method,
            "source_file": txn.source_file,
            "content_hash": txn.content_hash,
            "correlation_id": txn.correlation_id,
            "notes": txn.notes,
            "created_at": txn.created_at,
            "verified_at": txn.verified_at,
            # Phase 1.5 accounting fields
            "receipt_number": txn.receipt_number,
            "document_type": txn.document_type,
            "subtotal": txn.subtotal,
            "vendor_tax_id": txn.vendor_tax_id,
            "vendor_address": txn.vendor_address,
            "due_date": txn.due_date,
            "discount_total": txn.discount_total,
            "discount_description": txn.discount_description,
            "tip": txn.tip,
            # VAT
            "tax_breakdown_json": json.dumps(
                [asdict(tl) for tl in txn.tax_breakdown], ensure_ascii=False,
            ),
            "buyer_tax_id": txn.buyer_tax_id,
            "reverse_charge": 1 if txn.reverse_charge else 0,
        }
        self._mem_items[txn.txn_id] = [
            {**asdict(item), "position": i, "tax_deductible": int(item.tax_deductible)}
            for i, item in enumerate(txn.items)
        ]
        return txn.txn_id

    # ────────────────────────────────────────────────────────────────────
    # Transactions — read
    # ────────────────────────────────────────────────────────────────────
    async def get_transaction(self, txn_id: str) -> Transaction | None:
        """Fetch one transaction with its line items, or None if not found."""
        if not self._initialized:
            await self.initialize()
        if self._db is None:
            row = self._mem_txns.get(txn_id)
            if row is None:
                return None
            items = self._mem_items.get(txn_id, [])
            return self._row_to_transaction(row, items)
        try:
            cur = await self._db.execute(
                "SELECT * FROM finance_transactions WHERE txn_id = ?",
                (txn_id,),
            )
            cur.row_factory = _dict_row_factory
            row = await cur.fetchone()
            await cur.close()
            if row is None:
                return None
            items_cur = await self._db.execute(
                "SELECT * FROM finance_line_items WHERE txn_id = ? ORDER BY position",
                (txn_id,),
            )
            items_cur.row_factory = _dict_row_factory
            item_rows = await items_cur.fetchall()
            await items_cur.close()
            return self._row_to_transaction(row, item_rows)
        except Exception as exc:
            self._last_error = f"get: {type(exc).__name__}: {exc}"
            self._last_error_ts = _now_iso()
            log.exception("Failed to fetch transaction %s", txn_id)
            return None

    async def list_transactions(
        self,
        *,
        since: str = "",
        until: str = "",
        merchant_slug: str = "",
        project_slug: str = "",
        category_slug: str = "",
        status: str = "",
        currency: str = "",
        limit: int = 100,
        offset: int = 0,
    ) -> list[Transaction]:
        """Filtered ledger query, newest-first.

        Status defaults to "all visible" — returns ``draft``/``verified``/
        ``superseded`` but excludes ``archived`` and ``rejected``. Pass
        ``status="archived"`` etc. explicitly to include them.
        """
        if not self._initialized:
            await self.initialize()

        clauses: list[str] = []
        params: list = []

        if status:
            clauses.append("t.status = ?")
            params.append(status)
        else:
            clauses.append("t.status NOT IN ('archived', 'rejected')")
        if since:
            clauses.append("t.txn_date >= ?")
            params.append(since)
        if until:
            clauses.append("t.txn_date <= ?")
            params.append(until)
        if merchant_slug:
            clauses.append("t.merchant_slug = ?")
            params.append(merchant_slug)
        if project_slug:
            clauses.append("(t.project_slug = ? OR EXISTS ("
                           "SELECT 1 FROM finance_line_items i "
                           "WHERE i.txn_id = t.txn_id AND i.project_slug = ?))")
            params.append(project_slug)
            params.append(project_slug)
        if category_slug:
            clauses.append("EXISTS (SELECT 1 FROM finance_line_items i "
                           "WHERE i.txn_id = t.txn_id AND i.category_slug = ?)")
            params.append(category_slug)
        if currency:
            clauses.append("t.currency = ?")
            params.append(currency.upper())

        if self._db is None:
            return self._list_in_memory(
                since, until, merchant_slug, project_slug,
                category_slug, status, currency, limit, offset,
            )

        where = " AND ".join(clauses) if clauses else "1=1"
        sql = (
            f"SELECT * FROM finance_transactions t "
            f"WHERE {where} "
            f"ORDER BY t.txn_date DESC, t.created_at DESC "
            f"LIMIT ? OFFSET ?"
        )
        params.extend([limit, offset])

        try:
            cur = await self._db.execute(sql, tuple(params))
            cur.row_factory = _dict_row_factory
            rows = await cur.fetchall()
            await cur.close()
            results: list[Transaction] = []
            for row in rows:
                items_cur = await self._db.execute(
                    "SELECT * FROM finance_line_items WHERE txn_id = ? ORDER BY position",
                    (row["txn_id"],),
                )
                items_cur.row_factory = _dict_row_factory
                item_rows = await items_cur.fetchall()
                await items_cur.close()
                results.append(self._row_to_transaction(row, item_rows))
            return results
        except Exception as exc:
            self._last_error = f"list: {type(exc).__name__}: {exc}"
            self._last_error_ts = _now_iso()
            log.exception("Failed to list transactions")
            return []

    def _list_in_memory(
        self,
        since: str,
        until: str,
        merchant_slug: str,
        project_slug: str,
        category_slug: str,
        status: str,
        currency: str,
        limit: int,
        offset: int,
    ) -> list[Transaction]:
        """In-memory equivalent of list_transactions for tests."""
        rows = list(self._mem_txns.values())

        def keep(r: dict) -> bool:
            if status:
                if r["status"] != status:
                    return False
            elif r["status"] in ("archived", "rejected"):
                return False
            if since and r["txn_date"] < since:
                return False
            if until and r["txn_date"] > until:
                return False
            if merchant_slug and r["merchant_slug"] != merchant_slug:
                return False
            if currency and r["currency"] != currency.upper():
                return False
            if project_slug:
                items = self._mem_items.get(r["txn_id"], [])
                if r["project_slug"] != project_slug and not any(
                    item.get("project_slug") == project_slug for item in items
                ):
                    return False
            if category_slug:
                items = self._mem_items.get(r["txn_id"], [])
                if not any(item.get("category_slug") == category_slug for item in items):
                    return False
            return True

        filtered = [r for r in rows if keep(r)]
        filtered.sort(
            key=lambda r: (r["txn_date"], r["created_at"]),
            reverse=True,
        )
        page = filtered[offset : offset + limit]
        return [
            self._row_to_transaction(row, self._mem_items.get(row["txn_id"], []))
            for row in page
        ]

    # ────────────────────────────────────────────────────────────────────
    # Transactions — update / delete
    # ────────────────────────────────────────────────────────────────────
    async def update_transaction_status(
        self,
        txn_id: str,
        status: TransactionStatus,
        *,
        verified_at: str = "",
    ) -> bool:
        """Flip a transaction's status. Used for verify, reject, archive, supersede."""
        if not self._initialized:
            await self.initialize()
        ts = verified_at or (_now_iso() if status == TransactionStatus.VERIFIED else "")
        if self._db is None:
            row = self._mem_txns.get(txn_id)
            if row is None:
                return False
            row["status"] = status.value
            if ts:
                row["verified_at"] = ts
            return True
        try:
            async with self._lock:
                cur = await self._db.execute(
                    "UPDATE finance_transactions SET status = ?, verified_at = ? "
                    "WHERE txn_id = ?",
                    (status.value, ts, txn_id),
                )
                rowcount = cur.rowcount
                await cur.close()
                await self._db.commit()
            return rowcount > 0
        except Exception as exc:
            self._last_error = f"update_status: {type(exc).__name__}: {exc}"
            self._last_error_ts = _now_iso()
            log.exception("Failed to update transaction %s status", txn_id)
            return False

    async def update_line_item(
        self,
        item_id: str,
        *,
        category_slug: str | None = None,
        project_slug: str | None = None,
        tax_deductible: bool | None = None,
        notes: str | None = None,
    ) -> bool:
        """Recategorize / reattribute a single line item.

        ``None`` means "leave unchanged"; empty string means "clear". This
        is the surface the manual-correction UI calls — split-receipt
        attribution lives at the line-item level.
        """
        if not self._initialized:
            await self.initialize()

        sets: list[str] = []
        params: list = []
        if category_slug is not None:
            sets.append("category_slug = ?")
            params.append(category_slug)
        if project_slug is not None:
            sets.append("project_slug = ?")
            params.append(project_slug)
        if tax_deductible is not None:
            sets.append("tax_deductible = ?")
            params.append(1 if tax_deductible else 0)
        if notes is not None:
            sets.append("notes = ?")
            params.append(notes)
        if not sets:
            return True

        if self._db is None:
            for items in self._mem_items.values():
                for item in items:
                    if item.get("item_id") == item_id:
                        if category_slug is not None:
                            item["category_slug"] = category_slug
                        if project_slug is not None:
                            item["project_slug"] = project_slug
                        if tax_deductible is not None:
                            item["tax_deductible"] = int(tax_deductible)
                        if notes is not None:
                            item["notes"] = notes
                        return True
            return False

        params.append(item_id)
        try:
            async with self._lock:
                cur = await self._db.execute(
                    f"UPDATE finance_line_items SET {', '.join(sets)} WHERE item_id = ?",
                    tuple(params),
                )
                rowcount = cur.rowcount
                await cur.close()
                await self._db.commit()
            return rowcount > 0
        except Exception as exc:
            self._last_error = f"update_item: {type(exc).__name__}: {exc}"
            self._last_error_ts = _now_iso()
            log.exception("Failed to update line item %s", item_id)
            return False

    async def update_source_file(self, txn_id: str, source_file: str) -> bool:
        """Update the ``source_file`` pointer on an existing transaction row.

        Used by the inbox→library migration on startup so historical
        transactions whose original receipt was left in ``finance/inbox/``
        can be re-pointed at the new ``library/`` location after the file
        is moved. Idempotent — re-applying the same value is a no-op.
        """
        if not txn_id:
            return False
        if not self._initialized:
            await self.initialize()
        if self._db is None:
            row = self._mem_txns.get(txn_id)
            if row is None:
                return False
            row["source_file"] = source_file
            return True
        try:
            async with self._lock:
                cur = await self._db.execute(
                    "UPDATE finance_transactions SET source_file = ? "
                    "WHERE txn_id = ?",
                    (source_file, txn_id),
                )
                rowcount = cur.rowcount
                await cur.close()
                await self._db.commit()
            return rowcount > 0
        except Exception as exc:
            self._last_error = f"update_source_file: {type(exc).__name__}: {exc}"
            self._last_error_ts = _now_iso()
            log.exception("Failed to update source_file for %s", txn_id)
            return False

    async def soft_delete_transaction(self, txn_id: str) -> bool:
        """Soft-delete: status flipped to ``archived``. Reversible.

        We never DELETE rows from ``finance_transactions`` in the normal flow.
        Hard delete is exposed only via privacy/forget endpoints (Phase 1.4).
        """
        return await self.update_transaction_status(txn_id, TransactionStatus.ARCHIVED)

    # ────────────────────────────────────────────────────────────────────
    # Health
    # ────────────────────────────────────────────────────────────────────
    def health(self) -> dict:
        """Self-report counters. Surfaced in the dashboard alongside DecisionLogger."""
        return {
            "backend": "sqlite" if self._db is not None else "fallback",
            "initialized": self._initialized,
            "record_attempts": self._record_attempts,
            "record_failures": self._record_failures,
            "failure_rate": (
                self._record_failures / self._record_attempts
                if self._record_attempts
                else 0.0
            ),
            "last_error": self._last_error,
            "last_error_ts": self._last_error_ts,
        }

    # ────────────────────────────────────────────────────────────────────
    # Helpers
    # ────────────────────────────────────────────────────────────────────
    @staticmethod
    def _row_to_transaction(row: dict, item_rows: list[dict]) -> Transaction:
        try:
            status = TransactionStatus(row.get("status") or "verified")
        except ValueError:
            status = TransactionStatus.VERIFIED
        items = tuple(
            LineItem(
                item_id=ir.get("item_id", ""),
                description=ir.get("description", ""),
                quantity=ir.get("quantity", "") or "",
                unit_price=ir.get("unit_price", "") or "",
                amount=ir.get("amount", "0.00") or "0.00",
                category_slug=ir.get("category_slug", "") or "",
                project_slug=ir.get("project_slug", "") or "",
                tax_deductible=bool(ir.get("tax_deductible", 0)),
                notes=ir.get("notes", "") or "",
                item_code=ir.get("item_code", "") or "",
                discount=ir.get("discount", "") or "",
                tax_rate=ir.get("tax_rate", "") or "",
            )
            for ir in sorted(item_rows, key=lambda r: r.get("position", 0))
        )
        # Decode tax_breakdown JSON column. Pre-1.5 rows have either
        # NULL or empty string; both round-trip to an empty tuple.
        try:
            raw_breakdown = row.get("tax_breakdown_json") or "[]"
            decoded = json.loads(raw_breakdown) if isinstance(raw_breakdown, str) else []
            if not isinstance(decoded, list):
                decoded = []
            tax_breakdown = tuple(
                TaxLine(
                    rate=str(tl.get("rate", "")),
                    amount=str(tl.get("amount", "")),
                    taxable_base=str(tl.get("taxable_base", "")),
                )
                for tl in decoded
                if isinstance(tl, dict)
            )
        except (json.JSONDecodeError, TypeError, AttributeError):
            tax_breakdown = ()
        return Transaction(
            txn_id=row["txn_id"],
            txn_date=row.get("txn_date", ""),
            merchant_slug=row.get("merchant_slug", "") or "",
            currency=row.get("currency", "") or "",
            total=row.get("total", "0.00") or "0.00",
            tax=row.get("tax", "0.00") or "0.00",
            project_slug=row.get("project_slug", "") or "",
            status=status,
            items=items,
            payment_method=row.get("payment_method", "") or "",
            source_file=row.get("source_file", "") or "",
            content_hash=row.get("content_hash", "") or "",
            correlation_id=row.get("correlation_id", "") or "",
            notes=row.get("notes", "") or "",
            created_at=row.get("created_at", "") or "",
            verified_at=row.get("verified_at", "") or "",
            receipt_number=row.get("receipt_number", "") or "",
            document_type=row.get("document_type", "") or "",
            subtotal=row.get("subtotal", "") or "",
            vendor_tax_id=row.get("vendor_tax_id", "") or "",
            vendor_address=row.get("vendor_address", "") or "",
            due_date=row.get("due_date", "") or "",
            discount_total=row.get("discount_total", "") or "",
            discount_description=row.get("discount_description", "") or "",
            tip=row.get("tip", "") or "",
            tax_breakdown=tax_breakdown,
            buyer_tax_id=row.get("buyer_tax_id", "") or "",
            reverse_charge=bool(row.get("reverse_charge", 0)),
        )


def _dict_row_factory(cursor, row):
    """Convert aiosqlite tuple rows to dicts. Local to avoid global config."""
    return {col[0]: row[idx] for idx, col in enumerate(cursor.description)}


# Re-export the canonical helper for tests / future modules.
__all__ = ["FinanceLedger", "_normalize_amount", "_new_txn_id"]
