"""Finance API — Phase 1.

Surfaces the FinanceLedger, MerchantResolver, and ReceiptIngest services
over HTTP. Routes are designed to mirror the agent-tool surface so the
UI and the agent see the same state.

Mutating routes (commit, update, delete, ingest) go through the
ContractMiddleware in production — the contract action map is in
[api/contract_middleware.py](../contract_middleware.py). Read-only routes
are exempt.

Activity middleware classifications live in
[api/activity_middleware.py](../activity_middleware.py).
"""

from __future__ import annotations

import logging
import mimetypes
import shutil
from pathlib import Path

from fastapi import APIRouter, File, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel

from agntspace.finance.storage import (
    archive_rejected_draft,
    read_draft,
    write_transaction,
)
from agntspace.finance.types import TransactionStatus

log = logging.getLogger(__name__)

router = APIRouter(prefix="/finance", tags=["finance"])


# ────────────────────────────────────────────────────────────────────
# Service handles
# ────────────────────────────────────────────────────────────────────
def _ledger(request: Request):
    svc = getattr(request.app.state, "finance_ledger", None)
    if svc is None:
        raise HTTPException(status_code=503, detail="Finance ledger not wired.")
    return svc


def _merchants(request: Request):
    svc = getattr(request.app.state, "merchant_resolver", None)
    if svc is None:
        raise HTTPException(status_code=503, detail="Merchant resolver not wired.")
    return svc


def _ingest(request: Request):
    svc = getattr(request.app.state, "receipt_ingest", None)
    if svc is None:
        raise HTTPException(status_code=503, detail="Receipt ingest not wired.")
    return svc


def _writer(request: Request):
    svc = getattr(request.app.state, "vault_writer", None)
    if svc is None:
        raise HTTPException(status_code=503, detail="Vault writer not wired.")
    return svc


def _reader(request: Request):
    svc = getattr(request.app.state, "vault_reader", None)
    if svc is None:
        raise HTTPException(status_code=503, detail="Vault reader not wired.")
    return svc


# ────────────────────────────────────────────────────────────────────
# Request/response models
# ────────────────────────────────────────────────────────────────────
class IngestRequest(BaseModel):
    path: str = ""  # logical or physical path; required if file upload not used


class UpdateLineItem(BaseModel):
    item_id: str
    category_slug: str | None = None
    project_slug: str | None = None
    tax_deductible: bool | None = None
    notes: str | None = None


class UpdateTransactionRequest(BaseModel):
    project_slug: str | None = None
    notes: str | None = None
    items: list[UpdateLineItem] = []


class RejectRequest(BaseModel):
    reason: str = ""


class MerchantUpsert(BaseModel):
    canonical_name: str
    slug: str = ""
    aliases: list[str] = []
    country: str = ""
    default_category: str = ""
    notes: str = ""


# ────────────────────────────────────────────────────────────────────
# Read paths
# ────────────────────────────────────────────────────────────────────
@router.get("/transactions")
async def list_transactions(
    request: Request,
    since: str = "",
    until: str = "",
    merchant_slug: str = "",
    project_slug: str = "",
    category_slug: str = "",
    currency: str = "",
    status: str = "",
    limit: int = 50,
    offset: int = 0,
) -> dict:
    """List transactions newest-first. Default filter excludes archived + rejected."""
    rows = await _ledger(request).list_transactions(
        since=since,
        until=until,
        merchant_slug=merchant_slug,
        project_slug=project_slug,
        category_slug=category_slug,
        currency=currency,
        status=status,
        limit=limit,
        offset=offset,
    )
    return {
        "count": len(rows),
        "transactions": [_transaction_to_dict(t) for t in rows],
    }


@router.get("/transactions/{txn_id}")
async def get_transaction(request: Request, txn_id: str) -> dict:
    txn = await _ledger(request).get_transaction(txn_id)
    if txn is None:
        raise HTTPException(status_code=404, detail=f"Transaction not found: {txn_id}")
    return _transaction_to_dict(txn, include_items=True)


