"""Markdown serialization for finance records.

The SQLite ledger ([ledger.py](ledger.py)) is the queryable index; markdown
files under `agntspace-finance/` are the source of truth (Rule #1: vault
content survives the database). This module is the bridge — it serialises
``TransactionDraft`` / ``Transaction`` / ``Merchant`` to/from markdown with
YAML frontmatter, mirroring the wiki article + journal patterns.

Layout:

```
agntspace-finance/
├── pending/{draft_id}.md                  ← TransactionDraft awaiting approval
├── rejected/{draft_id}.md                 ← rejected drafts (audit trail)
├── transactions/YYYY-MM/{txn_id}.md       ← verified Transaction (canonical)
├── merchants/{merchant_slug}.md           ← canonical Merchant page
├── archived/{txn_id}.md                   ← soft-deleted transactions
├── library/YYYY-MM/{draft_id}{ext}        ← original receipt/invoice files,
│                                            attached to the transaction record
│                                            for the lifetime of the row
└── inbox/                                 ← drop-zone (raw receipts, transient)
```

Originals never linger in ``inbox/``: as soon as a draft is created the
receipt file is moved to ``library/YYYY-MM/{draft_id}{ext}`` so the vault
state matches what the user sees in the UI (an attached document, not a
detached image somewhere upstream). Hash-deduplicated re-ingests delete the
duplicate inbox copy because the original is already attached to the
existing draft.

Every write goes through ``VaultWriter`` so the contract runtime guard
(Rule #3 / threat-model §9.2) governs them. Callers must either be inside
a correlation scope with an authorized contract action, or inside a
``writer.trusted_scope("finance_storage")`` block, or a `trust_reason`
gets passed per write.

This module is pure I/O — no LLM, no contract logic. The ingest engine
([ingest.py](ingest.py)) and the FinanceLedger ([ledger.py](ledger.py))
call into here to persist the derived records.
"""

from __future__ import annotations

import logging
import shutil
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING

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

if TYPE_CHECKING:
    from agntspace.vault.paths import VaultPaths
    from agntspace.vault.reader import VaultReader
    from agntspace.vault.writer import VaultWriter

log = logging.getLogger(__name__)


_TRUST_REASON = "finance_storage"


# ────────────────────────────────────────────────────────────────────
# Path helpers — finance-vault layout
# ────────────────────────────────────────────────────────────────────
def draft_path(draft_id: str) -> str:
    return f"finance/pending/{draft_id}.md"


def rejected_draft_path(draft_id: str) -> str:
    return f"finance/rejected/{draft_id}.md"


def transaction_path(txn_id: str, txn_date: str) -> str:
    """Verified transactions live under YYYY-MM/ for filesystem locality."""
    period = (txn_date or datetime.now(UTC).date().isoformat())[:7]  # YYYY-MM
    return f"finance/transactions/{period}/{txn_id}.md"


def archived_transaction_path(txn_id: str) -> str:
    return f"finance/archived/{txn_id}.md"


def merchant_path(merchant_slug: str) -> str:
    return f"finance/merchants/{merchant_slug}.md"


def library_attachment_path(draft_id: str, txn_date: str, suffix: str) -> str:
    """Logical destination for an attached receipt or invoice file.

    The file lives under ``library/YYYY-MM/{draft_id}{ext}`` so an attachment
    is co-located with the transaction's markdown record (which uses the
    same period folder under ``transactions/``). The ``draft_id`` is the
    canonical id throughout the lifecycle — drafts retain their id when
    promoted to verified transactions, so the attachment never has to be
    renamed on approval.

    ``suffix`` is the original file extension including the dot
    (``".jpg"``, ``".pdf"``). Empty when unknown — caller's responsibility
    to pass the real source extension.
    """
    period = (txn_date or datetime.now(UTC).date().isoformat())[:7]
    safe_suffix = (suffix or "").lower()
    return f"finance/library/{period}/{draft_id}{safe_suffix}"


