"""Pipeline API — read-only view of file ingestion status.

Three endpoints, all GET, all read-only:

  GET /pipeline/summary  → counts + small samples for the dashboard card
  GET /pipeline/board    → full Kanban payload for the /pipeline page
  GET /pipeline/recent   → newest activity events (for timelines / debugging)

No new contract action required: GETs are exempt from the activity middleware
classification gate, and these endpoints don't mutate state. The user can act
on what they see via existing routes (/watcher/scan, /watcher/quarantine/restore,
/work-queue/retry).
"""

from __future__ import annotations

import logging
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

from agntspace.automation.pipeline_status import (
    build_full_board,
    build_summary,
    list_recent_events,
)

log = logging.getLogger(__name__)

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


def _vault_paths(request: Request):
    vault = getattr(request.app.state, "vault", None)
    if vault is None or getattr(vault, "paths", None) is None:
        return None
    return vault.paths


def _resolve_inside(base: Path, filename: str) -> Path:
    """Resolve `base / filename` and refuse paths that escape `base`.

    Defends against `filename = "../../etc/passwd"` style traversal. Mirror
    this guard in every endpoint that accepts a user-supplied filename.
    """
    if not filename or "/" in filename or "\\" in filename or ".." in filename:
        raise HTTPException(status_code=400, detail="Invalid filename")
    candidate = (base / filename).resolve()
    base_resolved = base.resolve()
    try:
        candidate.relative_to(base_resolved)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail="Path escape rejected") from exc
    return candidate


def _emit_pipeline_event(
    request: Request,
    *,
    action: str,
    summary: str,
    details: dict,
    importance: str = "medium",
) -> None:
    """Emit a pipeline_* activity event so the agent sees user file management.

    Rule #13: every manual user action surfaces as a structured event. Pipeline
    routes are user-initiated UI actions, so they get classified by the
    activity middleware too — but the middleware's generic event lacks the
    file/kb context, so we emit a richer one inline. Best-effort: a missing
    or broken activity logger never breaks the request.
    """
    try:
        activity = getattr(request.app.state, "activity_logger", None)
        if activity is None:
            return
        activity.log(
            category="user",
            action=action,
            summary=summary,
            details=details,
            importance=importance,
        )
    except Exception:
        log.debug("activity emit failed for pipeline.%s", action, exc_info=True)


@router.get("/summary")
async def pipeline_summary(request: Request) -> dict:
    """Top-level counts + small samples for the dashboard PipelineCard."""
    paths = _vault_paths(request)
    if paths is None:
        return {
            "available": False,
            "reason": "vault_not_configured",
            "queued_count": 0,
            "inflight_count": 0,
            "done_today_count": 0,
            "skipped_today_count": 0,
            "failed_count": 0,
            "queued_sample": [],
            "inflight_sample": [],
            "done_sample": [],
            "skipped_sample": [],
            "failed_sample": [],
            "watcher": {"running": False, "paused": False},
        }

    activity = getattr(request.app.state, "activity_logger", None)
    work_queue = getattr(request.app.state, "work_queue", None)
    watcher = getattr(request.app.state, "raw_watcher", None)
    kb_service = getattr(request.app.state, "kb_service", None)

    summary = await build_summary(
        paths=paths,
        activity=activity,
        work_queue=work_queue,
        watcher=watcher,
        kb_service=kb_service,
    )
    summary["available"] = True
    return summary