@router.get("/pending")
async def list_pending(request: Request, limit: int = 100) -> dict:
    """Return drafts awaiting user approval (status='draft')."""
    rows = await _ledger(request).list_transactions(status="draft", limit=limit)
    return {
        "count": len(rows),
        "drafts": [_transaction_to_dict(t, include_items=True) for t in rows],
    }


@router.get("/health")
async def finance_health(request: Request) -> dict:
    h = _ledger(request).health()
    return {
        "ledger": h,
        "merchants_wired": bool(getattr(request.app.state, "merchant_resolver", None)),
        "ingest_wired": bool(getattr(request.app.state, "receipt_ingest", None)),
    }


# ────────────────────────────────────────────────────────────────────
# Photo serving — used by chat UI to render images sent via channels
# (Telegram photo intercept writes ![Photo](/api/finance/photo/<file>)
# into the conversation; the chat's react-markdown loads through this
# endpoint) AND by the finance UI to render the attached receipt next to
# every transaction. Only serves files inside the finance vault subtree;
# anything else is 403.
# ────────────────────────────────────────────────────────────────────
_PHOTO_EXTS = frozenset({".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif", ".pdf"})  # arch:deterministic — capability surface of the photo serving endpoint


def _serve_finance_file(paths, candidate: Path, *, source_dir: Path) -> FileResponse:
    """Serve a verified candidate path under ``source_dir`` with proper headers.

    Caller is responsible for the ``relative_to`` traversal check; we just
    pick the media type and build the response.
    """
    media_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
    return FileResponse(
        path=str(candidate),
        media_type=media_type,
        headers={"Cache-Control": "private, max-age=300"},
    )


@router.get("/photo/{filename}")
async def serve_finance_photo(request: Request, filename: str) -> FileResponse:
    """Serve a receipt image from the finance vault for inline chat rendering.

    Search order: ``finance/inbox/`` then a recursive scan of
    ``finance/library/`` (so files moved to ``library/YYYY-MM/`` after
    ingest still resolve by bare filename — required for the Telegram-bot
    photo links written before the move). Refuses any path containing
    separators or ``..``, and any extension outside the photo set, so the
    endpoint can't be used to exfiltrate arbitrary vault files.
    """
    if "/" in filename or "\\" in filename or ".." in filename:
        raise HTTPException(status_code=403, detail="Access denied")
    ext = Path(filename).suffix.lower()
    if ext not in _PHOTO_EXTS:
        raise HTTPException(status_code=415, detail=f"Unsupported extension: {ext}")
    paths = getattr(request.app.state, "vault_paths", None)
    if paths is None:
        raise HTTPException(status_code=503, detail="Vault paths not wired")

    # Direct hits in the two flat search dirs first.
    for sub in ("finance/inbox", "finance/library"):
        sub_root = paths.resolve(sub)
        candidate = sub_root / filename
        try:
            candidate.resolve().relative_to(sub_root.resolve())
        except (ValueError, OSError):
            continue
        if candidate.exists() and candidate.is_file():
            return _serve_finance_file(paths, candidate, source_dir=sub_root)

    # Then walk library/YYYY-MM/ subdirs. The library is bounded by the
    # number of months the vault has been collecting receipts, so this is
    # cheap even for power users.
    library_root = paths.resolve("finance/library")
    if library_root.exists() and library_root.is_dir():
        for child in library_root.iterdir():
            if not child.is_dir():
                continue
            candidate = child / filename
            try:
                candidate.resolve().relative_to(library_root.resolve())
            except (ValueError, OSError):
                continue
            if candidate.exists() and candidate.is_file():
                return _serve_finance_file(paths, candidate, source_dir=library_root)

    raise HTTPException(status_code=404, detail=f"Photo not found: {filename}")


