"""Privacy API — export and delete flows (right-to-portability / right-to-erasure).

These endpoints are for the user to exercise control over their own data. They
are NOT for the agent — an LLM-initiated purge would be catastrophic. Every
mutating call goes through the contract + activity middleware and is logged
in the decision trail with a high-importance `user_override` event.

Endpoints:
    GET    /api/privacy/export               — stream a gzip tar of the whole vault
    GET    /api/privacy/summary              — lightweight snapshot of what's stored
    POST   /api/privacy/forget/entity/{slug} — remove an entity + all triples referencing it
    POST   /api/privacy/forget/date-range    — delete journal/memory entries in a date window
    POST   /api/privacy/forget/correlation   — delete every event/decision in one causal chain
"""

from __future__ import annotations

import io
import logging
import tarfile
from datetime import datetime
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

from agntspace.infra.rate_limit import get_limiter

log = logging.getLogger(__name__)

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


async def _check_rate(scope: str, request: Request) -> None:
    """Raise 429 if the caller is over the configured limit for this scope."""
    limiter = get_limiter()
    caller = request.client.host if request.client else "unknown"
    if not await limiter.acquire(scope, caller=caller):
        raise HTTPException(
            status_code=429,
            detail=f"Rate limit exceeded for {scope}. Try again later.",
        )


class DateRangeRequest(BaseModel):
    start_date: str  # YYYY-MM-DD inclusive
    end_date: str    # YYYY-MM-DD inclusive
    scope: str = "journal"  # "journal" | "memory" | "both"
    dry_run: bool = False


class CorrelationRequest(BaseModel):
    correlation_id: str
    dry_run: bool = False


@router.get("/summary")
async def privacy_summary(request: Request) -> dict:
    """Return a lightweight snapshot of what personal data is stored.

    Counts only — no content. Lets a user see the scale of their footprint
    before running an export or delete.
    """
    vault = request.app.state.vault
    paths = getattr(request.app.state, "vault_paths", None)

    def _count(logical: str, pattern: str = "*") -> int:
        try:
            if paths:
                root = paths.resolve(logical)
            else:
                root = Path(vault) / logical
            if not root.is_dir():
                return 0
            return sum(1 for _ in root.rglob(pattern) if _.is_file())
        except Exception:
            return 0

    decisions = getattr(request.app.state, "decisions", None)
    decision_count = 0
    if decisions is not None:
        try:
            stats = await decisions.stats()
            decision_count = stats.get("total", 0)
        except Exception:
            pass

    # Lightweight cloud-exposure snapshot (detailed breakdown lives at
    # /api/privacy/exposure/summary). Degrades gracefully if the cost tracker
    # isn't wired — this endpoint must never 500 on a partially-configured vault.
    exposure = {
        "cloud_calls_30d": 0,
        "cloud_input_tokens_30d": 0,
        "providers_used_30d": [],
    }
    cost_tracker = getattr(request.app.state, "cost_tracker", None)
    if cost_tracker is not None:
        try:
            summary = await cost_tracker.get_exposure_summary()
            exposure["cloud_calls_30d"] = summary["totals"]["cloud_calls"]
            exposure["cloud_input_tokens_30d"] = summary["totals"]["cloud_input_tokens"]
            exposure["providers_used_30d"] = [
                p["provider"] for p in summary["by_provider"] if p["mode"] != "local"
            ]
        except Exception as exc:
            log.debug("privacy_summary: exposure fetch failed: %s", exc)

    return {
        "journal_files": _count("journal", "*.md"),
        "memory_files": _count("memory", "*.md"),
        "project_files": _count("projects", "*.md"),
        "goal_files": _count("goals", "*.md"),
        "wiki_articles": _count("knowledge", "*.md"),
        "identity_files": _count("system/identity", "*.md"),
        "decisions_logged": decision_count,
        "cloud_exposure": exposure,
    }