@router.get("/board")
async def pipeline_board(request: Request, limit: int = 100) -> dict:
    """Full Kanban payload — every queued file, every inflight job, today's log."""
    paths = _vault_paths(request)
    if paths is None:
        return {
            "available": False,
            "reason": "vault_not_configured",
            "queued": [],
            "inflight": [],
            "done": [],
            "skipped": [],
            "crashed": [],
            "failed": [],
            "counts": {
                "queued": 0,
                "inflight": 0,
                "done": 0,
                "skipped": 0,
                "crashed": 0,
                "failed": 0,
            },
        }

    activity = getattr(request.app.state, "activity_logger", None)
    work_queue = getattr(request.app.state, "work_queue", None)
    kb_service = getattr(request.app.state, "kb_service", None)

    # Cap at a sane upper bound — UI never asks for more than a few hundred.
    bounded = max(1, min(limit, 500))
    board = await build_full_board(
        paths=paths,
        activity=activity,
        work_queue=work_queue,
        limit_per_bucket=bounded,
        kb_service=kb_service,
    )
    board["available"] = True
    return board


@router.get("/recent")
async def pipeline_recent(
    request: Request,
    limit: int = 50,
    since: str = "",
) -> dict:
    """Recent ingest activity events split into done / skipped / crashed.

    Used by polling clients that want a delta since their last fetch — pass
    `since` (ISO timestamp from the previous response's max event timestamp)
    to avoid re-pulling events the client already has.
    """
    activity = getattr(request.app.state, "activity_logger", None)
    bounded = max(1, min(limit, 200))
    events = list_recent_events(activity, limit=bounded, since_iso=since or None)
    return events


# ── Per-file management ─────────────────────────────────────────────────
# These mirror the watcher's quarantine routes (restore/delete) but cover
# the buckets the watcher doesn't own: drop-zone inbox + library/unsupported/.
# Treated as user-initiated UI actions — same exemption pattern as
# /api/wiki/article DELETE and /api/watcher/quarantine/*.


class InboxFileAction(BaseModel):
    """Identifies a file in either the global inbox or a per-KB inbox."""

    kb: str = ""  # empty => global inbox
    filename: str


class UnsupportedFileAction(BaseModel):
    """Identifies a file in a per-KB library/unsupported/ directory."""

    kb: str
    filename: str


def _resolve_inbox_dir(paths, kb: str) -> Path:
    """Pick the right inbox dir — global when kb is empty, per-KB otherwise."""
    if kb:
        # Validate kb segment so we can't traverse into another path.
        if "/" in kb or "\\" in kb or ".." in kb or not kb:
            raise HTTPException(status_code=400, detail="Invalid KB slug")
        return paths.resolve(f"knowledge/{kb}/inbox")
    return paths.resolve("inbox")


@router.delete("/inbox")
async def delete_inbox_file(request: Request, body: InboxFileAction) -> dict:
    """Remove a file from the global or per-KB inbox.

    Used when the user dropped a file by mistake or wants to abort an item
    before the watcher picks it up. Quarantine + library copies are NOT
    touched — only the staging copy in inbox/.
    """
    paths = _vault_paths(request)
    if paths is None:
        raise HTTPException(status_code=503, detail="Vault not available")

    inbox = _resolve_inbox_dir(paths, body.kb)
    target = _resolve_inside(inbox, body.filename)
    if not target.is_file():
        raise HTTPException(status_code=404, detail=f"File not in inbox: {body.filename}")

    try:
        size = target.stat().st_size
        target.unlink()
    except OSError as exc:
        raise HTTPException(status_code=500, detail=f"Delete failed: {exc}") from exc

    _emit_pipeline_event(
        request,
        action="pipeline_inbox_deleted",
        summary=f"Deleted from inbox: {body.filename}",
        details={"kb": body.kb or "global", "filename": body.filename, "size": size},
    )
    return {"status": "deleted", "kb": body.kb, "filename": body.filename}