@router.get("/transactions/{txn_id}/attachment")
async def serve_transaction_attachment(request: Request, txn_id: str) -> FileResponse:
    """Serve the original receipt or invoice attached to a transaction.

    This is the canonical UI surface for "show me the document this
    transaction was extracted from". It looks up the transaction's
    ``source_file`` field via the ledger and serves whatever it points at,
    constrained to ``finance/inbox/`` and ``finance/library/`` to prevent
    arbitrary-file disclosure if the field has been tampered with.

    Returns 404 when the transaction has no attachment (e.g. a manually
    entered transaction, or a draft whose original was deleted before it
    was attached).
    """
    ledger = _ledger(request)
    paths = getattr(request.app.state, "vault_paths", None)
    if paths is None:
        raise HTTPException(status_code=503, detail="Vault paths not wired")

    txn = await ledger.get_transaction(txn_id)
    if txn is None:
        raise HTTPException(status_code=404, detail=f"Transaction not found: {txn_id}")
    source = (txn.source_file or "").replace("\\", "/").strip()
    if not source:
        raise HTTPException(status_code=404, detail="No attachment recorded")
    # Constrain to finance/ subtree
    if not (
        source.startswith("finance/inbox/")
        or source.startswith("finance/library/")
    ):
        raise HTTPException(status_code=403, detail="Attachment outside finance vault")
    ext = Path(source).suffix.lower()
    if ext not in _PHOTO_EXTS:
        raise HTTPException(status_code=415, detail=f"Unsupported extension: {ext}")
    physical = paths.resolve(source)
    finance_root = paths.resolve("finance")
    try:
        physical.resolve().relative_to(finance_root.resolve())
    except (ValueError, OSError) as exc:
        raise HTTPException(status_code=403, detail="Path traversal refused") from exc
    if not physical.exists() or not physical.is_file():
        raise HTTPException(status_code=404, detail="Attachment file missing")
    return _serve_finance_file(paths, physical, source_dir=finance_root)


# ────────────────────────────────────────────────────────────────────
# Merchants
# ────────────────────────────────────────────────────────────────────
@router.get("/merchants")
async def list_merchants(request: Request, limit: int = 100, offset: int = 0) -> dict:
    rows = await _merchants(request).list_merchants(limit=limit, offset=offset)
    return {
        "count": len(rows),
        "merchants": [
            {
                "merchant_slug": m.merchant_slug,
                "canonical_name": m.canonical_name,
                "aliases": list(m.aliases),
                "country": m.country,
                "default_category": m.default_category,
                "last_seen": m.last_seen,
                "notes": m.notes,
            }
            for m in rows
        ],
    }


@router.get("/merchants/{merchant_slug}")
async def get_merchant(request: Request, merchant_slug: str) -> dict:
    m = await _merchants(request).get(merchant_slug)
    if m is None:
        raise HTTPException(status_code=404, detail=f"Merchant not found: {merchant_slug}")
    return {
        "merchant_slug": m.merchant_slug,
        "canonical_name": m.canonical_name,
        "aliases": list(m.aliases),
        "country": m.country,
        "default_category": m.default_category,
        "last_seen": m.last_seen,
        "notes": m.notes,
    }


@router.put("/merchants/{merchant_slug}")
async def upsert_merchant(
    request: Request, merchant_slug: str, body: MerchantUpsert,
) -> dict:
    """Update an existing merchant or create a new one. Idempotent."""
    slug = await _merchants(request).upsert_merchant(
        slug=body.slug or merchant_slug,
        canonical_name=body.canonical_name,
        aliases=body.aliases,
        country=body.country,
        default_category=body.default_category,
        notes=body.notes,
    )
    if not slug:
        raise HTTPException(status_code=400, detail="canonical_name is required")
    return {"ok": True, "merchant_slug": slug}


