"""Receipt ingestion engine — Layer 1 of the finance pipeline.

Orchestrates: file → vision/OCR LLM call → structured JSON → ``TransactionDraft``
→ markdown in ``finance/pending/`` + ledger row at ``status=draft``. Does NOT
commit to the verified ledger — that's a separate user-approval step.

Design choices:

- **Single LLM call per file** with a strict structured-output prompt.
  Avoids the two-pass overhead of "describe → structure" by asking for JSON
  in one shot. Audit step catches malformed extractions.
- **File-type aware**: images → vision LLM with bytes inline; PDFs →
  pdfplumber text-layer extraction → text LLM. Scanned PDFs (no text layer)
  fall through to vision extraction of the first page render. The
  ``receipt-image`` / ``receipt-pdf`` skill YAMLs own the prompt_focus
  text — Rule #12, no prompt strings in this module.
- **Idempotent on content_hash**: the same file ingested twice returns
  the existing draft_id without writing. Hash is computed before any LLM
  call so dedup is free.
- **Graceful failure**: malformed LLM output, missing total, illegible
  merchant — all produce a draft with audit_flags set, never raise. The
  user sees the flagged draft in ``/finance/pending`` and decides.

This module does NOT call the contract enforcer or open a correlation scope.
That's the caller's job — the API route, agent tool, or workflow step that
invokes ``ReceiptIngest.ingest()`` is responsible for opening the scope and
authorising ``ingest_receipt``.
"""

from __future__ import annotations

import base64
import hashlib
import json
import logging
import re
import uuid
from dataclasses import replace
from datetime import UTC, date, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

from agntspace.finance.ledger import _new_txn_id, _normalize_amount
from agntspace.finance.storage import (
    attach_receipt_to_library,
    remove_inbox_duplicate,
    write_draft,
)
from agntspace.finance.types import (
    LineItem,
    TaxLine,
    TransactionDraft,
    TransactionStatus,
)

if TYPE_CHECKING:
    from agntspace.finance.ledger import FinanceLedger
    from agntspace.finance.merchants import MerchantResolver
    from agntspace.llm.base import LLMProvider
    from agntspace.skills.loader import SkillLoader
    from agntspace.vault.paths import VaultPaths
    from agntspace.vault.writer import VaultWriter

log = logging.getLogger(__name__)


# Default structured-output prompt. Used as the fallback when the
# receipt-image / receipt-pdf file-type skill is unavailable.
# Skill YAML (config/file-type.yaml::prompt_focus) is the canonical source.
_FALLBACK_PROMPT = (
    "Extract the structured contents of this receipt or invoice. Return ONLY "
    "a JSON object matching this schema (no markdown fences, no commentary):\n"
    "{\n"
    '  "merchant_raw": "<merchant name as printed>",\n'
    '  "txn_date": "<YYYY-MM-DD>",\n'
    '  "currency": "<ISO 4217 code>",\n'
    '  "total": "<decimal string>",\n'
    '  "subtotal": "<decimal string, pre-tax; empty if not printed>",\n'
    '  "tax": "<decimal string or empty>",\n'
    '  "tip": "<decimal string for gratuity/service charge; empty if none>",\n'
    '  "discount_total": "<decimal string, transaction-level discount; positive number; empty if none>",\n'
    '  "discount_description": "<free-form: \\"SUMMER25\\", \\"Loyalty 10%\\", \\"Employee\\"; empty if no discount>",\n'
    '  "payment_method": "<masked card last-4 or cash/transfer or empty>",\n'
    '  "receipt_number": "<receipt or invoice number; empty if absent>",\n'
    '  "document_type": "<receipt | invoice | credit_note | refund | other>",\n'
    '  "vendor_tax_id": "<vendor VAT/Org/EIN; e.g. SE556677-8899; empty if absent>",\n'
    '  "vendor_address": "<billing address as printed; empty if absent>",\n'
    '  "due_date": "<YYYY-MM-DD; ONLY for invoices with a payment due date; empty otherwise>",\n'
    '  "buyer_tax_id": "<buyer VAT/Org if printed on the invoice; empty otherwise>",\n'
    '  "reverse_charge": false,  /* true ONLY for EU B2B 0% reverse-charge invoices */\n'
    '  "tax_breakdown": [\n'
    '    {"rate": "25", "amount": "<vat at 25%>", "taxable_base": "<net at 25%>"},\n'
    '    {"rate": "12", "amount": "<vat at 12%>", "taxable_base": "<net at 12%>"}\n'
    '  ],\n'
    '  "line_items": [\n'
    '    {"description": "...", "item_code": "<SKU/PLU/barcode if printed; else empty>",'
    ' "quantity": "...", "unit_price": "...", "amount": "<post-discount line total>",'
    ' "discount": "<positive decimal if line shows a discount; else empty>",'
    ' "tax_rate": "<VAT % printed on this line e.g. \\"25\\"; empty if not printed per-line>"}\n'
    "  ]\n"
    "}\n"
    "Rules:\n"
    "- Use empty string for fields you cannot read.\n"
    "- NEVER invent data the receipt does not show.\n"
    "- NEVER include card numbers beyond the last 4.\n"
    "- Discounts are POSITIVE numbers (e.g. a $5 discount is \"5.00\", not \"-5.00\").\n"
    "- Each line's `amount` is the POST-discount value (what the customer paid for that line).\n"
    "- `discount_total` is the transaction-level discount applied AFTER items but BEFORE tax;\n"
    "  reconciliation: sum(items.amount) - discount_total + tax + tip = total.\n"
    "- `tip` is gratuity / service charge — separate from discount and tax.\n"
    "- For Swedish receipts, vendor_tax_id is the org-nummer (NNNNNN-NNNN);\n"
    "  for EU it's the VAT number; for US it's EIN if printed.\n"
    "- document_type defaults to 'receipt' for paid same-day transactions;\n"
    "  use 'invoice' only when there's a printed due date / unpaid balance.\n"
    "- If subtotal is not printed but total + tax are both present, leave\n"
    "  subtotal empty (do NOT compute it — the auditor reconciles).\n"
    "- `item_code` should be the SKU / PLU / barcode VERBATIM as printed (digits, dashes, etc.).\n"
    "- VAT breakdown: include one entry per RATE that appears on the receipt.\n"
    "  Sum of `tax_breakdown[*].amount` should equal top-level `tax`.\n"
    "  When the receipt shows only a single VAT line (one rate), still emit\n"
    "  one entry. When the receipt is a reverse-charge invoice with 0% VAT,\n"
    "  emit `tax_breakdown: []` and set `reverse_charge: true`.\n"
    "- `tax_rate` per line is OPTIONAL — only set it when the rate is actually\n"
    "  printed next to the line (some POS systems do, most don't).\n"
    "- `buyer_tax_id` is YOUR organization's VAT/Org number printed on the invoice\n"
    "  as the buyer. Most consumer receipts don't have it — leave empty."
)

_IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".heic", ".webp"})  # arch:deterministic — image format capabilities of the receipt vision pipeline; mirrors raw_watcher._IMAGE_EXTS
_PDF_EXTS = frozenset({".pdf"})  # arch:deterministic — capability surface of the PDF receipt extractor; receipt-pdf file-type spec authoritatively declares the list, this is the matching engine-side gate

# Quality gate for auto-creating new merchants from receipt extractions.
# Conservative — refuses noise and LLM error markers but accepts any
# reasonable real-world merchant name (Latin, CJK, Cyrillic, hybrid).
# Stricter rules belong in the merchant-resolve skill YAML; these are the
# floor (Rule 12: pure noise filter, no domain strategy).
_AUTO_CREATE_NOISE_MARKERS = frozenset({  # arch:deterministic — known LLM-error markers
    "n/a", "na", "none", "null", "tbd", "unknown",
    "unreadable", "unclear", "(unreadable)", "(unclear)",
    "merchant", "vendor", "store",
})


def _is_auto_createable_merchant(raw: str) -> bool:
    """Quality gate for auto-creating a merchant from a receipt extraction.

    Returns True iff the raw text:
      - has at least 3 characters after strip
      - contains at least one alphabetic character (any script)
      - is NOT a known noise marker / placeholder
      - is NOT a pure number / phone-number-shaped string

    The bar is intentionally low — for personal finance a duplicate is
    cheap (mergeable later) but a missed merchant is friction.
    """
    text = (raw or "").strip()
    if len(text) < 3:
        return False
    if text.lower() in _AUTO_CREATE_NOISE_MARKERS:
        return False
    if not any(ch.isalpha() for ch in text):
        # Pure digits / punctuation → almost certainly not a merchant name
        return False
    # Reject runs of digits that dominate the string (likely transaction
    # number captured as merchant name by mistake).
    digit_share = sum(1 for ch in text if ch.isdigit()) / len(text)
    if digit_share > 0.6:
        return False
    return True


# ────────────────────────────────────────────────────────────────────
# Result shape
# ────────────────────────────────────────────────────────────────────
class IngestResult:
    """Lightweight result wrapper. Not a dataclass to keep this pure."""

    __slots__ = ("draft", "deduplicated", "error")

    def __init__(
        self,
        draft: TransactionDraft | None = None,
        *,
        deduplicated: bool = False,
        error: str = "",
    ) -> None:
        self.draft = draft
        self.deduplicated = deduplicated
        self.error = error

    def to_dict(self) -> dict:
        if self.error:
            return {"ok": False, "error": self.error}
        if self.draft is None:
            return {"ok": False, "error": "no_draft_produced"}
        return {
            "ok": True,
            "draft_id": self.draft.draft_id,
            "merchant_raw": self.draft.merchant_raw,
            "merchant_slug": self.draft.merchant_slug,
            "total": self.draft.total,
            "currency": self.draft.currency,
            "item_count": len(self.draft.items),
            "audit_flags": list(self.draft.audit_flags),
            "deduplicated": self.deduplicated,
        }