@router.post("/unsupported/restore")
async def restore_unsupported_file(request: Request, body: UnsupportedFileAction) -> dict:
    """Move a file out of library/unsupported/ back into the KB inbox for retry.

    Useful when the user adds a plugin that handles the file type, or when
    they want to convert + re-ingest manually. The destination inbox slot
    must be free — we never silently overwrite an active inbox item.
    """
    paths = _vault_paths(request)
    if paths is None:
        raise HTTPException(status_code=503, detail="Vault not available")

    unsupported = paths.resolve(f"knowledge/{body.kb}/library/unsupported")
    target = _resolve_inside(unsupported, body.filename)
    if not target.is_file():
        raise HTTPException(status_code=404, detail=f"Not in unsupported: {body.filename}")

    inbox = paths.resolve(f"knowledge/{body.kb}/inbox")
    try:
        inbox.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        raise HTTPException(status_code=500, detail=f"Inbox create failed: {exc}") from exc

    dest = _resolve_inside(inbox, body.filename)
    if dest.exists():
        raise HTTPException(status_code=409, detail=f"File already in inbox: {body.filename}")

    try:
        target.rename(dest)
    except OSError as exc:
        raise HTTPException(status_code=500, detail=f"Restore failed: {exc}") from exc

    # Also remove the .reason.txt sidecar if present — it's tied to the parked file.
    sidecar = unsupported / (body.filename + ".reason.txt")
    if sidecar.is_file():
        try:
            sidecar.unlink()
        except OSError:
            log.debug("sidecar cleanup failed: %s", sidecar)

    _emit_pipeline_event(
        request,
        action="pipeline_unsupported_restored",
        summary=f"Restored from unsupported: {body.filename}",
        details={"kb": body.kb, "filename": body.filename},
    )
    return {"status": "restored", "kb": body.kb, "filename": body.filename}


@router.delete("/unsupported")
async def delete_unsupported_file(request: Request, body: UnsupportedFileAction) -> dict:
    """Permanently delete a file from library/unsupported/ + its reason sidecar."""
    paths = _vault_paths(request)
    if paths is None:
        raise HTTPException(status_code=503, detail="Vault not available")

    unsupported = paths.resolve(f"knowledge/{body.kb}/library/unsupported")
    target = _resolve_inside(unsupported, body.filename)
    if not target.is_file():
        raise HTTPException(status_code=404, detail=f"Not in unsupported: {body.filename}")

    try:
        size = target.stat().st_size
        target.unlink()
    except OSError as exc:
        raise HTTPException(status_code=500, detail=f"Delete failed: {exc}") from exc

    sidecar = unsupported / (body.filename + ".reason.txt")
    if sidecar.is_file():
        try:
            sidecar.unlink()
        except OSError:
            log.debug("sidecar cleanup failed: %s", sidecar)

    _emit_pipeline_event(
        request,
        action="pipeline_unsupported_deleted",
        summary=f"Deleted from unsupported: {body.filename}",
        details={"kb": body.kb, "filename": body.filename, "size": size},
        importance="medium",
    )
    return {"status": "deleted", "kb": body.kb, "filename": body.filename}


# ── Per-file ingest-now (used by drag-drop + Queued "Ingest now" button) ──


def _file_in_library(paths, kb: str, filename: str) -> str:
    """Return the relative path under library/ if `filename` is there.

    Walks the KB's library tree (capped, breadth-first) looking for an
    exact filename match. Used by ingest-now to distinguish a real "file
    is missing" 404 from a benign race where another path already
    processed the file. Empty string when not found.
    """
    if not kb:
        return ""
    try:
        library = paths.resolve(f"knowledge/{kb}/library")
    except Exception:
        return ""
    if not library.is_dir():
        return ""
    # Cap walk so a huge library doesn't burn time. 500 entries is plenty —
    # if the file isn't in the first 500 we treat it as a real miss.
    try:
        for seen, f in enumerate(library.rglob(filename)):
            if f.is_file() and f.name == filename:
                # Return path relative to vault root for the UI to display
                try:
                    return str(f.relative_to(paths.root_dir)).replace("\\", "/")
                except Exception:
                    return str(f).replace("\\", "/")
            if seen > 500:
                break
    except OSError:
        return ""
    return ""


