"""linear-canal cleanup — Manage tombstoned projects and purge stale data.

When a project directory is deleted the indexing artefacts live on:
  • LanceDB vectors   — in the global store (~/.linear-canal/data/lancedb_global_store/)
  • Backup snapshot   — ctx/, metadata, hash records (~/.linear-canal/backups/<project_id>/)
  • Registry entry    — ~/.linear-canal/data/projects.json

This command:
  1. Scans the registry for projects whose directory no longer exists.
  2. Tombstones them (records ``deleted_at`` + ``purge_after``).
  3. After the grace period has elapsed, purges their vectors + backup.

The default grace period is 30 days — giving you a month to restore an accidentally
deleted project before its embeddings are wiped.  Run `linear-canal setup` inside a
restored project to re-activate it; the pipeline will detect the backup and perform a
fast incremental re-index instead of a full re-embed.

Usage
-----
  linear-canal cleanup                    # scan + show status, ask to purge overdue
  linear-canal cleanup --dry-run          # same, but never write anything
  linear-canal cleanup --purge-now        # purge all overdue without confirmation
  linear-canal cleanup --grace-days 7     # override the grace period for new tombstones
  linear-canal cleanup --restore <id>     # restore a tombstoned project's backup
"""

from __future__ import annotations

import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

import click
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text

console = Console()

_DEFAULT_GRACE_DAYS = 30


@click.command("cleanup")
@click.option(
    "--grace-days",
    default=_DEFAULT_GRACE_DAYS,
    show_default=True,
    help="Grace period in days before deleted project data is purged.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    default=False,
    help="Show what would happen without making any changes.",
)
@click.option(
    "--purge-now",
    is_flag=True,
    default=False,
    help="Immediately purge all overdue tombstoned projects (no confirmation prompt).",
)
@click.option(
    "--restore",
    "restore_id",
    default=None,
    metavar="PROJECT_ID",
    help="Restore a tombstoned project's backup to a target directory.",
)
@click.option(
    "--target",
    "restore_target",
    default=None,
    type=click.Path(file_okay=False),
    metavar="DIR",
    help=(
        "Directory to restore into when using --restore. "
        "Defaults to the original project path stored in the registry."
    ),
)
def cleanup_cmd(
    grace_days: int,
    dry_run: bool,
    purge_now: bool,
    restore_id: Optional[str],
    restore_target: Optional[str],
) -> None:
    """Scan for deleted projects, tombstone them, and purge after the grace period.

    \b
    Lifecycle of a deleted project:
      1. Project directory is deleted  ->  directory is missing
      2. `cleanup` detects the missing path  ->  tombstoned (deleted_at set)
      3. Grace period passes (default 30 days)  ->  marked as overdue
      4. `cleanup` purges LanceDB vectors + backup files

    \b
    To undo deletion within the grace period:
      1. Restore the project directory (git restore, cloud backup, etc.)
      2. Run:  linear-canal cleanup --restore <project_id>
         OR:   linear-canal setup   (inside the restored project)
    """
    if dry_run:
        console.print("[dim]Dry-run mode — no changes will be written.[/dim]\n")

    # ── Handle --restore ──────────────────────────────────────────────────────
    if restore_id:
        _do_restore(restore_id, restore_target, dry_run)
        return

    # ── Scan registry ─────────────────────────────────────────────────────────
    from linear_canal.pipeline import (
        _load_registry, tombstone_project, purge_project_data,
        DEFAULT_GRACE_DAYS,
    )

    registry = _load_registry()
    if not registry:
        console.print(Panel(
            "[yellow]No registered projects found.[/yellow]\n\n"
            "Run [bold cyan]linear-canal setup[/bold cyan] to index a project first.",
            border_style="yellow", padding=(0, 2),
        ))
        return

    now = datetime.now(timezone.utc)

    # Categorise every entry
    healthy:   list[dict] = []
    tombstoned: list[dict] = []  # deleted_at set, not yet overdue
    overdue:   list[dict] = []   # deleted_at set, purge_after has passed
    newly_missing: list[str] = []  # path missing, not yet tombstoned

    for pid, entry in registry.items():
        project_path = Path(entry.get("project_path", ""))
        path_exists  = project_path.exists()
        deleted_at   = entry.get("deleted_at")
        purge_after  = entry.get("purge_after")

        if path_exists and not deleted_at:
            healthy.append(entry)
        elif path_exists and deleted_at:
            # Project was tombstoned but directory came back — already handled
            # by register_project on next pipeline run, but flag it as healthy
            healthy.append(entry)
        elif not path_exists and not deleted_at:
            newly_missing.append(pid)
        elif not path_exists and deleted_at and purge_after:
            try:
                purge_dt = datetime.fromisoformat(purge_after)
                if now >= purge_dt:
                    overdue.append(entry)
                else:
                    tombstoned.append(entry)
            except ValueError:
                tombstoned.append(entry)
        else:
            tombstoned.append(entry)

    # ── Tombstone newly missing projects ─────────────────────────────────────
    if newly_missing:
        for pid in newly_missing:
            if not dry_run:
                tombstone_project(pid, grace_days=grace_days)
            entry = registry[pid]
            entry["deleted_at"] = now.isoformat()  # for display
            tombstoned.append(entry)
        verb = "[dim](dry-run)[/dim]" if dry_run else ""
        console.print(
            f"[yellow]Tombstoned {len(newly_missing)} newly missing project(s)[/yellow] {verb}:"
        )
        for pid in newly_missing:
            console.print(f"  [yellow]⚠[/yellow]  {pid}")
        console.print()

    # ── Summary table ─────────────────────────────────────────────────────────
    _print_status_table(healthy, tombstoned, overdue, now, grace_days)

    # ── Purge overdue projects ────────────────────────────────────────────────
    if not overdue:
        if not healthy and not tombstoned:
            console.print("[dim]Nothing to clean up.[/dim]")
        return

    console.print()
    if not purge_now and not dry_run:
        console.print(
            f"[bold red]⚠  {len(overdue)} project(s) are overdue and ready to purge.[/bold red]\n"
            "This will permanently delete their LanceDB vectors and backup files.\n"
            "The project registry entries will also be removed.\n"
        )
        ans = click.prompt(
            "Purge now?",
            default="n",
            show_choices=True,
            type=click.Choice(["y", "n"], case_sensitive=False),
        )
        if ans.lower() != "y":
            console.print("[dim]Skipped. Run again with --purge-now to skip this prompt.[/dim]")
            return

    for entry in overdue:
        pid = entry["project_id"]
        if dry_run:
            console.print(f"  [dim](dry-run) would purge:[/dim] [bold red]{pid}[/bold red]")
            continue
        console.print(f"  Purging [bold red]{pid}[/bold red]...", end="  ")
        report = purge_project_data(pid)
        v = report["vectors_deleted"]
        b = "✓" if report["backup_deleted"] else "·"
        r = "✓" if report["registry_removed"] else "·"
        console.print(
            f"[green]Done[/green]  "
            f"vectors=[cyan]{v:,}[/cyan]  backup={b}  registry={r}"
        )

    if not dry_run:
        console.print(f"\n[green]✓ Purged {len(overdue)} project(s).[/green]")