def attach_receipt_to_library(
    paths: VaultPaths,
    *,
    source_logical_path: str,
    source_physical_path: Path | None,
    draft_id: str,
    txn_date: str,
) -> str:
    """Move the original receipt out of the inbox and into ``library/``.

    Returns the new logical path (``finance/library/YYYY-MM/{draft_id}{ext}``).
    If the move fails for any reason (source missing, permission error,
    cross-device rename), the function falls back to a copy + best-effort
    unlink, and on total failure returns the ORIGINAL ``source_logical_path``
    so the draft still records a valid pointer the photo endpoint can find.

    Idempotent: if the destination already exists with the same content
    (e.g. a retry after a partial failure left both files in place), the
    inbox copy is removed and the existing destination is reused. The
    function never raises.
    """
    if not draft_id:
        return source_logical_path

    src_logical = (source_logical_path or "").replace("\\", "/")
    if not src_logical:
        return source_logical_path

    # Resolve the source physical path — caller may have provided one
    # already (the ingest engine has the file path on hand). Otherwise
    # derive it from the logical path.
    src_physical: Path | None = source_physical_path
    if src_physical is None:
        try:
            src_physical = paths.resolve(src_logical)
        except Exception:  # pragma: no cover — defensive
            log.exception("attach_receipt: cannot resolve source %r", src_logical)
            return source_logical_path

    if not src_physical.exists() or not src_physical.is_file():
        # File already moved (perhaps by a previous attempt) or never landed.
        # If the logical path is already inside library/, treat as success.
        if src_logical.startswith("finance/library/"):
            return src_logical
        log.debug("attach_receipt: source missing %s — leaving pointer as-is", src_physical)
        return source_logical_path

    suffix = src_physical.suffix
    dest_logical = library_attachment_path(draft_id, txn_date, suffix)
    dest_physical = paths.resolve(dest_logical)

    # If the source already lives at the destination, nothing to do.
    try:
        if src_physical.resolve() == dest_physical.resolve():
            return dest_logical
    except OSError:
        # resolve() can fail on broken symlinks / permission errors;
        # fall through to the actual move attempt below.
        pass

    try:
        dest_physical.parent.mkdir(parents=True, exist_ok=True)
    except OSError:
        log.exception("attach_receipt: cannot create %s", dest_physical.parent)
        return source_logical_path

    # Idempotency: if dest already exists, prefer the existing copy and
    # remove the inbox duplicate. Receipts are content-hash deduped by the
    # ingest engine, so collisions on (draft_id, suffix) are essentially
    # impossible — but if one happens, keeping the older file matches the
    # "first sight wins" semantics of dedup.
    if dest_physical.exists():
        try:
            src_physical.unlink()
        except OSError:
            log.debug("attach_receipt: could not remove duplicate inbox copy %s", src_physical)
        return dest_logical

    try:
        shutil.move(str(src_physical), str(dest_physical))
        return dest_logical
    except (OSError, shutil.Error):
        log.exception("attach_receipt: move %s -> %s failed; trying copy+unlink",
                      src_physical, dest_physical)

    # Fallback: copy then best-effort unlink.
    try:
        shutil.copy2(str(src_physical), str(dest_physical))
    except OSError:
        log.exception("attach_receipt: copy %s -> %s failed",
                      src_physical, dest_physical)
        return source_logical_path
    try:
        src_physical.unlink()
    except OSError:
        log.debug("attach_receipt: copied to library but could not remove %s", src_physical)
    return dest_logical


def remove_inbox_duplicate(
    paths: VaultPaths,
    *,
    source_logical_path: str,
) -> bool:
    """Best-effort delete an inbox-resident file after content-hash dedup.

    Used by the ingest engine when a re-uploaded receipt matches an
    existing transaction by ``content_hash``. The original is already
    attached to the prior draft/transaction in ``library/``; the duplicate
    in ``inbox/`` would otherwise sit there forever.

    Refuses to act on paths outside ``finance/inbox/`` — paranoia against
    bad logical paths flowing in from a buggy caller. Never raises.
    """
    src_logical = (source_logical_path or "").replace("\\", "/")
    if not src_logical.startswith("finance/inbox/"):
        return False
    try:
        physical = paths.resolve(src_logical)
    except Exception:  # pragma: no cover — defensive
        log.exception("remove_inbox_duplicate: cannot resolve %r", src_logical)
        return False
    if not physical.exists():
        return False
    try:
        physical.unlink()
        return True
    except OSError:
        log.debug("remove_inbox_duplicate: could not delete %s", physical)
        return False


