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

Composes data from existing subsystems (watcher state, work queue, activity log,
quarantine + unsupported folders) into one shape the UI can render as Kanban
columns: Queued / In flight / Completed today / Failed-quarantined.

This module never writes. It scans inbox folders, queries the activity buffer,
counts work-queue rows by job_type, and returns plain dicts. No new schema, no
new persistence — every datum already exists somewhere; this is a read aggregate.

Source-of-truth mapping:
  Queued  → inbox folders (global + per-KB) minus .quarantine/.unsupported/hidden
  Inflight→ work_queue.status='running' for ingest job_types
            + best-effort: known_files but not yet in completed events
  Done    → ActivityLogger events with category=knowledge, action in DONE_ACTIONS
  Failed  → library/.quarantine/ files + library/unsupported/ + work_queue dead_letter
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from agntspace.activity.logger import ActivityEvent, ActivityLogger
    from agntspace.automation.work_queue import WorkQueue
    from agntspace.vault.paths import VaultPaths

log = logging.getLogger(__name__)


# Activity actions that mean "a file successfully made it through ingestion".
# Mirrors the action names emitted by KnowledgeBaseService._do_ingest_body.
#
# Critical: do NOT include ``ingest_completed`` here. That event fires once
# per batch (regardless of file count) — including empty batches when the
# work queue drains pre-queued retry jobs against a KB whose inbox is now
# empty. Counting batch-completes as "done" inflates the dashboard with
# events that didn't actually ingest anything. Use the per-file ``ingested``
# event only.
_DONE_ACTIONS: frozenset[str] = frozenset({
    "ingested",
})

# Activity actions that mean "this file did NOT enter the wiki" — used for the
# "Skipped today" sub-count under Completed. Skips are visible but separate
# from successes so the user can see "5 imported, 3 skipped (duplicate)" rather
# than a misleading 8-count.
_SKIP_ACTIONS: frozenset[str] = frozenset({  # arch:deterministic — internal ActivityLogger action identifiers tied 1:1 to ingest emitter sites
    "ingest_skipped_unchanged",
    "ingest_skipped_duplicate",
    "ingest_skipped_cross_kb",
    "ingest_skipped_unsupported",
})

# Activity actions that mean a file ended badly mid-flight. Crashes accumulate
# inside the failed bucket alongside on-disk quarantine + unsupported.
_CRASH_ACTIONS: frozenset[str] = frozenset({
    "ingest_file_crashed",
    "ingest_quarantined",
})

# Job types in the work queue that represent file-level ingest work. Other job
# types (compile, reflect, lint) operate at KB level and don't belong on this
# board — they're work AFTER files made it in.
_INGEST_JOB_TYPES: frozenset[str] = frozenset({
    "ingest",
    "wiki_job",
})

# Same exclusion rules as raw_watcher — keep the two in sync if the watcher's
# rules change. Hidden + skip-dirs match what _scan_dir_recursive does.
_HIDDEN_PREFIXES: tuple[str, ...] = (".", "_")
_SKIP_DIR_NAMES: frozenset[str] = frozenset({  # arch:deterministic — must match raw_watcher's hidden/skip dirs; structural filesystem layout, not user strategy
    ".quarantine",
    ".unsupported",
    "unsupported",
    "library",
})


def _is_visible_inbox_file(p: Path) -> bool:
    """Files we'd consider 'queued' — exclude hidden, system, and skip-dir entries."""
    if not p.is_file():
        return False
    if p.name.startswith(_HIDDEN_PREFIXES):
        return False
    if p.name == ".gitkeep":
        return False
    return True


def _scan_inbox_dir(inbox: Path, *, source_label: str, kb: str = "") -> list[dict[str, Any]]:
    """Yield queued-file records from one inbox directory (non-recursive)."""
    out: list[dict[str, Any]] = []
    if not inbox.is_dir():
        return out
    try:
        entries = list(inbox.iterdir())
    except OSError:
        return out
    for f in entries:
        if not _is_visible_inbox_file(f):
            continue
        try:
            st = f.stat()
        except OSError:
            continue
        out.append({
            "filename": f.name,
            "path": str(f),
            "size": st.st_size,
            "mtime": st.st_mtime,
            "kb": kb,
            "source": source_label,
            "ext": f.suffix.lower(),
        })
    return out