@router.get("/exposure/summary")
async def privacy_exposure_summary(
    request: Request,
    since: str | None = None,
    until: str | None = None,
    include_local: bool = False,
) -> dict:
    """Aggregate cloud-LLM exposure over a time window (§9.5 dashboard).

    Answers: "what data categories / pipelines sent how many tokens to which
    provider in the last N days?" Defaults to the last 30 days of cloud-only
    traffic so the user can see the active attack surface at a glance.

    `since` / `until` accept ISO-8601 timestamps. Unset → sensible defaults.
    `include_local=true` flips Ollama calls back in for an LLM-spend view.
    """
    cost_tracker = getattr(request.app.state, "cost_tracker", None)
    if cost_tracker is None:
        raise HTTPException(
            status_code=503,
            detail="Cost tracker not available — cloud exposure data requires an initialized LLM layer.",
        )
    try:
        return await cost_tracker.get_exposure_summary(
            since_iso=since,
            until_iso=until,
            include_local=include_local,
        )
    except Exception as exc:
        log.exception("exposure summary failed")
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.get("/exposure/timeline")
async def privacy_exposure_timeline(
    request: Request,
    since: str | None = None,
    until: str | None = None,
    bucket: str = "day",
    include_local: bool = False,
) -> dict:
    """Bucketed timeline of cloud exposure for charting (oldest first)."""
    cost_tracker = getattr(request.app.state, "cost_tracker", None)
    if cost_tracker is None:
        raise HTTPException(status_code=503, detail="Cost tracker not available.")
    try:
        points = await cost_tracker.get_exposure_timeline(
            since_iso=since, until_iso=until, bucket=bucket, include_local=include_local,
        )
        return {"bucket": bucket if bucket in ("day", "hour") else "day", "points": points}
    except Exception as exc:
        log.exception("exposure timeline failed")
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.get("/exposure/recent")
async def privacy_exposure_recent(
    request: Request,
    limit: int = 50,
    include_local: bool = False,
) -> dict:
    """Most recent individual exposure events, newest first.

    Every row carries the correlation_id so the user can click through to
    `/decisions?correlation_id=...` and see the full causal chain.
    """
    cost_tracker = getattr(request.app.state, "cost_tracker", None)
    if cost_tracker is None:
        raise HTTPException(status_code=503, detail="Cost tracker not available.")
    try:
        rows = await cost_tracker.get_exposure_recent(limit=limit, include_local=include_local)
        return {"items": rows, "count": len(rows)}
    except Exception as exc:
        log.exception("exposure recent failed")
        raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.get("/export")