# ────────────────────────────────────────────────────────────────────
# YAML helpers — minimal, no PyYAML round-trip surprises
# ────────────────────────────────────────────────────────────────────
def _yaml_quote(value: str) -> str:
    """Quote a string for YAML frontmatter. Conservative: always quote.

    Receipts contain ``:``, ``#``, ``-``, leading digits — every YAML
    special. Quoting unconditionally side-steps the entire question.
    Backslash escapes inside the string; double quotes escape themselves.
    """
    if value is None:
        return '""'
    text = str(value).replace("\\", "\\\\").replace('"', '\\"')
    return f'"{text}"'


def _format_frontmatter(data: dict) -> str:
    """Format a dict as YAML frontmatter.

    Handles primitives + lists + nested dicts up to the depth needed for
    line items. Avoids PyYAML to keep zero-dep parity with the rest of
    the finance package — these documents have a known small schema.
    """
    lines: list[str] = []

    def _emit(key: str, value, indent: int) -> None:
        prefix = " " * indent
        if value is None or value == "":
            lines.append(f"{prefix}{key}: \"\"")
            return
        if isinstance(value, bool):
            lines.append(f"{prefix}{key}: {'true' if value else 'false'}")
            return
        if isinstance(value, (int, float)):
            lines.append(f"{prefix}{key}: {value}")
            return
        if isinstance(value, str):
            lines.append(f"{prefix}{key}: {_yaml_quote(value)}")
            return
        if isinstance(value, list):
            if not value:
                lines.append(f"{prefix}{key}: []")
                return
            lines.append(f"{prefix}{key}:")
            for entry in value:
                if isinstance(entry, dict):
                    first = True
                    for sub_k, sub_v in entry.items():
                        if first:
                            lines.append(f"{prefix}  - {sub_k}: {_yaml_format_scalar(sub_v)}")
                            first = False
                        else:
                            _emit(sub_k, sub_v, indent + 4)
                else:
                    lines.append(f"{prefix}  - {_yaml_format_scalar(entry)}")
            return
        if isinstance(value, dict):
            lines.append(f"{prefix}{key}:")
            for sub_k, sub_v in value.items():
                _emit(sub_k, sub_v, indent + 2)
            return
        # Fallback: stringify
        lines.append(f"{prefix}{key}: {_yaml_quote(str(value))}")

    for k, v in data.items():
        _emit(k, v, 0)
    return "\n".join(lines)


def _yaml_format_scalar(value) -> str:
    if value is None:
        return '""'
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        return str(value)
    return _yaml_quote(str(value))


def _build_document(metadata: dict, body: str) -> str:
    """Assemble frontmatter + body into a single markdown document."""
    fm = _format_frontmatter(metadata)
    return f"---\n{fm}\n---\n\n{body.rstrip()}\n"


# ────────────────────────────────────────────────────────────────────
# TransactionDraft serialization
# ────────────────────────────────────────────────────────────────────
def write_draft(writer: VaultWriter, draft: TransactionDraft) -> str:
    """Persist a draft to ``pending/`` and return the logical path written."""
    path = draft_path(draft.draft_id)
    metadata = _draft_to_frontmatter(draft)
    body = _draft_body(draft)
    writer.write(path, _build_document(metadata, body), versioned=False, trust_reason=_TRUST_REASON)
    return path


def read_draft(reader: VaultReader, draft_id: str) -> TransactionDraft | None:
    """Load a draft by ID, or None if not found."""
    try:
        doc = reader.read(draft_path(draft_id))
    except FileNotFoundError:
        return None
    if doc is None or not doc.metadata:
        return None
    return _frontmatter_to_draft(doc.metadata)


def list_drafts(reader: VaultReader) -> list[str]:
    """Return draft IDs currently pending. Newest-first by mtime if available."""
    paths = reader.list_files("finance/pending") if hasattr(reader, "list_files") else []
    return sorted(
        [p.rsplit("/", 1)[-1].removesuffix(".md") for p in paths if p.endswith(".md")],
    )