def list_queued_files(paths: VaultPaths) -> list[dict[str, Any]]:
    """Enumerate files currently waiting in inbox folders.

    Scans the global drop zone and every per-KB inbox. Excludes .quarantine/,
    .unsupported/, and hidden/system files. Returns newest-first.
    """
    out: list[dict[str, Any]] = []

    # Global inbox
    global_inbox = paths.resolve("inbox")
    out.extend(_scan_inbox_dir(global_inbox, source_label="global"))

    # Per-KB inboxes
    kb_root = paths.resolve("knowledge")
    if kb_root.is_dir():
        try:
            kb_entries = list(kb_root.iterdir())
        except OSError:
            kb_entries = []
        for kb in kb_entries:
            if not kb.is_dir():
                continue
            kb_inbox = kb / "inbox"
            out.extend(_scan_inbox_dir(kb_inbox, source_label=f"kb:{kb.name}", kb=kb.name))

    out.sort(key=lambda r: r["mtime"], reverse=True)
    return out


def list_quarantined_files(paths: VaultPaths) -> list[dict[str, Any]]:
    """Enumerate files in per-KB .quarantine/ directories.

    Mirrors api/routes/watcher.py:list_quarantine but returns a richer shape
    suitable for the pipeline view (adds bucket label + ext).
    """
    out: list[dict[str, Any]] = []
    kb_root = paths.resolve("knowledge")
    if not kb_root.is_dir():
        return out
    try:
        kb_entries = list(kb_root.iterdir())
    except OSError:
        return out
    for kb in kb_entries:
        if not kb.is_dir():
            continue
        q = kb / "inbox" / ".quarantine"
        if not q.is_dir():
            continue
        try:
            files = list(q.iterdir())
        except OSError:
            continue
        for f in files:
            if not f.is_file():
                continue
            try:
                st = f.stat()
            except OSError:
                continue
            out.append({
                "filename": f.name,
                "path": str(f),
                "size": st.st_size,
                "mtime": st.st_mtime,
                "kb": kb.name,
                "bucket": "quarantined",
                "reason": "Timed out or crashed during ingest",
                "ext": f.suffix.lower(),
            })

    out.sort(key=lambda r: r["mtime"], reverse=True)
    return out


def list_unsupported_files(paths: VaultPaths) -> list[dict[str, Any]]:
    """Enumerate files in per-KB library/unsupported/ directories.

    Reads the .reason.txt sidecar (if present) so the user sees WHY each file
    was parked. Does not recurse — sidecars sit next to their files.
    """
    out: list[dict[str, Any]] = []
    kb_root = paths.resolve("knowledge")
    if not kb_root.is_dir():
        return out
    try:
        kb_entries = list(kb_root.iterdir())
    except OSError:
        return out
    for kb in kb_entries:
        if not kb.is_dir():
            continue
        unsupported = kb / "library" / "unsupported"
        if not unsupported.is_dir():
            continue
        try:
            files = list(unsupported.iterdir())
        except OSError:
            continue
        for f in files:
            if not f.is_file() or f.suffix.lower() == ".txt" and f.name.endswith(".reason.txt"):
                # Sidecars are read alongside their parent file, not listed standalone.
                continue
            try:
                st = f.stat()
            except OSError:
                continue
            reason_file = f.with_name(f.name + ".reason.txt")
            reason = ""
            if reason_file.is_file():
                try:
                    reason = reason_file.read_text(encoding="utf-8", errors="replace").strip()
                except OSError:
                    reason = ""
            out.append({
                "filename": f.name,
                "path": str(f),
                "size": st.st_size,
                "mtime": st.st_mtime,
                "kb": kb.name,
                "bucket": "unsupported",
                "reason": reason or "Unsupported type or oversized",
                "ext": f.suffix.lower(),
            })

    out.sort(key=lambda r: r["mtime"], reverse=True)
    return out


