"""linear-canal watch — Watch a project for file changes and incrementally update context.

When a file is created or modified:
    1. Scout scores it (is it relevant?)
    2. If it meets the relevance threshold, the Indexer embeds it into Qdrant
    3. The Context Compiler recompiles the affected .ctx context ring
    4. The ambient files (CLAUDE.md etc.) are refreshed

This runs as a foreground process until Ctrl-C.
"""

from __future__ import annotations

import threading
import time
from pathlib import Path
from typing import Optional

import click
from rich.console import Console
from rich.panel import Panel

console = Console()

# Files/dirs that are always ignored by the watcher (LC internal + common noise)
_ALWAYS_IGNORE = {
    ".linear_canal_data",
    ".linear-canal",
    ".LC",
    ".git",
    "__pycache__",
    "node_modules",
    ".venv",
    "venv",
    ".mypy_cache",
    ".pytest_cache",
    ".ruff_cache",
}

# How long to wait after the last event before triggering pipeline (debounce)
_DEFAULT_DEBOUNCE_SECONDS = 3


@click.command("watch")
@click.option(
    "--project",
    "-p",
    type=click.Path(exists=True, file_okay=False),
    default=".",
    help="Path to the project to watch (default: current directory)",
)
@click.option(
    "--profile",
    type=click.Choice(["standard", "minimal"]),
    default=None,
    help="Embedding profile to use (default: auto-detected from RAM)",
)
@click.option(
    "--debounce",
    type=int,
    default=_DEFAULT_DEBOUNCE_SECONDS,
    show_default=True,
    help="Seconds to wait after last file event before triggering pipeline",
)
@click.option(
    "--verbose", "-v",
    is_flag=True,
    default=False,
    help="Show every file event as it happens",
)
@click.option(
    "--sidecars",
    is_flag=True,
    default=False,
    help="Generate .lc.md sidecar context files for all source files and exit",
)
@click.option(
    "--clean-sidecars",
    is_flag=True,
    default=False,
    help="Remove all .lc.md sidecar files and exit",
)
def watch_cmd(
    project: str,
    profile: Optional[str],
    debounce: int,
    verbose: bool,
    sidecars: bool,
    clean_sidecars: bool,
) -> None:
    """Watch a project for file changes and keep context up-to-date.

    Monitors the project directory for new or modified files generated
    by AI agents or developers. When a change is detected:

    \b
      1. Scout scores the changed files
      2. Relevant files are embedded (Indexer)
      3. Context ring (.ctx files) is recompiled
      4. Ambient agent files (CLAUDE.md etc.) are refreshed

    Press Ctrl-C to stop watching.

    \b
    Sidecar mode (passive context for all agents):
      linear-canal watch --sidecars       Generate .lc.md context files once
      linear-canal watch --clean-sidecars Remove all .lc.md sidecar files
    """
    project_path = Path(project).resolve()

    # ── Sidecar-only modes ────────────────────────────────────────────────
    if clean_sidecars:
        from linear_canal.sidecar import delete_all_sidecars
        count = delete_all_sidecars(project_path)
        console.print(f"[grey74]Removed {count} sidecar file{'s' if count != 1 else ''}.[/grey74]")
        return

    if sidecars:
        from linear_canal.sidecar import find_project_ctx, generate_all_sidecars
        pid, ctx_dir = find_project_ctx(project_path)
        if not pid:
            console.print("[red]No .linear_canal_data found. Run linear-canal setup first.[/red]")
            return
        console.print(f"[bold white]LC Sidecars[/bold white] [grey74]— project:[/grey74] [orange1]{pid}[/orange1]")
        count = generate_all_sidecars(project_path)
        console.print(f"[grey74]Generated {count} sidecar file{'s' if count != 1 else ''}.[/grey74]")
        console.print(f"[dim]Add *.lc.md to .gitignore to keep them local.[/dim]")
        return

    # ── Normal pipeline watcher ───────────────────────────────────────────
    data_dir = project_path / ".linear_canal_data"
    project_id = project_path.name

    console.print(
        Panel(
            f"[bold green]Linear Canal — File Watcher[/bold green]\n"
            f"Project  : [cyan]{project_path}[/cyan]\n"
            f"Profile  : [cyan]{profile or 'auto'}[/cyan]\n"
            f"Debounce : [cyan]{debounce}s[/cyan]\n\n"
            f"[dim]Watching for file changes... Press Ctrl-C to stop.[/dim]",
            border_style="green",
        )
    )

    # State tracking
    stats = {
        "files_watched": 0,
        "events_caught": 0,
        "pipeline_runs": 0,
        "last_event": "—",
        "last_run": "—",
        "status": "[green]Watching[/green]",
    }

    try:
        _run_watcher(
            project_path=project_path,
            data_dir=data_dir,
            project_id=project_id,
            profile=profile,
            debounce_seconds=debounce,
            verbose=verbose,
            stats=stats,
        )
    except KeyboardInterrupt:
        console.print("\n[yellow]Watcher stopped.[/yellow]")