def archive_rejected_draft(
    writer: VaultWriter,
    draft: TransactionDraft,
    reason: str = "",
) -> str:
    """Move a draft from ``pending/`` to ``rejected/`` with the user's reason.

    The original ``pending/`` file is left in place by this function — the
    caller (typically the API approve/reject route) owns the deletion or
    moves to a separate archive path. Keeping it idempotent here means the
    ledger state and the markdown state can be reconciled even if the API
    call is retried.
    """
    metadata = _draft_to_frontmatter(draft)
    metadata["rejected_reason"] = reason
    metadata["rejected_at"] = datetime.now(UTC).isoformat(timespec="seconds")
    body = _draft_body(draft, rejected_reason=reason)
    path = rejected_draft_path(draft.draft_id)
    writer.write(path, _build_document(metadata, body), versioned=False, trust_reason=_TRUST_REASON)
    return path


def _draft_to_frontmatter(draft: TransactionDraft) -> dict:
    return {
        "draft_id": draft.draft_id,
        "txn_date": draft.txn_date,
        "merchant_slug": draft.merchant_slug,
        "merchant_raw": draft.merchant_raw,
        "currency": draft.currency,
        "total": draft.total,
        "tax": draft.tax,
        "project_slug": draft.project_slug,
        "payment_method": draft.payment_method,
        "source_file": draft.source_file,
        "content_hash": draft.content_hash,
        "correlation_id": draft.correlation_id,
        "audit_flags": list(draft.audit_flags),
        "extracted_at": draft.extracted_at,
        "items": [_item_to_dict(item) for item in draft.items],
        "notes": draft.notes,
        # Phase 1.5 accounting 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,
        # Phase 1.5 — VAT
        "tax_breakdown": [_taxline_to_dict(tl) for tl in draft.tax_breakdown],
        "buyer_tax_id": draft.buyer_tax_id,
        "reverse_charge": draft.reverse_charge,
        "kind": "transaction_draft",
    }


def _draft_body(draft: TransactionDraft, *, rejected_reason: str = "") -> str:
    lines = [f"# Pending transaction — {draft.merchant_raw or draft.merchant_slug or 'unknown'} — {draft.txn_date}"]
    lines.append("")
    lines.append("Drafted by bookkeeper. Awaiting your review.")
    if draft.audit_flags:
        lines.append("")
        lines.append("## Audit flags")
        for flag in draft.audit_flags:
            lines.append(f"- {flag}")
    if rejected_reason:
        lines.append("")
        lines.append("## Rejection reason")
        lines.append(rejected_reason)
    return "\n".join(lines)


def _frontmatter_to_draft(meta: dict) -> TransactionDraft:
    return TransactionDraft(
        draft_id=str(meta.get("draft_id", "")),
        txn_date=str(meta.get("txn_date", "")),
        merchant_slug=str(meta.get("merchant_slug", "")),
        merchant_raw=str(meta.get("merchant_raw", "")),
        currency=str(meta.get("currency", "")),
        total=str(meta.get("total", "0.00")),
        tax=str(meta.get("tax", "0.00")),
        project_slug=str(meta.get("project_slug", "")),
        items=tuple(_dict_to_item(d) for d in (meta.get("items") or [])),
        payment_method=str(meta.get("payment_method", "")),
        source_file=str(meta.get("source_file", "")),
        content_hash=str(meta.get("content_hash", "")),
        correlation_id=str(meta.get("correlation_id", "")),
        notes=str(meta.get("notes", "")),
        audit_flags=tuple(meta.get("audit_flags") or ()),
        extracted_at=str(meta.get("extracted_at", "")),
        receipt_number=str(meta.get("receipt_number", "")),
        document_type=str(meta.get("document_type", "")),
        subtotal=str(meta.get("subtotal", "")),
        vendor_tax_id=str(meta.get("vendor_tax_id", "")),
        vendor_address=str(meta.get("vendor_address", "")),
        due_date=str(meta.get("due_date", "")),
        discount_total=str(meta.get("discount_total", "")),
        discount_description=str(meta.get("discount_description", "")),
        tip=str(meta.get("tip", "")),
        tax_breakdown=tuple(
            _dict_to_taxline(d) for d in (meta.get("tax_breakdown") or [])
        ),
        buyer_tax_id=str(meta.get("buyer_tax_id", "")),
        reverse_charge=bool(meta.get("reverse_charge", False)),
    )