async def privacy_export(request: Request):
    """Stream a gzip tar of the entire vault for user data portability.

    Everything under the vault root is included: journal, memory, knowledge,
    projects, goals, identity, system skills/agents, and the sqlite database
    (agntspace.db). Secrets (credentials.json) are explicitly excluded —
    they contain encrypted API keys that do not belong in a user-export.
    """
    await _check_rate("privacy_export", request)
    vault = Path(request.app.state.vault)
    if not vault.is_dir():
        raise HTTPException(status_code=404, detail="No vault configured")

    # Also include the fixed app_dir contents except credentials
    settings = request.app.state.settings
    app_dir = Path(getattr(settings, "home_dir", "") or "").expanduser()

    def _build_tar() -> io.BytesIO:
        buf = io.BytesIO()
        with tarfile.open(fileobj=buf, mode="w:gz") as tar:
            # Vault — everything under the root
            tar.add(
                str(vault),
                arcname="vault",
                filter=_exclude_secrets,
            )
            # App dir database (no credentials, no secrets)
            if app_dir.is_dir():
                db_file = app_dir / "agntspace.db"
                if db_file.is_file():
                    tar.add(str(db_file), arcname="app/agntspace.db")
                config_file = app_dir / "config.toml"
                if config_file.is_file():
                    tar.add(str(config_file), arcname="app/config.toml")
        buf.seek(0)
        return buf

    def _exclude_secrets(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
        name = info.name
        # Strip credentials and ephemeral state from the tar
        excluded_basenames = {
            "credentials.json",
            ".DS_Store",
            ".inbox-watcher-state.json",
            ".vault-edit-watcher-state.json",
            ".layout-migrated",
        }
        base = name.rsplit("/", 1)[-1]
        if base in excluded_basenames:
            return None
        if "/.quarantine/" in name or "/.history/" in name:
            return None
        return info

    buf = _build_tar()
    filename = f"agntspace-export-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.tar.gz"
    return StreamingResponse(
        buf,
        media_type="application/gzip",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


@router.post("/forget/entity/{slug}")
async def forget_entity(
    request: Request,
    slug: str,
    dry_run: bool = False,
    cascade_prose: bool = False,
    cascade_mode: str = "llm",
) -> dict:
    """Remove an entity and every triple referencing it from the knowledge graph.

    Modes when `cascade_prose=true`:
        cascade_mode="llm"   — (default) each affected article is rewritten
                               by the LLM to remove references while
                               preserving coherent prose
        cascade_mode="strip" — fast line-strip cascade: every line mentioning
                               a redacted name is removed. Preserves nothing
                               if it breaks the article, but runs without
                               any LLM cost.

    Use case: "forget everything about this specific person". By default
    only touches the structured graph. Pass `cascade_prose=true` to ALSO
    rewrite wiki articles mentioning the entity.
    """
    if not dry_run:
        await _check_rate("privacy_forget", request)
    graph = getattr(request.app.state, "knowledge_graph", None)
    if graph is None:
        raise HTTPException(status_code=503, detail="Knowledge graph not available")

    registry = getattr(graph, "_registry", None) or getattr(graph, "registry", None)
    entity = registry.get(slug) if registry else None
    if not entity:
        raise HTTPException(status_code=404, detail=f"Entity not found: {slug}")

    # Count triples referencing this entity first (dry-run friendly)
    referencing_triples = []
    try:
        for tri in list(getattr(graph, "_triples", [])):
            subj_slug = tri.get("subject_slug") or ""
            obj_slug = tri.get("object_slug") or ""
            if subj_slug == slug or obj_slug == slug:
                referencing_triples.append(tri)
    except Exception:
        log.warning("Failed to scan triples for entity %s", slug, exc_info=True)

    if dry_run:
        return {
            "dry_run": True,
            "slug": slug,
            "canonical_name": entity.get("canonical_name", ""),
            "triples_to_delete": len(referencing_triples),
            "aliases": entity.get("aliases", []),
        }

    # Delete triples
    if hasattr(graph, "_triples"):
        graph._triples = [  # noqa: SLF001
            t for t in graph._triples  # noqa: SLF001
            if (t.get("subject_slug") != slug and t.get("object_slug") != slug)
        ]
        if hasattr(graph, "save"):
            try:
                graph.save()
            except Exception:
                log.warning("Failed to save graph after entity deletion", exc_info=True)

    # Delete entity
    if registry and hasattr(registry, "_entities"):
        registry._entities.pop(slug, None)  # noqa: SLF001
        # Clean name + alias indexes
        if hasattr(registry, "_name_index"):
            registry._name_index = {  # noqa: SLF001
                k: v for k, v in registry._name_index.items() if v != slug  # noqa: SLF001
            }
        if hasattr(registry, "_alias_index"):
            registry._alias_index = {  # noqa: SLF001
                k: v for k, v in registry._alias_index.items() if v != slug  # noqa: SLF001
            }
        if hasattr(registry, "save"):
            try:
                registry.save()
            except Exception:
                log.warning("Failed to save registry after entity deletion", exc_info=True)

    # Cascade to prose (gap #3): find wiki articles that mention the
    # entity's canonical name (or any alias) in their body and rewrite
    # them to remove references. Two modes:
    #   llm   — send each article to the LLM for a context-preserving rewrite
    #   strip — fast line-drop cascade
    articles_affected: list[str] = []
    articles_purged: list[str] = []
    articles_rewritten: list[dict] = []
    if cascade_prose:
        canonical_name = entity.get("canonical_name", "")
        aliases = entity.get("aliases", [])
        entity_type = entity.get("entity_type", "")
        names_to_redact = [n for n in [canonical_name, *aliases] if n]
        vault = request.app.state.vault
        kb = getattr(request.app.state, "kb_service", None)
        llm = getattr(request.app.state, "llm", None)

        to_logical = vault.paths.to_logical if vault.paths else (lambda p: str(p))

        try:
            candidate_files: list = []
            for kb_file in vault.list_files("knowledge", "**/wiki/**/*.md"):
                fpath_str = str(kb_file).replace("\\", "/")
                if "/.history/" in fpath_str or "/.archives/" in fpath_str:
                    continue
                try:
                    body = kb_file.read_text(encoding="utf-8")
                except OSError:
                    continue
                if any(name in body for name in names_to_redact):
                    candidate_files.append((kb_file, body))
                    articles_affected.append(to_logical(kb_file))

            # LLM rewrite mode — one job per article, respects contract
            if cascade_mode == "llm" and llm is not None:
                from agntspace.privacy.llm_rewrite import rewrite_article
                # Contract gate — check once; if blocked, fall back to strip
                contract = getattr(request.app.state, "contract", None)
                contract_allowed = True
                if contract is not None:
                    check = contract.check("write_vault")
                    if not check.allowed:
                        contract_allowed = False
                        log.warning(
                            "Privacy cascade LLM rewrite blocked by contract (%s) — "
                            "falling back to strip mode", check.reason,
                        )

                if contract_allowed:
                    for kb_file, _ in candidate_files:
                        rewrite_result = await rewrite_article(
                            llm, kb_file, names_to_redact, entity_type,
                        )
                        articles_rewritten.append(rewrite_result)
                        if rewrite_result["rewritten"]:
                            articles_purged.append(to_logical(kb_file))
                else:
                    cascade_mode = "strip"  # reclassify for downstream logic

            # Strip mode — fast but lossy
            if cascade_mode == "strip":
                for kb_file, body in candidate_files:
                    new_lines = [
                        line for line in body.splitlines()
                        if not any(name in line for name in names_to_redact)
                    ]
                    cleaned = "\n".join(new_lines)
                    if cleaned != body:
                        try:
                            kb_file.write_text(cleaned, encoding="utf-8")
                            articles_purged.append(to_logical(kb_file))
                        except OSError:
                            pass

            # Queue a full re-compile for each affected KB so the
            # librarian can reconcile the stripped/rewritten articles.
            if kb is not None and articles_affected:
                affected_kbs: set[str] = set()
                for rel in articles_affected:
                    parts = rel.replace("\\", "/").split("/")
                    if len(parts) >= 2 and parts[0] == "knowledge":
                        affected_kbs.add(parts[1])
                if getattr(kb, "_work_queue", None) is not None:
                    import asyncio as _asyncio
                    for kb_slug in sorted(affected_kbs):
                        try:
                            _asyncio.create_task(
                                kb._work_queue.enqueue(  # noqa: SLF001
                                    "compile",
                                    {"slug": kb_slug, "reason": "privacy_cascade"},
                                    deduplicate=True,
                                )
                            )
                        except Exception:
                            pass
        except Exception:
            log.exception("Privacy cascade to prose failed for entity %s", slug)

    # Record as a high-importance user_override in the decision log
    decisions = getattr(request.app.state, "decisions", None)
    if decisions is not None:
        try:
            await decisions.record(
                actor="user",
                action_type="privacy_forget",
                action_target=f"entity:{slug}",
                contract_action="forget_entity",
                contract_result="allowed",
                triggered_by="privacy_api",
                rationale=f"User invoked right-to-erasure for entity '{slug}'",
                details={
                    "slug": slug,
                    "canonical_name": entity.get("canonical_name", ""),
                    "triples_deleted": len(referencing_triples),
                    "cascade_prose": cascade_prose,
                    "articles_affected": len(articles_affected),
                    "articles_purged": len(articles_purged),
                },
            )
        except Exception:
            pass

    # Cascade to trust-changes.md: remove any change-log lines that
    # mention this entity's canonical name or aliases (right-to-erasure).
    canonical_name = entity.get("canonical_name", "")
    aliases = entity.get("aliases", [])
    _redact_names = [n.lower() for n in [canonical_name, *aliases] if n]
    trust_changes_cleaned = 0
    if _redact_names:
        try:
            vault = request.app.state.vault
            reader = getattr(request.app.state, "vault_reader", vault)
            change_path = "system/identity/trust-changes.md"
            if reader.exists(change_path):
                doc = reader.read(change_path)
                lines = doc.content.split("\n")
                cleaned = [
                    line for line in lines
                    if not any(rn in line.lower() for rn in _redact_names)
                ]
                if len(cleaned) < len(lines):
                    trust_changes_cleaned = len(lines) - len(cleaned)
                    writer = request.app.state.vault_writer
                    writer.write(change_path, "\n".join(cleaned),
                                 trust_reason="privacy_forget_trust_cascade")
        except Exception:
            log.debug("Trust cascade failed for entity %s", slug, exc_info=True)

    return {
        "slug": slug,
        "canonical_name": entity.get("canonical_name", ""),
        "triples_deleted": len(referencing_triples),
        "entity_deleted": True,
        "cascade_prose": cascade_prose,
        "cascade_mode": cascade_mode if cascade_prose else "",
        "articles_affected": articles_affected,
        "articles_purged": articles_purged,
        "articles_rewritten": articles_rewritten,
        "trust_changes_cleaned": trust_changes_cleaned,
    }


@router.post("/forget/date-range")
async def forget_date_range(request: Request, body: DateRangeRequest) -> dict:
    """Delete journal and/or memory entries within a date window.

    A date-scoped right-to-erasure: "forget everything that happened between
    A and B". Scopes: "journal", "memory", or "both". dry_run previews the
    affected files without deleting.
    """
    if not body.dry_run:
        await _check_rate("privacy_forget", request)
    try:
        start = datetime.strptime(body.start_date, "%Y-%m-%d").date()
        end = datetime.strptime(body.end_date, "%Y-%m-%d").date()
    except ValueError as e:
        raise HTTPException(status_code=400, detail=f"Bad date format: {e}") from None
    if start > end:
        raise HTTPException(status_code=400, detail="start_date must be <= end_date")

    vault = Path(request.app.state.vault)
    paths = getattr(request.app.state, "vault_paths", None)

    def _in_range(fname: str) -> bool:
        """Journal/memory files are named *_YYYY-MM-DD.md. Extract the date."""
        import re
        m = re.search(r"(\d{4}-\d{2}-\d{2})", fname)
        if not m:
            return False
        try:
            d = datetime.strptime(m.group(1), "%Y-%m-%d").date()
        except ValueError:
            return False
        return start <= d <= end

    targets: list[Path] = []
    scopes = ["journal", "memory"] if body.scope == "both" else [body.scope]
    for scope in scopes:
        try:
            root = paths.resolve(scope) if paths else (vault / scope)
        except Exception:
            continue
        if not root.is_dir():
            continue
        for p in root.rglob("*.md"):
            if "/.history/" in str(p).replace("\\", "/"):
                continue
            if _in_range(p.name):
                targets.append(p)

    if body.dry_run:
        return {
            "dry_run": True,
            "start_date": body.start_date,
            "end_date": body.end_date,
            "scope": body.scope,
            "files": [str(p.relative_to(vault)) for p in targets],
            "count": len(targets),
        }

    deleted = 0
    for p in targets:
        try:
            p.unlink()
            deleted += 1
        except OSError:
            continue

    decisions = getattr(request.app.state, "decisions", None)
    if decisions is not None:
        try:
            await decisions.record(
                actor="user",
                action_type="privacy_forget",
                action_target=f"date-range:{body.start_date}..{body.end_date}",
                contract_action="forget_date_range",
                contract_result="allowed",
                triggered_by="privacy_api",
                rationale=f"User erased {deleted} files in scope '{body.scope}'",
                details={
                    "start_date": body.start_date,
                    "end_date": body.end_date,
                    "scope": body.scope,
                    "files_deleted": deleted,
                },
            )
        except Exception:
            pass

    return {
        "start_date": body.start_date,
        "end_date": body.end_date,
        "scope": body.scope,
        "files_deleted": deleted,
    }


@router.post("/forget/correlation")
async def forget_correlation(request: Request, body: CorrelationRequest) -> dict:
    """Delete every decision and activity event belonging to one causal chain.

    Use case: "Undo everything that happened in that chat turn" — the
    correlation_id threads all the decisions, tool calls, and events.
    """
    if not body.dry_run:
        await _check_rate("privacy_forget", request)
    decisions = getattr(request.app.state, "decisions", None)
    if decisions is None:
        raise HTTPException(status_code=503, detail="Decision log not available")

    # Preview
    matching = await decisions.query(correlation_id=body.correlation_id, limit=10000)
    if body.dry_run:
        return {
            "dry_run": True,
            "correlation_id": body.correlation_id,
            "decisions": len(matching),
        }

    # Delete from SQLite
    deleted = 0
    try:
        if decisions._db is not None:  # noqa: SLF001
            cur = await decisions._db.execute(  # noqa: SLF001
                "DELETE FROM decision_log WHERE correlation_id = ?",
                (body.correlation_id,),
            )
            deleted = cur.rowcount if cur.rowcount and cur.rowcount > 0 else len(matching)
            await decisions._db.commit()  # noqa: SLF001
        else:
            # Fallback list
            before = len(decisions._fallback)  # noqa: SLF001
            decisions._fallback = [  # noqa: SLF001
                d for d in decisions._fallback  # noqa: SLF001
                if d.correlation_id != body.correlation_id
            ]
            deleted = before - len(decisions._fallback)  # noqa: SLF001
    except Exception:
        log.exception("Failed to delete correlation chain")

    # Record the purge as its own event (new correlation_id, not the one we just deleted)
    try:
        await decisions.record(
            actor="user",
            action_type="privacy_forget",
            action_target=f"correlation:{body.correlation_id}",
            contract_action="forget_correlation",
            contract_result="allowed",
            triggered_by="privacy_api",
            rationale=f"User erased correlation chain {body.correlation_id}",
            details={"correlation_id": body.correlation_id, "decisions_deleted": deleted},
        )
    except Exception:
        pass

    return {
        "correlation_id": body.correlation_id,
        "decisions_deleted": deleted,
    }