# ────────────────────────────────────────────────────────────────────
# Ingest (manual trigger)
# ────────────────────────────────────────────────────────────────────
@router.post("/ingest")
async def ingest_existing_file(request: Request, body: IngestRequest) -> dict:
    """Ingest a receipt file already in the vault.

    The path may be logical (``finance/inbox/r1.jpg``) or absolute. The
    file remains in place — this endpoint does not move it. The watcher
    (when enabled in Phase 1.5) will move processed files to ``library/``
    automatically.
    """
    if not body.path:
        raise HTTPException(status_code=400, detail="path is required")
    physical: Path
    paths = getattr(request.app.state, "vault_paths", None)
    if paths is not None:
        physical = paths.resolve(body.path)
    else:
        physical = Path(body.path)
    result = await _ingest(request).ingest(physical, source_logical_path=body.path)
    return result.to_dict()


@router.post("/ingest/upload")
async def ingest_upload(request: Request, file: UploadFile = File(...)) -> dict:
    """Upload a receipt and ingest it in one call.

    The file is saved to ``finance/inbox/`` then handed to the ingest
    engine. Useful for the UI's drag-and-drop UX without going through
    the watcher.
    """
    if not file.filename:
        raise HTTPException(status_code=400, detail="filename is required")
    paths = getattr(request.app.state, "vault_paths", None)
    if paths is None:
        raise HTTPException(status_code=503, detail="Vault paths not wired")
    inbox = paths.resolve("finance/inbox")
    inbox.mkdir(parents=True, exist_ok=True)
    target = inbox / Path(file.filename).name
    try:
        with target.open("wb") as fp:
            shutil.copyfileobj(file.file, fp)
    finally:
        await file.close()
    logical = f"finance/inbox/{target.name}"
    result = await _ingest(request).ingest(target, source_logical_path=logical)
    return result.to_dict()


# ────────────────────────────────────────────────────────────────────
# Approval gate
# ────────────────────────────────────────────────────────────────────
@router.post("/transactions/{txn_id}/approve")
async def approve_draft(request: Request, txn_id: str) -> dict:
    """Promote a draft to verified. Mirrors the memory-approval pattern.

    Side effects: ledger row flipped to ``verified``, canonical markdown
    written to ``transactions/YYYY-MM/``. Phase 2 will additionally
    decrement matching budgets and emit SPO triples.
    """
    ledger = _ledger(request)
    writer = _writer(request)
    txn = await ledger.get_transaction(txn_id)
    if txn is None:
        raise HTTPException(status_code=404, detail=f"Transaction not found: {txn_id}")
    if txn.status not in (TransactionStatus.DRAFT, TransactionStatus.REJECTED):
        raise HTTPException(
            status_code=409,
            detail=f"Cannot approve transaction in status '{txn.status.value}'",
        )
    ok = await ledger.update_transaction_status(txn_id, TransactionStatus.VERIFIED)
    if not ok:
        raise HTTPException(status_code=500, detail="ledger update failed")
    # Re-fetch to get verified_at populated, then materialise markdown
    txn = await ledger.get_transaction(txn_id)
    if txn is not None:
        try:
            write_transaction(writer, txn)
        except Exception:
            log.exception("approve: markdown write failed for %s", txn_id)
    return {"ok": True, "txn_id": txn_id, "status": "verified"}


@router.post("/transactions/{txn_id}/reject")
async def reject_draft(request: Request, txn_id: str, body: RejectRequest) -> dict:
    """Mark a draft rejected. Does NOT consume budget; archived for audit."""
    ledger = _ledger(request)
    reader = _reader(request)
    writer = _writer(request)
    txn = await ledger.get_transaction(txn_id)
    if txn is None:
        raise HTTPException(status_code=404, detail=f"Transaction not found: {txn_id}")
    if txn.status != TransactionStatus.DRAFT:
        raise HTTPException(
            status_code=409,
            detail=f"Only drafts can be rejected (current status: {txn.status.value})",
        )
    ok = await ledger.update_transaction_status(txn_id, TransactionStatus.REJECTED)
    if not ok:
        raise HTTPException(status_code=500, detail="ledger update failed")
    # Mirror the rejection into the rejected/ markdown trail
    draft = read_draft(reader, txn_id)
    if draft is not None:
        try:
            archive_rejected_draft(writer, draft, reason=body.reason)
        except Exception:
            log.exception("reject: rejected/ markdown write failed for %s", txn_id)
    return {"ok": True, "txn_id": txn_id, "status": "rejected"}