# ────────────────────────────────────────────────────────────────────
# Engine
# ────────────────────────────────────────────────────────────────────
class ReceiptIngest:
    """Single-file receipt → TransactionDraft pipeline.

    Test fixture pattern: pass a stub LLM that returns a known JSON string
    via ``complete()`` / ``complete_vision()``. The merchant resolver and
    ledger can be in-memory (db=None). Writer is required (writes the draft
    markdown) — use a temp-dir-backed VaultWriter in tests.
    """

    def __init__(
        self,
        llm: LLMProvider,
        merchants: MerchantResolver,
        writer: VaultWriter,
        ledger: FinanceLedger | None = None,
        *,
        skill_loader: SkillLoader | None = None,
        currency_precision: float = 0.02,
        vault_paths: VaultPaths | None = None,
    ) -> None:
        self._llm = llm
        self._merchants = merchants
        self._writer = writer
        self._ledger = ledger
        self._skill_loader = skill_loader
        self._currency_precision = currency_precision
        # ``vault_paths`` is optional so existing test fixtures keep working
        # without a paths resolver. When wired, the engine moves originals
        # out of ``finance/inbox/`` and into ``finance/library/YYYY-MM/`` so
        # the inbox stays empty regardless of the user's review pace.
        self._vault_paths = vault_paths

    # ────────────────────────────────────────────────────────────────────
    # Public API
    # ────────────────────────────────────────────────────────────────────
    async def ingest(self, path: Path, *, source_logical_path: str = "") -> IngestResult:
        """Process one receipt file. Never raises."""
        try:
            if not path.exists() or not path.is_file():
                return IngestResult(error=f"file_not_found: {path}")

            ext = path.suffix.lower()
            if ext not in _IMAGE_EXTS and ext not in _PDF_EXTS:
                return IngestResult(error=f"unsupported_filetype: {ext}")

            # 1. Hash for idempotent dedup
            content_hash = self._hash_file(path)

            # 2. Check ledger for existing transaction with this hash
            if self._ledger is not None:
                existing = await self._find_existing_by_hash(content_hash)
                if existing is not None:
                    # Original is already attached to the prior draft/transaction
                    # in finance/library/. The duplicate copy in inbox/ would
                    # otherwise sit there forever — clean it up so the inbox
                    # stays empty (Rule #1: vault layout matches user mental
                    # model; receipts belong "with" their transaction, not in
                    # a generic drop zone).
                    if self._vault_paths is not None and source_logical_path:
                        try:
                            remove_inbox_duplicate(
                                self._vault_paths,
                                source_logical_path=source_logical_path,
                            )
                        except Exception:
                            log.exception(
                                "dedup cleanup failed for %s", source_logical_path,
                            )
                    return IngestResult(
                        draft=existing,
                        deduplicated=True,
                    )

            # 3. Run extraction → structured JSON
            extracted = await self._extract(path, ext)
            if extracted is None:
                return IngestResult(error="extraction_failed")

            # 4. Build draft
            draft_id = _new_txn_id()
            draft = self._build_draft(
                draft_id=draft_id,
                extracted=extracted,
                source_file=source_logical_path or str(path),
                content_hash=content_hash,
            )

            # 5. Resolve merchant + record alias if new
            #
            # Three-tier resolution:
            #   1. Existing canonical match → use that slug
            #   2. No match BUT merchant_raw passes the auto-create quality
            #      gate → upsert a new canonical merchant from the receipt
            #      and stamp the draft with `merchant_auto_created` so the
            #      user can see it was inferred (and rename if wrong)
            #   3. Fails the quality gate → leave merchant_slug empty,
            #      surface as `unreadable_merchant` in audit_flags
            extra_audit_flags: list[str] = []
            if draft.merchant_raw:
                match = await self._merchants.resolve(draft.merchant_raw)
                if match is not None:
                    draft = replace(draft, merchant_slug=match.merchant_slug)
                    raw = draft.merchant_raw.strip()
                    if raw and raw.lower() != match.canonical_name.lower() and raw not in match.aliases:
                        await self._merchants.add_alias(match.merchant_slug, raw)
                    # Observed tax_id on the receipt → persist on the
                    # merchant if absent. Idempotent observation (won't
                    # overwrite an existing one — see upsert_merchant docs).
                    if draft.vendor_tax_id and not match.tax_id:
                        await self._merchants.upsert_merchant(
                            slug=match.merchant_slug,
                            canonical_name=match.canonical_name,
                            tax_id=draft.vendor_tax_id,
                        )
                elif _is_auto_createable_merchant(draft.merchant_raw):
                    # First sight of this merchant — auto-create a canonical
                    # entry. Personal-finance scope: the receipt itself is
                    # the user signal, duplicates can be merged later. The
                    # vendor_tax_id (if extracted) seeds the persistent
                    # merchant identity for cross-receipt validation.
                    new_slug = await self._merchants.upsert_merchant(
                        canonical_name=draft.merchant_raw.strip(),
                        tax_id=draft.vendor_tax_id,
                    )
                    if new_slug:
                        draft = replace(draft, merchant_slug=new_slug)
                        extra_audit_flags.append("merchant_auto_created")
                        log.info(
                            "Auto-created merchant %r → slug=%s (from receipt extraction)",
                            draft.merchant_raw,
                            new_slug,
                        )
                    # else: upsert returned empty (e.g. canonical_name was
                    # blank after strip); fall through to leave slug empty.

            # 6. Validate + assign audit flags (merging in any merchant
            # resolution flags from step 5)
            flags = self._validate(draft)
            flags.extend(extra_audit_flags)
            # Skill-driven duplicate detection — reads dedup_rules from
            # transaction-audit/config/audit.yaml. Each matching rule
            # appends an audit flag formatted with the existing
            # transaction's id, so the user can click through at /finance.
            dupe_flags = await self._check_duplicates(draft)
            flags.extend(dupe_flags)
            if flags:
                draft = replace(draft, audit_flags=tuple(flags))

            # 7. Move original out of inbox into library/ before persisting
            # so the draft's source_file points at the canonical attachment
            # location from the very first read. The helper is idempotent +
            # never raises — failure leaves the original in place and the
            # draft records the inbox path (the photo endpoint searches both
            # locations, so the user always sees the receipt).
            if self._vault_paths is not None and source_logical_path:
                try:
                    new_logical = attach_receipt_to_library(
                        self._vault_paths,
                        source_logical_path=source_logical_path,
                        source_physical_path=path,
                        draft_id=draft.draft_id,
                        txn_date=draft.txn_date,
                    )
                    if new_logical and new_logical != draft.source_file:
                        draft = replace(draft, source_file=new_logical)
                except Exception:
                    log.exception(
                        "attach failed for draft %s — leaving source in inbox",
                        draft.draft_id,
                    )

            # 8. Persist (markdown + ledger row at draft status)
            write_draft(self._writer, draft)
            await self._persist_draft_to_ledger(draft)

            return IngestResult(draft=draft)
        except Exception as exc:
            log.exception("Ingest failed for %s", path)
            return IngestResult(error=f"unexpected: {type(exc).__name__}: {exc}")

    # ────────────────────────────────────────────────────────────────────
    # Extraction — image vs PDF
    # ────────────────────────────────────────────────────────────────────
    async def _extract(self, path: Path, ext: str) -> dict | None:
        """Dispatch by file type, return parsed JSON dict or None."""
        if ext in _IMAGE_EXTS:
            return await self._extract_image(path)
        if ext in _PDF_EXTS:
            return await self._extract_pdf(path)
        return None

    async def _extract_image(self, path: Path) -> dict | None:
        """Vision LLM call with the receipt prompt. Returns parsed dict or None."""
        from agntspace.llm.base import ImageContent

        try:
            image_bytes = path.read_bytes()
        except OSError:
            log.exception("Failed to read image %s", path)
            return None
        b64 = base64.b64encode(image_bytes).decode("ascii")
        media_type = self._mime_for_ext(path.suffix.lower())
        image = ImageContent(base64_data=b64, media_type=media_type)

        prompt = self._receipt_prompt("receipt-image")
        try:
            resp = await self._llm.complete_vision(
                prompt=prompt,
                images=[image],
                max_tokens=2048,
                temperature=0.1,
            )
        except Exception:
            log.exception("Vision LLM call failed for %s", path)
            return None
        return self._parse_json_response(resp.content)

    async def _extract_pdf(self, path: Path) -> dict | None:
        """Digital PDF → text → text LLM. Falls through to None on scanned PDFs."""
        text = self._pdfplumber_text(path)
        if not text or len(text.strip()) < 30:
            # Scanned or empty PDF — Phase 1 skips. Phase 4 OCR ladder
            # would render the page and re-route through vision; for now,
            # surface a clear error so the user re-uploads as an image.
            log.info("PDF %s has no usable text layer — Phase 1 limitation", path)
            return None

        from agntspace.llm.base import Message
        messages: list = [Message(role="user", content=text[:32000])]  # 32k cap
        prompt = self._receipt_prompt("receipt-pdf")
        try:
            resp = await self._llm.complete(
                messages=messages,
                system=prompt,
                max_tokens=2048,
                temperature=0.1,
            )
        except Exception:
            log.exception("Text LLM call failed for %s", path)
            return None
        return self._parse_json_response(resp.content)

    # ────────────────────────────────────────────────────────────────────
    # Helpers
    # ────────────────────────────────────────────────────────────────────
    def _receipt_prompt(self, file_type_skill: str) -> str:
        """Read prompt_focus from the file-type skill, fall back to default."""
        if self._skill_loader is None:
            return _FALLBACK_PROMPT
        try:
            cfg = self._skill_loader.get_skill_config(file_type_skill, "file-type")
        except Exception:
            return _FALLBACK_PROMPT
        if not cfg:
            return _FALLBACK_PROMPT
        focus = cfg.get("prompt_focus") if isinstance(cfg, dict) else None
        if not focus:
            return _FALLBACK_PROMPT
        # Always append the strict JSON schema reminder — skill YAML may
        # describe goals in prose, but we need parseable output.
        return f"{focus}\n\n{_FALLBACK_PROMPT}"

    @staticmethod
    def _mime_for_ext(ext: str) -> str:
        return {
            ".jpg": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".png": "image/png",
            ".heic": "image/heic",
            ".webp": "image/webp",
        }.get(ext, "image/jpeg")

    @staticmethod
    def _hash_file(path: Path) -> str:
        """SHA-256 of file bytes — same family as KB ingest content_hash."""
        try:
            return hashlib.sha256(path.read_bytes()).hexdigest()
        except OSError:
            return ""

    @staticmethod
    def _pdfplumber_text(path: Path) -> str:
        """Extract digital text from a PDF. Empty string on no text layer."""
        try:
            import pdfplumber  # type: ignore[import-untyped]
        except ImportError:
            log.warning("pdfplumber not installed — cannot extract PDF text")
            return ""
        try:
            with pdfplumber.open(path) as pdf:
                pages = [(page.extract_text() or "") for page in pdf.pages]
            return "\n\n".join(pages).strip()
        except Exception:
            log.exception("pdfplumber failed on %s", path)
            return ""

    @staticmethod
    def _parse_json_response(text: str) -> dict | None:
        """Parse the LLM's structured response.

        Robust to common LLM artifacts:
        - leading/trailing prose
        - markdown code fences (``` or ```json)
        - embedded explanations after the JSON
        Returns None on unrecoverable parse failure.
        """
        if not text:
            return None
        # Strip code fences
        cleaned = text.strip()
        cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
        cleaned = re.sub(r"\s*```$", "", cleaned)
        # Find the first { ... } block (greedy but balanced)
        start = cleaned.find("{")
        end = cleaned.rfind("}")
        if start == -1 or end == -1 or end < start:
            return None
        candidate = cleaned[start : end + 1]
        try:
            parsed = json.loads(candidate)
        except json.JSONDecodeError:
            return None
        if not isinstance(parsed, dict):
            return None
        return parsed

    def _build_draft(
        self,
        *,
        draft_id: str,
        extracted: dict,
        source_file: str,
        content_hash: str,
    ) -> TransactionDraft:
        """Map LLM JSON to TransactionDraft. Conservative on every field."""
        from agntspace.activity.decisions import current_correlation_id

        items_raw = extracted.get("line_items") or extracted.get("items") or []
        items: list[LineItem] = []
        if isinstance(items_raw, list):
            for entry in items_raw:
                if not isinstance(entry, dict):
                    continue
                # Normalise the line-level discount: empty stays empty (no
                # discount on this line), populated values get the same
                # decimal treatment as amounts.
                line_disc_raw = entry.get("discount") or ""
                line_disc = (
                    _normalize_amount(line_disc_raw)
                    if str(line_disc_raw).strip()
                    else ""
                )
                items.append(
                    LineItem(
                        item_id=uuid.uuid4().hex[:12],
                        description=str(entry.get("description") or "").strip(),
                        quantity=str(entry.get("quantity") or "").strip(),
                        unit_price=str(entry.get("unit_price") or "").strip(),
                        amount=_normalize_amount(entry.get("amount") or "0"),
                        category_slug="",  # categorize skill assigns later
                        project_slug="",
                        tax_deductible=False,
                        notes="",
                        item_code=str(entry.get("item_code") or "").strip(),
                        discount=line_disc,
                        tax_rate=str(entry.get("tax_rate") or "").strip(),
                    )
                )

        # If the LLM returned no line items but a total, synthesise a single
        # line so downstream skills always have something to categorise.
        total = _normalize_amount(extracted.get("total") or "0")
        if not items and total != "0.00":
            items.append(
                LineItem(
                    item_id=uuid.uuid4().hex[:12],
                    description=str(extracted.get("merchant_raw") or "").strip()
                    or "Receipt total",
                    amount=total,
                )
            )

        # Phase 1.5 accounting fields. Empty strings when the LLM didn't
        # find the field on the receipt — never invented. The auditor flag
        # surface picks up gaps the user cares about (e.g. unreadable_total).
        subtotal_raw = extracted.get("subtotal") or ""
        subtotal_str = (
            _normalize_amount(subtotal_raw) if str(subtotal_raw).strip() else ""
        )
        discount_total_raw = extracted.get("discount_total") or ""
        discount_total_str = (
            _normalize_amount(discount_total_raw)
            if str(discount_total_raw).strip()
            else ""
        )
        tip_raw = extracted.get("tip") or ""
        tip_str = (
            _normalize_amount(tip_raw) if str(tip_raw).strip() else ""
        )
        # VAT breakdown: list of {rate, amount, taxable_base}. Each rate is
        # a percent string ("25", "12", "6"); amounts go through the same
        # decimal normaliser as totals. Empty list when receipt has no
        # breakdown OR for reverse-charge invoices.
        breakdown_raw = extracted.get("tax_breakdown") or []
        tax_breakdown: list[TaxLine] = []
        if isinstance(breakdown_raw, list):
            for entry in breakdown_raw:
                if not isinstance(entry, dict):
                    continue
                rate = str(entry.get("rate") or "").strip()
                amount_raw = entry.get("amount") or ""
                base_raw = entry.get("taxable_base") or ""
                if not rate and not amount_raw and not base_raw:
                    continue
                tax_breakdown.append(
                    TaxLine(
                        rate=rate,
                        amount=(
                            _normalize_amount(amount_raw)
                            if str(amount_raw).strip()
                            else ""
                        ),
                        taxable_base=(
                            _normalize_amount(base_raw)
                            if str(base_raw).strip()
                            else ""
                        ),
                    )
                )
        return TransactionDraft(
            draft_id=draft_id,
            txn_date=self._normalise_date(extracted.get("txn_date")),
            merchant_slug="",  # filled in by resolve step
            merchant_raw=str(extracted.get("merchant_raw") or "").strip(),
            currency=self._normalise_currency(extracted.get("currency")),
            total=total,
            tax=_normalize_amount(extracted.get("tax") or "0"),
            project_slug="",
            items=tuple(items),
            payment_method=str(extracted.get("payment_method") or "").strip(),
            source_file=source_file,
            content_hash=content_hash,
            correlation_id=current_correlation_id(),
            audit_flags=(),
            extracted_at=datetime.now(UTC).isoformat(timespec="seconds"),
            receipt_number=str(extracted.get("receipt_number") or "").strip(),
            document_type=str(extracted.get("document_type") or "").strip().lower(),
            subtotal=subtotal_str,
            vendor_tax_id=str(extracted.get("vendor_tax_id") or "").strip(),
            vendor_address=str(extracted.get("vendor_address") or "").strip(),
            due_date=self._normalise_date(extracted.get("due_date")),
            discount_total=discount_total_str,
            discount_description=str(extracted.get("discount_description") or "").strip(),
            tip=tip_str,
            tax_breakdown=tuple(tax_breakdown),
            buyer_tax_id=str(extracted.get("buyer_tax_id") or "").strip(),
            reverse_charge=bool(extracted.get("reverse_charge", False)),
        )

    @staticmethod
    def _normalise_date(value: Any) -> str:
        """Coerce to YYYY-MM-DD; empty string if unparseable."""
        if not value:
            return ""
        text = str(value).strip()
        # Already ISO?
        if re.match(r"^\d{4}-\d{2}-\d{2}", text):
            return text[:10]
        # Common alternates: MM/DD/YYYY, DD/MM/YYYY (ambiguous — prefer ISO)
        m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{4})$", text)
        if m:
            # Heuristic: if first part > 12, it's DD/MM
            a, b, y = m.groups()
            ai, bi = int(a), int(b)
            if ai > 12:
                return f"{y}-{bi:02d}-{ai:02d}"
            return f"{y}-{ai:02d}-{bi:02d}"
        return ""

    @staticmethod
    def _normalise_currency(value: Any) -> str:
        """Coerce to ISO 4217 code (3 letters uppercase). Empty on miss."""
        if not value:
            return ""
        text = str(value).strip().upper()
        # Strip symbols if the LLM included them
        symbol_map = {"$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY", "KR": "SEK"}
        if text in symbol_map:
            return symbol_map[text]
        if re.match(r"^[A-Z]{3}$", text):
            return text
        return ""

    def _validate(self, draft: TransactionDraft) -> list[str]:
        """Compute audit_flags. Empty list = clean draft."""
        flags: list[str] = []

        if not draft.merchant_raw:
            flags.append("unreadable_merchant")
        if not draft.txn_date:
            flags.append("unreadable_date")
        if not draft.currency:
            flags.append("unreadable_currency")
        if draft.total == "0.00":
            flags.append("unreadable_total")
        # Future-date guard (clock skew tolerance: 1 day)
        if draft.txn_date:
            try:
                txn_d = date.fromisoformat(draft.txn_date)
                if (txn_d - date.today()).days > 1:
                    flags.append("future_date")
            except ValueError:
                pass

        # Totals reconciliation (Phase 1.5):
        #   sum(items.amount) − discount_total + tax + tip ≈ total
        # All four optional terms default to 0 when empty, so pre-1.5
        # receipts (no discount/tip on file) reconcile exactly as before.
        if draft.items and draft.total != "0.00":
            try:
                from decimal import Decimal
                items_sum = sum(Decimal(item.amount) for item in draft.items)
                tax = Decimal(draft.tax or "0")
                discount_total = Decimal(draft.discount_total or "0")
                tip = Decimal(draft.tip or "0")
                total = Decimal(draft.total)
                diff = abs(
                    (items_sum - discount_total + tax + tip) - total
                )
                if diff > Decimal(str(self._currency_precision)):
                    flags.append("totals_mismatch")
            except Exception:
                # Decimal parse failure → don't add a flag here; the
                # individual unreadable_total flag handles the bad-total case
                pass

        # VAT breakdown cross-check (Phase 1.5):
        # When a breakdown is present, sum-of-amounts should equal the
        # top-level `tax`. Empty breakdown is fine (consumer receipts often
        # don't print one, and reverse_charge invoices have empty breakdown
        # by design). Reverse-charge invoices skip the check entirely.
        if draft.tax_breakdown and not draft.reverse_charge:
            try:
                from decimal import Decimal
                breakdown_sum = sum(
                    Decimal(tl.amount or "0") for tl in draft.tax_breakdown
                )
                top_tax = Decimal(draft.tax or "0")
                if abs(breakdown_sum - top_tax) > Decimal(str(self._currency_precision)):
                    flags.append("vat_breakdown_mismatch")
            except Exception:
                # Don't add a flag on parse failure — same conservative
                # posture as the totals check.
                pass

        return flags

    # ────────────────────────────────────────────────────────────────────
    # Skill-driven duplicate detection (Phase 1.5)
    # ────────────────────────────────────────────────────────────────────
    async def _check_duplicates(self, draft: TransactionDraft) -> list[str]:
        """Apply each skill-driven dedup rule against the ledger.

        Returns a list of audit-flag strings (one per matching rule).
        Empty list when ledger is unwired, no rules are configured, or no
        rule matched. Never raises — a broken dedup rule should NOT block
        ingest; it just doesn't produce a flag.

        Strategy lives in [transaction-audit/config/audit.yaml](\
``defaults/skills/finance/transaction-audit/config/audit.yaml``).
        Engine code (this method) only EXECUTES rules; it doesn't
        encode any thresholds or field choices.
        """
        if self._ledger is None:
            return []
        rules = self._load_dedup_rules()
        if not rules:
            return []

        flags: list[str] = []
        for rule in rules:
            try:
                hit = await self._apply_dedup_rule(draft, rule)
                if hit:
                    flags.append(hit)
            except Exception:
                log.exception(
                    "dedup rule %r crashed (skipping)", rule.get("name", "?"),
                )
        return flags

    def _load_dedup_rules(self) -> list[dict]:
        """Read ``dedup_rules`` from the transaction-audit skill YAML.

        Falls through to an empty list when:
          - skill loader is unwired (e.g. tests without a SkillLoader),
          - the skill is missing from the loader,
          - the YAML is missing the ``dedup_rules`` key.

        Conservative: a misconfigured skill produces zero flags rather
        than crash. The engine never invents rules — silence is correct.
        """
        if self._skill_loader is None:
            return []
        try:
            cfg = self._skill_loader.get_skill_config("transaction-audit", "audit")
        except Exception:
            return []
        if not isinstance(cfg, dict):
            return []
        rules = cfg.get("dedup_rules") or []
        return [r for r in rules if isinstance(r, dict)]

    async def _apply_dedup_rule(
        self, draft: TransactionDraft, rule: dict,
    ) -> str | None:
        """Run a single dedup rule. Returns the audit-flag string on a
        hit, ``None`` otherwise.

        Rule schema is documented in [audit.yaml](\
``defaults/skills/finance/transaction-audit/config/audit.yaml``).
        """
        from datetime import date, timedelta

        # Required-non-empty gate: skip when any required field is empty
        # on the draft (would match anything in the ledger otherwise).
        require = rule.get("require_non_empty") or []
        for field_name in require:
            if not str(getattr(draft, field_name, "") or "").strip():
                return None

        match_fields = rule.get("match_fields") or []
        if not match_fields:
            return None

        # Compute the date window for the search. Default 7 days when not
        # specified — conservative middle ground.
        window_days = int(rule.get("window_days", 7))
        try:
            txn_d = date.fromisoformat(draft.txn_date)
            since = (txn_d - timedelta(days=window_days)).isoformat()
            until = (txn_d + timedelta(days=window_days)).isoformat()
        except Exception:
            # Bad txn_date → skip rule (the unreadable_date flag handles it)
            return None

        # Pre-filter the ledger by the strongest indexed field in match_fields
        # to avoid scanning every transaction. merchant_slug + txn_date both
        # have indexes; vendor_tax_id doesn't yet (Phase 2 candidate).
        list_kwargs: dict = {"since": since, "until": until, "limit": 200}
        if "merchant_slug" in match_fields:
            list_kwargs["merchant_slug"] = draft.merchant_slug
        # Status filter — exclude rejected/archived/own draft. Default
        # excludes archived+rejected; we further skip the draft's own row.
        candidates = await self._ledger.list_transactions(**list_kwargs)

        # Amount-match config (within tolerance)
        amount_match = rule.get("amount_match") or ""
        amount_tolerance = rule.get(
            "amount_tolerance",
            self._currency_precision,
        )

        from decimal import Decimal
        for txn in candidates:
            if txn.txn_id == draft.draft_id:
                continue  # don't match against the draft itself
            # Field-by-field exact match for non-empty match_fields
            ok = True
            for field_name in match_fields:
                draft_val = str(getattr(draft, field_name, "") or "").strip()
                txn_val = str(getattr(txn, field_name, "") or "").strip()
                if not draft_val or draft_val != txn_val:
                    ok = False
                    break
            if not ok:
                continue
            # Optional amount tolerance check
            if amount_match:
                try:
                    a = Decimal(getattr(draft, amount_match, "0") or "0")
                    b = Decimal(getattr(txn, amount_match, "0") or "0")
                    if abs(a - b) > Decimal(str(amount_tolerance)):
                        continue
                except Exception:
                    continue
            # Match!
            template = rule.get("flag_template") or "{name}:{existing_id}"
            return template.format(
                name=rule.get("name", "duplicate"),
                existing_id=txn.txn_id,
            )
        return None

    async def _find_existing_by_hash(self, content_hash: str) -> TransactionDraft | None:
        """Look up any prior verified transaction with this content_hash.

        Drafts in pending/ aren't checked here — the user might have a
        partly-extracted draft they're correcting. Only verified ledger
        rows count as duplicates.
        """
        if self._ledger is None or not content_hash:
            return None
        try:
            # list_transactions doesn't filter by hash directly. Iterate the
            # latest few and compare. For Phase 1 this is fine; Phase 2 adds
            # a dedicated hash index lookup.
            recent = await self._ledger.list_transactions(limit=200)
            for txn in recent:
                if txn.content_hash == content_hash:
                    # Convert Transaction → minimal Draft shape for the dedup return
                    return TransactionDraft(
                        draft_id=txn.txn_id,
                        txn_date=txn.txn_date,
                        merchant_slug=txn.merchant_slug,
                        merchant_raw="",
                        currency=txn.currency,
                        total=txn.total,
                        tax=txn.tax,
                        project_slug=txn.project_slug,
                        items=txn.items,
                        payment_method=txn.payment_method,
                        source_file=txn.source_file,
                        content_hash=txn.content_hash,
                        correlation_id=txn.correlation_id,
                        notes="(duplicate of verified transaction)",
                    )
        except Exception:
            log.exception("Dedup lookup failed")
        return None

    async def _persist_draft_to_ledger(self, draft: TransactionDraft) -> None:
        """Write the draft as a status=draft row in the ledger.

        Same idempotent-on-content-hash semantics as ``commit_transaction``.
        Lets the API list pending drafts via the same SQLite query path
        used for the verified ledger — single source for queries.
        """
        if self._ledger is None:
            return
        from agntspace.finance.types import Transaction
        try:
            await self._ledger.commit_transaction(
                Transaction(
                    txn_id=draft.draft_id,
                    txn_date=draft.txn_date,
                    merchant_slug=draft.merchant_slug,
                    currency=draft.currency,
                    total=draft.total,
                    tax=draft.tax,
                    project_slug=draft.project_slug,
                    status=TransactionStatus.DRAFT,
                    items=draft.items,
                    payment_method=draft.payment_method,
                    source_file=draft.source_file,
                    content_hash=draft.content_hash,
                    correlation_id=draft.correlation_id,
                    notes=draft.notes,
                    # Phase 1.5 — preserve all accounting fields through
                    # the draft → ledger boundary. Without this, the
                    # ledger row for a pending draft loses everything the
                    # LLM extracted beyond the v1 fields.
                    receipt_number=draft.receipt_number,
                    document_type=draft.document_type,
                    subtotal=draft.subtotal,
                    vendor_tax_id=draft.vendor_tax_id,
                    vendor_address=draft.vendor_address,
                    due_date=draft.due_date,
                    discount_total=draft.discount_total,
                    discount_description=draft.discount_description,
                    tip=draft.tip,
                    tax_breakdown=draft.tax_breakdown,
                    buyer_tax_id=draft.buyer_tax_id,
                    reverse_charge=draft.reverse_charge,
                )
            )
        except Exception:
            log.exception("Failed to persist draft %s to ledger", draft.draft_id)


__all__ = ["IngestResult", "ReceiptIngest"]