# ────────────────────────────────────────────────────────────────────
# Transaction (verified) serialization
# ────────────────────────────────────────────────────────────────────
def write_transaction(writer: VaultWriter, txn: Transaction) -> str:
    """Persist a verified transaction. Path includes YYYY-MM for locality."""
    path = transaction_path(txn.txn_id, txn.txn_date)
    metadata = _txn_to_frontmatter(txn)
    body = _txn_body(txn)
    writer.write(path, _build_document(metadata, body), versioned=True, trust_reason=_TRUST_REASON)
    return path


def read_transaction(
    reader: VaultReader,
    txn_id: str,
    txn_date: str = "",
) -> Transaction | None:
    """Load a transaction by id. Provide ``txn_date`` (or any YYYY-MM-DD) if
    known; we use it to locate the file directly. Otherwise we scan recent
    months — slow on huge ledgers, fine for personal use."""
    if txn_date:
        try:
            doc = reader.read(transaction_path(txn_id, txn_date))
        except FileNotFoundError:
            doc = None
        if doc and doc.metadata:
            return _frontmatter_to_txn(doc.metadata)
    # Fall back to scanning if reader supports listing
    if hasattr(reader, "list_files"):
        for p in reader.list_files("finance/transactions"):
            if p.endswith(f"/{txn_id}.md"):
                try:
                    doc = reader.read(p)
                except FileNotFoundError:
                    continue
                if doc and doc.metadata:
                    return _frontmatter_to_txn(doc.metadata)
    return None


def _txn_to_frontmatter(txn: Transaction) -> dict:
    return {
        "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,
        "items": [_item_to_dict(item) for item in txn.items],
        "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,
        # Phase 1.5 — VAT
        "tax_breakdown": [_taxline_to_dict(tl) for tl in txn.tax_breakdown],
        "buyer_tax_id": txn.buyer_tax_id,
        "reverse_charge": txn.reverse_charge,
        "kind": "transaction",
    }


def _txn_body(txn: Transaction) -> str:
    parts = [
        f"# {txn.merchant_slug or 'Transaction'} — {txn.txn_date} — {txn.total} {txn.currency}",
        "",
        f"Verified transaction. Status: **{txn.status.value}**.",
    ]
    if txn.items:
        parts.append("")
        parts.append("## Line items")
        for item in txn.items:
            descr = item.description or "(no description)"
            cat = item.category_slug or "uncategorised"
            proj = f", project: {item.project_slug}" if item.project_slug else ""
            parts.append(f"- {descr} — {item.amount} {txn.currency} ({cat}{proj})")
    return "\n".join(parts)


def _frontmatter_to_txn(meta: dict) -> Transaction:
    try:
        status = TransactionStatus(str(meta.get("status", "verified")))
    except ValueError:
        status = TransactionStatus.VERIFIED
    return Transaction(
        txn_id=str(meta.get("txn_id", "")),
        txn_date=str(meta.get("txn_date", "")),
        merchant_slug=str(meta.get("merchant_slug", "")),
        currency=str(meta.get("currency", "")),
        total=str(meta.get("total", "0.00")),
        tax=str(meta.get("tax", "0.00")),
        project_slug=str(meta.get("project_slug", "")),
        status=status,
        items=tuple(_dict_to_item(d) for d in (meta.get("items") or [])),
        payment_method=str(meta.get("payment_method", "")),
        source_file=str(meta.get("source_file", "")),
        content_hash=str(meta.get("content_hash", "")),
        correlation_id=str(meta.get("correlation_id", "")),
        notes=str(meta.get("notes", "")),
        created_at=str(meta.get("created_at", "")),
        verified_at=str(meta.get("verified_at", "")),
        receipt_number=str(meta.get("receipt_number", "")),
        document_type=str(meta.get("document_type", "")),
        subtotal=str(meta.get("subtotal", "")),
        vendor_tax_id=str(meta.get("vendor_tax_id", "")),
        vendor_address=str(meta.get("vendor_address", "")),
        due_date=str(meta.get("due_date", "")),
        discount_total=str(meta.get("discount_total", "")),
        discount_description=str(meta.get("discount_description", "")),
        tip=str(meta.get("tip", "")),
        tax_breakdown=tuple(
            _dict_to_taxline(d) for d in (meta.get("tax_breakdown") or [])
        ),
        buyer_tax_id=str(meta.get("buyer_tax_id", "")),
        reverse_charge=bool(meta.get("reverse_charge", False)),
    )