# ────────────────────────────────────────────────────────────────────
# Update / delete
# ────────────────────────────────────────────────────────────────────
@router.put("/transactions/{txn_id}")
async def update_transaction(
    request: Request, txn_id: str, body: UpdateTransactionRequest,
) -> dict:
    """Update line-item fields. Transaction-level updates are Phase 1.5."""
    ledger = _ledger(request)
    txn = await ledger.get_transaction(txn_id)
    if txn is None:
        raise HTTPException(status_code=404, detail=f"Transaction not found: {txn_id}")
    items_updated = 0
    for entry in body.items:
        if not entry.item_id:
            continue
        if await ledger.update_line_item(
            entry.item_id,
            category_slug=entry.category_slug,
            project_slug=entry.project_slug,
            tax_deductible=entry.tax_deductible,
            notes=entry.notes,
        ):
            items_updated += 1
    warnings: list[str] = []
    if body.project_slug is not None:
        warnings.append("transaction-level project_slug update is Phase 1.5")
    if body.notes is not None:
        warnings.append("transaction-level notes update is Phase 1.5")
    return {"ok": True, "txn_id": txn_id, "items_updated": items_updated, "warnings": warnings}


@router.delete("/transactions/{txn_id}")
async def delete_transaction(request: Request, txn_id: str) -> dict:
    """Soft-delete (archive). Reversible by re-approving."""
    ok = await _ledger(request).soft_delete_transaction(txn_id)
    if not ok:
        raise HTTPException(status_code=404, detail=f"Transaction not found: {txn_id}")
    return {"ok": True, "txn_id": txn_id, "status": "archived"}


# ────────────────────────────────────────────────────────────────────
# Helpers
# ────────────────────────────────────────────────────────────────────
def _attachment_url(txn) -> str:
    """Build a canonical URL for the transaction's attached receipt.

    Returns ``""`` when the transaction has no usable attachment (manual
    entry, deleted source, unsupported file type). The UI uses presence of
    this URL to decide whether to render a thumbnail.
    """
    source = (getattr(txn, "source_file", "") or "").replace("\\", "/").strip()
    if not source:
        return ""
    if not (
        source.startswith("finance/inbox/")
        or source.startswith("finance/library/")
    ):
        return ""
    ext = Path(source).suffix.lower()
    if ext not in _PHOTO_EXTS:
        return ""
    txn_id = getattr(txn, "txn_id", "")
    if not txn_id:
        return ""
    return f"/api/finance/transactions/{txn_id}/attachment"


def _attachment_kind(txn) -> str:
    """Classify the attachment for the UI: ``image`` | ``pdf`` | ``""``."""
    source = (getattr(txn, "source_file", "") or "").lower()
    if not source:
        return ""
    ext = Path(source).suffix.lower()
    if ext == ".pdf":
        return "pdf"
    if ext in {".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif"}:
        return "image"
    return ""


def _transaction_to_dict(txn, *, include_items: bool = False) -> dict:
    out = {
        "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,
        "attachment_url": _attachment_url(txn),
        "attachment_kind": _attachment_kind(txn),
        "correlation_id": txn.correlation_id,
        "created_at": txn.created_at,
        "verified_at": txn.verified_at,
        "item_count": len(txn.items),
    }
    if include_items:
        out["items"] = [
            {
                "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,
            }
            for item in txn.items
        ]
    return out