def _should_ignore(path: Path, workspace: Path) -> bool:
    """Return True if this path should be ignored by the watcher."""
    try:
        rel = path.relative_to(workspace)
    except ValueError:
        return True

    # Check every part of the relative path against the ignore set,
    # and ignore any hidden directory (part starting with ".") except
    # .env and .env.local files at any depth.
    for part in rel.parts:
        if part in _ALWAYS_IGNORE:
            return True
        # Ignore hidden directories (e.g. .hidden_module/src/file.py)
        # but allow .env / .env.local files specifically
        if part.startswith(".") and part not in {".env", ".env.local"}:
            return True

    # Ignore common generated/binary extensions
    ext = path.suffix.lower()
    if ext in {".pyc", ".pyo", ".map", ".min.js", ".min.css", ".lock", ".tmp"}:
        return True

    return False


def _run_incremental_pipeline(
    changed_paths: list[Path],
    project_path: Path,
    data_dir: Path,
    project_id: str,
    profile: Optional[str],
    stats: dict,
) -> None:
    """Run the incremental pipeline using the bundled linear_canal.pipeline module."""
    rel_paths = [
        str(p.relative_to(project_path)).replace("\\", "/") for p in changed_paths
    ]
    stats["status"] = "[yellow]Running pipeline...[/yellow]"
    stats["last_run"] = time.strftime("%H:%M:%S")
    ts = time.strftime("%H:%M:%S")
    n = len(rel_paths)

    try:
        from linear_canal.pipeline import run_incremental

        run_incremental(
            changed_files=rel_paths,
            target_dir=project_path,
            project_id=project_id,
            profile=profile,
        )
        stats["pipeline_runs"] += 1
        console.print(
            f"[dim]{ts}[/dim]  [green]✓[/green]  "
            f"[dim]{n} file{'s' if n != 1 else ''} changed — context updated[/dim]"
        )
    except Exception as exc:
        console.print(
            f"[dim]{ts}[/dim]  [yellow]⚠[/yellow]  "
            f"[dim]{n} file{'s' if n != 1 else ''} changed — pipeline error: {exc}[/dim]"
        )

    stats["status"] = "[green]Watching[/green]"