# ── Restore helper ─────────────────────────────────────────────────────────────

def _do_restore(project_id: str, target: Optional[str], dry_run: bool) -> None:
    """Restore a tombstoned project's backup."""
    from linear_canal.pipeline import _load_registry, _BACKUP_ROOT, restore_from_backup

    backup_dir = _BACKUP_ROOT / project_id
    if not backup_dir.exists():
        console.print(Panel(
            f"[red]No backup found for project '[bold]{project_id}[/bold]'.[/red]\n\n"
            f"  Expected: [dim]{backup_dir}[/dim]\n\n"
            "Backups are created automatically after every full index run.\n"
            "If the project was never indexed with this version of Linear Canal\n"
            "no backup will exist.",
            border_style="red", padding=(0, 2),
        ))
        sys.exit(1)

    # Determine restore target
    if target:
        restore_path = Path(target).resolve()
    else:
        registry  = _load_registry()
        entry     = registry.get(project_id, {})
        orig_path = entry.get("project_path")
        if not orig_path:
            console.print(
                f"[red]Project '{project_id}' not in registry. "
                "Use --target to specify a restore directory.[/red]"
            )
            sys.exit(1)
        restore_path = Path(orig_path)

    console.print(f"Restoring [bold cyan]{project_id}[/bold cyan] -> [white]{restore_path}[/white]")

    if not restore_path.exists():
        console.print(
            f"[yellow]Target directory does not exist: {restore_path}[/yellow]\n"
            "Create it first or use --target to choose a different destination."
        )
        sys.exit(1)

    if dry_run:
        console.print("[dim](dry-run) — no files written.[/dim]")
        return

    restored = restore_from_backup(project_id, restore_path)
    if restored:
        console.print(
            Panel(
                f"[bold green]Restored '{project_id}'[/bold green]\n\n"
                f"  Ctx files, metadata, and hash records have been copied to:\n"
                f"  [cyan]{restore_path / '.linear_canal_data' / project_id}[/cyan]\n\n"
                "  Run [bold cyan]linear-canal setup[/bold cyan] inside the project to\n"
                "  complete the re-index (LanceDB vectors are still in the global store;\n"
                "  only changed files will be re-embedded).",
                border_style="green", padding=(0, 2),
            )
        )
    else:
        console.print("[yellow]Nothing was restored (files may already exist).[/yellow]")