def _pick_default_kb(paths) -> str:
    """Pick a target KB for global-inbox files when the user doesn't specify one.

    Mirrors the watcher's auto-routing rule: prefer ``general`` if it exists,
    else the alphabetically-first KB. Returns "" when no KB exists.
    """
    kb_root = paths.resolve("knowledge")
    if not kb_root.is_dir():
        return ""
    try:
        kbs = sorted(p.name for p in kb_root.iterdir() if p.is_dir())
    except OSError:
        return ""
    if not kbs:
        return ""
    if "general" in kbs:
        return "general"
    return kbs[0]


@router.post("/queued/ingest-now")
async def ingest_now(request: Request, body: InboxFileAction) -> dict:
    """Trigger ingest for one queued file immediately.

    Powers the Queued column's "Ingest now" button AND the drag-drop
    interaction (drag a Queued card onto the In Flight column). Bypasses
    the watcher's pause flag — the user explicitly asked for processing.

    For per-KB inbox files: calls ``kb_service.ingest(kb)`` directly. The
    KB's whole inbox is processed (not just this one file) — `kb.ingest`
    isn't per-file scoped — but the dragged file is guaranteed to be in
    that batch. The toast reflects total counts.

    For global inbox files: routes the file into a target KB (``general``
    if it exists, else the first KB alphabetically) by moving it into
    that KB's inbox, then calls ``kb_service.ingest(target_kb)``. Mirrors
    the watcher's auto-routing rule.
    """
    paths = _vault_paths(request)
    if paths is None:
        raise HTTPException(status_code=503, detail="Vault not available")

    kb_service = getattr(request.app.state, "kb_service", None)
    if kb_service is None:
        raise HTTPException(status_code=503, detail="KB service not available")

    target_kb = body.kb

    if not target_kb:
        # Global inbox — verify file, route to a target KB
        global_inbox = paths.resolve("inbox")
        target = _resolve_inside(global_inbox, body.filename)
        if not target.is_file():
            # Race-friendly: file disappeared from inbox between drag-start
            # and drop. Most common cause: work queue or watcher cycle
            # processed it in parallel. Check every KB's library/ — if it's
            # already there, return a clear "already processed" success
            # instead of a confusing 404 that the user can't action on.
            target_kb_guess = _pick_default_kb(paths) or ""
            lib_path = _file_in_library(paths, target_kb_guess, body.filename)
            if lib_path:
                _emit_pipeline_event(
                    request,
                    action="pipeline_force_ingest_already_processed",
                    summary=f"Drag of {body.filename} — already processed in {target_kb_guess}",
                    details={"kb": target_kb_guess, "filename": body.filename, "library_path": lib_path},
                    importance="low",
                )
                return {
                    "status": "already_processed",
                    "kb": target_kb_guess,
                    "target_filename": body.filename,
                    "target_status": "already_processed",
                    "target_message": (
                        f"{body.filename} was already processed by a parallel path "
                        f"(work queue or watcher) before your drag landed. It's in {lib_path}."
                    ),
                    "library_path": lib_path,
                }
            raise HTTPException(status_code=404, detail=f"Not in global inbox: {body.filename}")

        target_kb = _pick_default_kb(paths)
        if not target_kb:
            raise HTTPException(
                status_code=409,
                detail="No KB exists yet. Create one first or drop the file directly into a KB inbox.",
            )

        # Move file into the chosen KB's inbox
        kb_inbox = paths.resolve(f"knowledge/{target_kb}/inbox")
        try:
            kb_inbox.mkdir(parents=True, exist_ok=True)
        except OSError as exc:
            raise HTTPException(status_code=500, detail=f"Inbox create failed: {exc}") from exc
        dest = _resolve_inside(kb_inbox, body.filename)
        if dest.exists():
            raise HTTPException(
                status_code=409,
                detail=f"File already exists in {target_kb}/inbox/ — pick a different KB or rename first.",
            )
        try:
            target.rename(dest)
        except OSError as exc:
            raise HTTPException(status_code=500, detail=f"Route to KB failed: {exc}") from exc
    else:
        # Per-KB inbox — verify the file is actually there before triggering
        if "/" in target_kb or "\\" in target_kb or ".." in target_kb:
            raise HTTPException(status_code=400, detail="Invalid KB slug")
        kb_inbox = paths.resolve(f"knowledge/{target_kb}/inbox")
        target = _resolve_inside(kb_inbox, body.filename)
        if not target.is_file():
            # Same race-friendly check as the global path — the file may have
            # been processed by a parallel work-queue retry between drag-start
            # and drop. Library hit = success, not a confusing 404.
            lib_path = _file_in_library(paths, target_kb, body.filename)
            if lib_path:
                _emit_pipeline_event(
                    request,
                    action="pipeline_force_ingest_already_processed",
                    summary=f"Drag of {body.filename} — already processed in {target_kb}",
                    details={"kb": target_kb, "filename": body.filename, "library_path": lib_path},
                    importance="low",
                )
                return {
                    "status": "already_processed",
                    "kb": target_kb,
                    "target_filename": body.filename,
                    "target_status": "already_processed",
                    "target_message": (
                        f"{body.filename} was already processed by a parallel path "
                        f"(work queue or watcher) before your drag landed. It's in {lib_path}."
                    ),
                    "library_path": lib_path,
                }
            raise HTTPException(status_code=404, detail=f"Not in {target_kb} inbox: {body.filename}")

    _emit_pipeline_event(
        request,
        action="pipeline_force_ingest",
        summary=f"User triggered ingest of {body.filename} → KB {target_kb}",
        details={"kb": target_kb, "filename": body.filename, "via": "global" if not body.kb else "per_kb"},
    )

    # Pre-flight freeze check — call is_pipeline_frozen() ourselves rather
    # than waiting for kb.ingest to raise. We need to know NOW (before
    # firing the background task) so we can return 423 with the thaw
    # instructions instead of a misleading 202.
    if hasattr(kb_service, "is_pipeline_frozen") and kb_service.is_pipeline_frozen():  # type: ignore[attr-defined]
        raise HTTPException(
            status_code=423,
            detail=(
                "Ingestion pipeline is frozen. Thaw via Settings → Inbox watcher → "
                "'Freeze all ingestion' or PUT /api/settings/pipeline-freeze body "
                "{\"frozen\": false} to proceed."
            ),
        )

    # Fire-and-forget the ingest. kb.ingest() processes up to 10 files via
    # LLM and can take 30s–3+ minutes, which is way too long to block a
    # user-driven HTTP request (the UI hangs the whole time, no toast, no
    # board refresh, the dragged card stays visible). Instead we kick off
    # the work as a background task and return 202 immediately. The board's
    # 8s polling cycle will pick up the result. Optimistic UI on the client
    # bridges the gap visually: the card moves to In Flight on drop and
    # disappears once polling confirms the file landed in library/.
    import asyncio as _aio

    async def _bg_ingest() -> None:
        try:
            await kb_service.ingest(target_kb)
        except Exception:
            log.exception(
                "background ingest from drag-drop failed for kb=%s target=%s",
                target_kb, body.filename,
            )

    _aio.create_task(_bg_ingest())

    # Honest status: the file IS now in the target KB inbox (we just moved
    # it there for global drops; per-KB drops verified it earlier). The
    # actual extraction runs in the background. UI shows In Flight via
    # optimistic state until polling reflects the move to library.
    return {
        "status": "queued_for_ingest",
        "kb": target_kb,
        "target_filename": body.filename,
        "target_status": "queued_for_ingest",
        "target_message": (
            f"{body.filename} queued for ingestion in KB {target_kb}. "
            "Processing happens in the background — refresh in ~30s to see "
            "it in the wiki."
        ),
        # Legacy-shape fields kept so older UI clients still render:
        "batch_ingested_count": 0,
        "batch_skipped_count": 0,
    }