def _run_watcher(
    project_path: Path,
    data_dir: Path,
    project_id: str,
    profile: Optional[str],
    debounce_seconds: int,
    verbose: bool,
    stats: dict,
) -> None:
    """Core watcher loop using watchdog with debounced pipeline execution."""

    try:
        from watchdog.events import FileSystemEvent, FileSystemEventHandler
        from watchdog.observers import Observer

        # Thread-safe shared state
        pending: set[Path] = set()
        pending_lock = threading.Lock()
        last_event_time: list[float] = [0.0]
        pipeline_running: list[bool] = [False]

        class _Handler(FileSystemEventHandler):
            def _handle(self, path: Path) -> None:
                if _should_ignore(path, project_path):
                    return
                with pending_lock:
                    pending.add(path)
                last_event_time[0] = time.time()
                stats["events_caught"] += 1
                stats["last_event"] = path.name
                if verbose:
                    try:
                        rel = path.relative_to(project_path)
                    except ValueError:
                        rel = path
                    console.print(
                        f"[dim]{time.strftime('%H:%M:%S')}[/dim] "
                        f"[blue]event:[/blue] {rel}"
                    )

            def on_created(self, event: FileSystemEvent) -> None:
                if not event.is_directory:
                    self._handle(Path(str(event.src_path)))

            def on_modified(self, event: FileSystemEvent) -> None:
                if not event.is_directory:
                    self._handle(Path(str(event.src_path)))

            def on_deleted(self, event: FileSystemEvent) -> None:
                if not event.is_directory:
                    self._handle(Path(str(event.src_path)))

            def on_moved(self, event: FileSystemEvent) -> None:
                if not event.is_directory:
                    # Track both source (deleted) and destination (created)
                    self._handle(Path(str(event.src_path)))
                    if hasattr(event, "dest_path"):
                        self._handle(Path(str(event.dest_path)))

        handler = _Handler()
        observer = Observer()
        observer.schedule(handler, str(project_path), recursive=True)
        observer.start()
        console.print("[dim]Using watchdog (real-time inotify/FSEvents)[/dim]\n")

        try:
            while True:
                time.sleep(0.5)

                # Skip if pipeline already running (re-entrancy guard)
                if pipeline_running[0]:
                    continue

                with pending_lock:
                    ready = bool(pending) and (time.time() - last_event_time[0]) >= debounce_seconds
                    if ready:
                        batch = list(pending)
                        pending.clear()

                if not ready:
                    continue

                pipeline_running[0] = True
                try:
                    _run_incremental_pipeline(
                        batch, project_path, data_dir, project_id, profile, stats
                    )
                finally:
                    pipeline_running[0] = False
                    # Reset the event clock so events that accumulated
                    # *during* the pipeline run (e.g. LC writing ctx/sentinel
                    # files) get a fresh debounce window and don't immediately
                    # trigger a second pipeline run.
                    last_event_time[0] = time.time()

        finally:
            observer.stop()
            observer.join(timeout=10)

    except ImportError:
        # Fallback: mtime polling
        console.print(
            "[yellow]watchdog not installed — falling back to mtime polling "
            "(slower). Install with: pip install watchdog[/yellow]\n"
        )
        _poll_watcher(
            project_path=project_path,
            data_dir=data_dir,
            project_id=project_id,
            profile=profile,
            debounce_seconds=debounce_seconds,
            verbose=verbose,
            stats=stats,
        )


def _poll_watcher(
    project_path: Path,
    data_dir: Path,
    project_id: str,
    profile: Optional[str],
    debounce_seconds: int,
    verbose: bool,
    stats: dict,
) -> None:
    """Fallback mtime-based polling watcher."""
    import os

    mtimes: dict[str, float] = {}
    pending: set[Path] = set()
    last_event_time: list[float] = [0.0]
    first_run = True

    console.print("[dim]Polling for changes every 2s...[/dim]\n")

    while True:
        time.sleep(2)

        current_paths: set[str] = set()
        for dirpath, dirs, files in os.walk(project_path):
            # Prune ignored dirs in-place so os.walk doesn't descend into them
            dirs[:] = [d for d in dirs if d not in _ALWAYS_IGNORE]
            for fname in files:
                full = Path(dirpath) / fname
                if _should_ignore(full, project_path):
                    continue
                try:
                    mtime = full.stat().st_mtime
                except OSError:
                    continue
                key = str(full)
                current_paths.add(key)
                if key not in mtimes or mtimes[key] != mtime:
                    mtimes[key] = mtime
                    if not first_run:
                        # Only report changes after the baseline snapshot
                        pending.add(full)
                        last_event_time[0] = time.time()
                        stats["events_caught"] += 1
                        stats["last_event"] = full.name
                        if verbose:
                            console.print(f"[dim]changed:[/dim] [blue]{full.relative_to(project_path)}[/blue]")

        # Prune deleted files from mtimes to prevent unbounded growth
        for gone in set(mtimes) - current_paths:
            del mtimes[gone]

        if first_run:
            first_run = False
            continue

        if pending and (time.time() - last_event_time[0]) >= debounce_seconds:
            batch = list(pending)
            pending.clear()
            _run_incremental_pipeline(
                batch, project_path, data_dir, project_id, profile, stats
            )
            last_event_time[0] = time.time()