# ── Status display ─────────────────────────────────────────────────────────────

def _fmt_dt(iso: Optional[str]) -> str:
    if not iso:
        return "—"
    try:
        return datetime.fromisoformat(iso).strftime("%Y-%m-%d")
    except Exception:
        return iso[:10]


def _days_until(iso: Optional[str], now: datetime) -> str:
    if not iso:
        return "—"
    try:
        dt   = datetime.fromisoformat(iso)
        diff = (dt - now).days
        if diff <= 0:
            return "[bold red]overdue[/bold red]"
        if diff <= 7:
            return f"[yellow]{diff}d[/yellow]"
        return f"[dim]{diff}d[/dim]"
    except Exception:
        return "?"


def _print_status_table(
    healthy: list,
    tombstoned: list,
    overdue: list,
    now: datetime,
    grace_days: int,
) -> None:
    """Print a rich summary table of all tracked projects."""
    tbl = Table(
        title="[bold]Project Status[/bold]",
        title_style="bold",
        border_style="dim",
        show_header=True,
        header_style="dim",
        expand=False,
        padding=(0, 1),
    )
    tbl.add_column("Project",       style="bold white",   min_width=22)
    tbl.add_column("Status",        min_width=14)
    tbl.add_column("Last Indexed",  style="dim",          width=12, justify="right")
    tbl.add_column("Deleted At",    style="dim",          width=12, justify="right")
    tbl.add_column("Purge In",      width=10,             justify="right")
    tbl.add_column("Backup",        width=8,              justify="center")
    tbl.add_column("Project Path",  style="dim",          min_width=30)

    _BACKUP_ROOT = Path.home() / ".linear-canal" / "backups"

    def _row(entry: dict, status_text: str, status_style: str) -> None:
        pid          = entry.get("project_id", "?")
        backup_ok    = (_BACKUP_ROOT / pid).exists()
        tbl.add_row(
            pid,
            Text(status_text, style=status_style),
            _fmt_dt(entry.get("last_indexed")),
            _fmt_dt(entry.get("deleted_at")) if entry.get("deleted_at") else "—",
            _days_until(entry.get("purge_after"), now),
            "[green]✓[/green]" if backup_ok else "[dim]✗[/dim]",
            str(entry.get("project_path", ""))[:60],
        )

    for e in sorted(healthy,    key=lambda x: x.get("project_id", "")):
        _row(e, "● healthy",   "bold green")
    for e in sorted(tombstoned, key=lambda x: x.get("project_id", "")):
        _row(e, "⚠ tombstoned", "yellow")
    for e in sorted(overdue,    key=lambda x: x.get("project_id", "")):
        _row(e, "✗ overdue",   "bold red")

    console.print(tbl)

    # Summary line
    parts = []
    if healthy:
        parts.append(f"[green]{len(healthy)} healthy[/green]")
    if tombstoned:
        parts.append(f"[yellow]{len(tombstoned)} tombstoned[/yellow]")
    if overdue:
        parts.append(f"[red]{len(overdue)} overdue[/red]")
    if parts:
        console.print("  " + "  ·  ".join(parts))

    if tombstoned:
        console.print(
            f"\n  [dim]Tombstoned projects will be purged after [bold]{grace_days}[/bold] days.[/dim]\n"
            "  [dim]To recover a tombstoned project:[/dim]\n"
            "  [dim]  1. Restore the project directory[/dim]\n"
            "  [dim]  2. Run  [cyan]linear-canal cleanup --restore <project_id>[/cyan][/dim]\n"
            "  [dim]     OR  [cyan]linear-canal setup[/cyan]  inside the restored directory[/dim]"
        )