def _event_to_record(ev: ActivityEvent, *, bucket: str) -> dict[str, Any]:
    """Render an ActivityEvent into the row shape used by the pipeline UI."""
    details = ev.details or {}
    filename = (
        details.get("file")
        or details.get("filename")
        or details.get("target_file")
        or ""
    )
    kb = details.get("slug") or details.get("kb") or ""
    return {
        "filename": filename or ev.summary[:80],
        "path": details.get("path", ""),
        "kb": kb,
        "bucket": bucket,
        "action": ev.action,
        "timestamp": ev.timestamp,
        "summary": ev.summary,
        "importance": ev.importance,
        "correlation_id": ev.correlation_id,
        "reason": (
            details.get("reason")
            or details.get("error")
            or details.get("error_type")
            or ""
        ),
    }


def list_recent_events(
    activity: ActivityLogger | None,
    *,
    limit: int = 50,
    since_iso: str | None = None,
) -> dict[str, list[dict[str, Any]]]:
    """Return today's ingest activity events split into done / skipped / crashed.

    Reads the in-memory activity buffer. Quiet on missing logger (returns empty
    lists) so the route never breaks because activity wiring failed.
    """
    if activity is None:
        return {"done": [], "skipped": [], "crashed": []}

    try:
        events = activity.get_recent_events(
            since_iso=since_iso,
            categories=["knowledge"],
            min_importance="low",
            limit=max(limit * 4, 200),  # over-fetch so we have headroom after splitting
        )
    except Exception:
        log.exception("pipeline_status: failed to read activity buffer")
        return {"done": [], "skipped": [], "crashed": []}

    done: list[dict[str, Any]] = []
    skipped: list[dict[str, Any]] = []
    crashed: list[dict[str, Any]] = []

    for ev in events:
        action = ev.action or ""
        if action in _DONE_ACTIONS:
            done.append(_event_to_record(ev, bucket="done"))
        elif action in _SKIP_ACTIONS:
            skipped.append(_event_to_record(ev, bucket="skipped"))
        elif action in _CRASH_ACTIONS:
            crashed.append(_event_to_record(ev, bucket="crashed"))

    # Newest first within each bucket (the buffer is chronological)
    done.reverse()
    skipped.reverse()
    crashed.reverse()
    return {
        "done": done[:limit],
        "skipped": skipped[:limit],
        "crashed": crashed[:limit],
    }


async def list_inflight_jobs(work_queue: WorkQueue | None) -> list[dict[str, Any]]:
    """Return work-queue rows for ingest-related job types currently running or pending.

    Pulls the same data the work-queue page uses, but filtered to ingest types
    so the pipeline board is about FILES, not LLM-job retries in general.
    """
    if work_queue is None:
        return []

    try:
        status = await work_queue.get_status()
    except Exception:
        log.exception("pipeline_status: failed to read work queue status")
        return []

    out: list[dict[str, Any]] = []
    for job in status.get("pending_jobs", []):
        if job.get("job_type") not in _INGEST_JOB_TYPES:
            continue
        payload = job.get("payload") or {}
        out.append({
            "id": job.get("id"),
            "job_type": job.get("job_type"),
            "kb": payload.get("slug") or payload.get("kb") or "",
            "filename": payload.get("target_file") or payload.get("file") or "",
            "attempts": job.get("attempts", 0),
            "next_retry": job.get("next_retry") or "",
            "last_error": (job.get("last_error") or "")[:240],
            "created_at": job.get("created_at") or "",
            "reason": payload.get("reason") or "",
            "tier_hint": payload.get("escalate_tier") or payload.get("empty_extraction_tier") or "",
        })
    return out