# ────────────────────────────────────────────────────────────────────
# Merchant page serialization
# ────────────────────────────────────────────────────────────────────
def write_merchant(writer: VaultWriter, merchant: Merchant) -> str:
    path = merchant_path(merchant.merchant_slug)
    metadata = {
        "merchant_slug": merchant.merchant_slug,
        "canonical_name": merchant.canonical_name,
        "country": merchant.country,
        "default_category": merchant.default_category,
        "last_seen": merchant.last_seen,
        "aliases": list(merchant.aliases),
        "notes": merchant.notes,
        "kind": "merchant",
    }
    body_lines = [
        f"# {merchant.canonical_name}",
        "",
        f"Default category: **{merchant.default_category or 'unset'}**.",
    ]
    if merchant.aliases:
        body_lines.append("")
        body_lines.append("## Aliases")
        for alias in merchant.aliases:
            body_lines.append(f"- {alias}")
    body = "\n".join(body_lines)
    writer.write(path, _build_document(metadata, body), versioned=False, trust_reason=_TRUST_REASON)
    return path


def read_merchant(reader: VaultReader, merchant_slug: str) -> Merchant | None:
    try:
        doc = reader.read(merchant_path(merchant_slug))
    except FileNotFoundError:
        return None
    if doc is None or not doc.metadata:
        return None
    meta = doc.metadata
    return Merchant(
        merchant_slug=str(meta.get("merchant_slug", "")),
        canonical_name=str(meta.get("canonical_name", "")),
        aliases=tuple(meta.get("aliases") or ()),
        country=str(meta.get("country", "")),
        default_category=str(meta.get("default_category", "")),
        notes=str(meta.get("notes", "")),
        last_seen=str(meta.get("last_seen", "")),
    )


# ────────────────────────────────────────────────────────────────────
# LineItem helpers
# ────────────────────────────────────────────────────────────────────
def _item_to_dict(item: LineItem) -> dict:
    return {
        "item_id": item.item_id,
        "description": item.description,
        "quantity": item.quantity,
        "unit_price": item.unit_price,
        "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,
    }


def _dict_to_item(d: dict) -> LineItem:
    return LineItem(
        item_id=str(d.get("item_id", "")),
        description=str(d.get("description", "")),
        quantity=str(d.get("quantity", "")),
        unit_price=str(d.get("unit_price", "")),
        amount=str(d.get("amount", "0.00")),
        category_slug=str(d.get("category_slug", "")),
        project_slug=str(d.get("project_slug", "")),
        tax_deductible=bool(d.get("tax_deductible", False)),
        notes=str(d.get("notes", "")),
        item_code=str(d.get("item_code", "")),
        discount=str(d.get("discount", "")),
        tax_rate=str(d.get("tax_rate", "")),
    )


def _taxline_to_dict(tl: TaxLine) -> dict:
    return {
        "rate": tl.rate,
        "amount": tl.amount,
        "taxable_base": tl.taxable_base,
    }


def _dict_to_taxline(d: dict) -> TaxLine:
    if not isinstance(d, dict):
        return TaxLine(rate="", amount="", taxable_base="")
    return TaxLine(
        rate=str(d.get("rate", "")),
        amount=str(d.get("amount", "")),
        taxable_base=str(d.get("taxable_base", "")),
    )


__all__ = [
    "draft_path",
    "rejected_draft_path",
    "transaction_path",
    "archived_transaction_path",
    "merchant_path",
    "write_draft",
    "read_draft",
    "list_drafts",
    "archive_rejected_draft",
    "write_transaction",
    "read_transaction",
    "write_merchant",
    "read_merchant",
]