async def build_summary(
    *,
    paths: VaultPaths,
    activity: ActivityLogger | None,
    work_queue: WorkQueue | None,
    watcher: object | None = None,
    kb_service: object | None = None,
) -> dict[str, Any]:
    """Top-level aggregate for the dashboard PipelineCard.

    Returns counts plus a small sample for each bucket. Cheap to call (single
    pass over inbox dirs, single activity-buffer scan, one work-queue status).
    """
    queued = list_queued_files(paths)
    inflight = await list_inflight_jobs(work_queue)
    events = list_recent_events(activity, limit=50)
    quarantined = list_quarantined_files(paths)
    unsupported = list_unsupported_files(paths)

    failed = quarantined + unsupported

    # Watcher gives us an authoritative "running?" + "last cycle" — surface it
    # in the summary so the PipelineCard can show a traffic-light dot without
    # a second round trip.
    watcher_state: dict[str, Any] = {"running": False, "paused": False}
    if watcher is not None:
        try:
            ws = watcher.get_status()
            watcher_state = {
                "running": bool(ws.get("running", False)),
                "paused": bool(ws.get("paused", False)),
                "last_cycle_seconds_ago": ws.get("last_cycle_seconds_ago"),
                "last_cycle_ingested": ws.get("last_cycle_ingested", 0),
                "last_error": ws.get("last_error", ""),
                "interval_seconds": ws.get("interval_seconds", 0),
                "cycle_count": ws.get("cycle_count", 0),
                # fs_events bridge tells the dashboard whether we're in
                # real-time (watchdog) mode or polling-only — folded into
                # PipelineCard after WatcherHealthCard was retired.
                "fs_events": ws.get("fs_events"),
            }
        except Exception:
            log.exception("pipeline_status: failed to read watcher status")

    # Pipeline freeze — global emergency gate distinct from watcher.paused.
    # Surface here so the dashboard card + /pipeline page render an honest
    # banner without a separate round trip to /api/settings/pipeline-freeze.
    pipeline_frozen = False
    if kb_service is not None and hasattr(kb_service, "is_pipeline_frozen"):
        try:
            pipeline_frozen = bool(kb_service.is_pipeline_frozen())
        except Exception:
            log.exception("pipeline_status: failed to read freeze state")

    return {
        "queued_count": len(queued),
        "inflight_count": len(inflight),
        "done_today_count": len(events["done"]),
        "skipped_today_count": len(events["skipped"]),
        "failed_count": len(failed),
        "queued_sample": queued[:8],
        "inflight_sample": inflight[:8],
        "done_sample": events["done"][:8],
        "skipped_sample": events["skipped"][:5],
        "failed_sample": failed[:8],
        "watcher": watcher_state,
        "frozen": pipeline_frozen,
    }


async def build_full_board(
    *,
    paths: VaultPaths,
    activity: ActivityLogger | None,
    work_queue: WorkQueue | None,
    limit_per_bucket: int = 100,
    kb_service: object | None = None,
) -> dict[str, Any]:
    """Full Kanban-board payload for the /pipeline page.

    Same data as build_summary but unbounded (per limit) so users can scroll
    through every queued file, every inflight job, today's complete log.
    """
    queued = list_queued_files(paths)[:limit_per_bucket]
    inflight = (await list_inflight_jobs(work_queue))[:limit_per_bucket]
    events = list_recent_events(activity, limit=limit_per_bucket)
    quarantined = list_quarantined_files(paths)
    unsupported = list_unsupported_files(paths)

    failed = (quarantined + unsupported)[:limit_per_bucket]

    pipeline_frozen = False
    if kb_service is not None and hasattr(kb_service, "is_pipeline_frozen"):
        try:
            pipeline_frozen = bool(kb_service.is_pipeline_frozen())
        except Exception:
            log.exception("pipeline_status: failed to read freeze state")

    return {
        "queued": queued,
        "inflight": inflight,
        "done": events["done"],
        "skipped": events["skipped"],
        "crashed": events["crashed"],
        "failed": failed,  # quarantine + unsupported (file-system terminal failures)
        "counts": {
            "queued": len(queued),
            "inflight": len(inflight),
            "done": len(events["done"]),
            "skipped": len(events["skipped"]),
            "crashed": len(events["crashed"]),
            "failed": len(failed),
        },
        "frozen": pipeline_frozen,
    }
