#!/usr/bin/env python3
"""
Nia - Local Sync Engine

Keep your local folders and databases in sync with Nia cloud.
Real-time file watching with instant sync.

Usage:
    nia              # Start sync engine (default)
    nia login        # Authenticate with Nia
    nia status       # Show what's syncing
    nia link ID PATH # Link a source to local folder
"""
import os
import json
import time
import typer
import httpx
import logging
import webbrowser
from typing import Any
from importlib import metadata as importlib_metadata
from rich.console import Console
from rich.live import Live
from rich.markdown import Markdown
from rich.panel import Panel
from rich.table import Table

from auth import login as do_login, logout as do_logout, is_authenticated, get_api_key
from api_client import get_sources, add_source, remove_source, enable_source_sync, request_json
from ui import print_logo, print_header, spinner, success, error, warning, info, dim, NIA_LOGO_MINI
from config import (
    NIA_SYNC_DIR,
    find_folder_path,
    get_api_base_url,
    get_watch_dirs,
    add_watch_dir,
    remove_watch_dir,
    get_ignore_patterns,
    add_ignore_pattern,
    remove_ignore_pattern,
    get_config_value,
    set_config_value,
)
from sync import sync_all_sources
from extractor import (
    detect_source_type,
    extract_incremental,
    TYPE_FOLDER,
    TYPE_TELEGRAM,
    TYPE_GENERIC_DB,
    TYPE_IMESSAGE,
    TYPE_SAFARI_HISTORY,
    TYPE_CHROME_HISTORY,
    TYPE_FIREFOX_HISTORY,
)

app = typer.Typer(
    name="nia",
    help="[cyan]Nia Sync Engine[/cyan] — Keep local folders in sync with Nia cloud",
    no_args_is_help=False,
    rich_markup_mode="rich",
    epilog=f"[dim]Quick start: [cyan]nia login[/cyan] → [cyan]nia status[/cyan] → [cyan]nia[/cyan][/dim]",
)
config_app = typer.Typer(help="Manage CLI configuration")
ignore_app = typer.Typer(help="Manage local ignore patterns")
watch_app = typer.Typer(help="Manage watched directories")

app.add_typer(config_app, name="config")
app.add_typer(ignore_app, name="ignore")
app.add_typer(watch_app, name="watch")
console = Console()
logger = logging.getLogger(__name__)


@app.callback(invoke_without_command=True)
def main(ctx: typer.Context):
    """Start the sync daemon if no command specified."""
    if ctx.invoked_subcommand is None:
        # Default: start daemon mode with default values
        daemon(watch=True, fallback_interval=600, refresh_interval=30)


@app.command()
def login():
    """Authenticate with Nia using browser-based login."""
    if is_authenticated():
        warning("Already logged in.")
        dim(f"Config stored at: {NIA_SYNC_DIR}")
        _check_local_sources()
        return

    print_logo(compact=True)
    console.print("[dim]Opening browser to authenticate...[/dim]\n")

    with spinner("login", "Waiting for authentication..."):
        login_success = do_login()
    
    if login_success:
        success("Successfully logged in!")
        _check_local_sources()
    else:
        error("Login failed. Please try again.")
        raise typer.Exit(1)


KNOWN_PATHS = {
    "imessage": "~/Library/Messages/chat.db",
    "safari_history": "~/Library/Safari/History.db",
    "chrome_history": "~/Library/Application Support/Google/Chrome/Default/History",
    "firefox_history": "~/Library/Application Support/Firefox/Profiles/*/places.sqlite",
}

DB_SOURCE_TYPES = {
    TYPE_IMESSAGE,
    TYPE_SAFARI_HISTORY,
    TYPE_CHROME_HISTORY,
    TYPE_FIREFOX_HISTORY,
    TYPE_GENERIC_DB,
}


def _check_local_sources():
    """Check for indexed sources that exist locally and can be synced."""
    sources = get_sources()
    if not sources:
        return

    found_locally = []
    need_sync_enable = []

    for src in sources:
        path = src.get("path")
        detected_type = src.get("detected_type")

        # If no path but known type, try standard path
        if not path and detected_type and detected_type in KNOWN_PATHS:
            path = KNOWN_PATHS[detected_type]
            src["_detected_path"] = path

        if not path:
            continue

        expanded = os.path.expanduser(path)
        if os.path.exists(expanded):
            found_locally.append(src)
            src["_local_path"] = expanded
            if not src.get("sync_enabled", False):
                need_sync_enable.append(src)

    if found_locally:
        console.print(f"\n[green]Found {len(found_locally)} source(s) on this machine:[/green]")
        for src in found_locally:
            sync_status = "[green]✓ syncing[/green]" if src.get("sync_enabled") else "[yellow]○ not syncing[/yellow]"
            console.print(f"  • {src.get('display_name', 'Unknown')} {sync_status}")

        if need_sync_enable:
            console.print(f"\n[dim]Enabling sync for {len(need_sync_enable)} source(s)...[/dim]")
            for src in need_sync_enable:
                local_path = src.get("_local_path") or src.get("_detected_path")
                if local_path and enable_source_sync(src["local_folder_id"], local_path):
                    console.print(f"  [green]✓[/green] Enabled sync for {src.get('display_name')}")
            console.print(f"\n[dim]Run [cyan]nia[/cyan] to start syncing.[/dim]")


@app.command()
def logout():
    """Clear stored credentials."""
    do_logout()
    console.print("[green]✓ Logged out[/green]")


@app.command()
def upgrade():
    """Upgrade Nia to the latest version."""
    import subprocess
    import sys

    with spinner("upgrade", "Checking for updates...") as status:
        try:
            result = subprocess.run(
                [sys.executable, "-m", "pip", "install", "--upgrade", "nia-sync"],
                capture_output=True,
                text=True,
            )
        except Exception as e:
            pass
        else:
            e = None

    if e:
        error(f"Upgrade failed: {e}")
        dim("Try manually: pip install --upgrade nia-sync")
        raise typer.Exit(1)

    if "Successfully installed" in result.stdout:
        success("Upgraded to latest version")
    elif "Requirement already satisfied" in result.stdout:
        success("Already on latest version")
    else:
        warning("No updates available")


@app.command()
def status(
    json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON for programmatic use"),
):
    """Show sync status and configured sources."""
    if not is_authenticated():
        if json_output:
            print(json.dumps({"error": "Not logged in"}))
        else:
            error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)

    with spinner("fetch", "Loading sources..."):
        sources = get_sources()

    if json_output:
        output = []
        for source in sources:
            source_id = source.get("local_folder_id", "")
            name = source.get("display_name", "")
            path = source.get("path") or ""
            detected_type = source.get("detected_type") or "folder"

            # Determine status
            if path:
                expanded = os.path.expanduser(path)
                status_str = "ready" if os.path.exists(expanded) else "path_not_found"
            elif detected_type in KNOWN_PATHS:
                known_path = os.path.expanduser(KNOWN_PATHS[detected_type])
                if os.path.exists(known_path):
                    status_str = "ready"
                    path = KNOWN_PATHS[detected_type]
                else:
                    status_str = "not_found_locally"
            else:
                status_str = "needs_link"

            output.append({
                "id": source_id,
                "name": name,
                "path": path,
                "type": detected_type,
                "status": status_str,
            })

        print(json.dumps({"sources": output}, indent=2))
        return

    print_header("Status", "Sync status and configured sources")

    if not sources:
        console.print("[yellow]No sources configured.[/yellow]")
        console.print("\n[dim]Add sources in the Nia web app, or run:[/dim] [cyan]nia add ~/path/to/folder[/cyan]")
        return

    table = Table(show_header=True)
    table.add_column("ID", style="dim")
    table.add_column("Name", style="cyan")
    table.add_column("Path")
    table.add_column("Type", style="green")
    table.add_column("Status")

    needs_link = []
    for source in sources:
        source_id = source.get("local_folder_id", "")[:8]
        name = source.get("display_name", "")
        path = source.get("path") or ""
        detected_type = source.get("detected_type") or "folder"

        # Check if source can be synced
        if path:
            expanded = os.path.expanduser(path)
            if os.path.exists(expanded):
                status_str = "[green]✓ ready[/green]"
            else:
                status_str = "[yellow]○ path not found[/yellow]"
        else:
            # Check if it's a known type we can auto-detect
            if detected_type in KNOWN_PATHS:
                known_path = os.path.expanduser(KNOWN_PATHS[detected_type])
                if os.path.exists(known_path):
                    status_str = "[green]✓ ready[/green]"
                    path = KNOWN_PATHS[detected_type]
                else:
                    status_str = "[yellow]○ not found locally[/yellow]"
            else:
                status_str = "[red]⚠ needs link[/red]"
                needs_link.append(source)

        table.add_row(source_id, name, path or "[dim]not set[/dim]", detected_type, status_str)

    console.print(table)

    if needs_link:
        console.print(f"\n[yellow]{len(needs_link)} source(s) need to be linked to local paths.[/yellow]")
        console.print("[dim]Use:[/dim] [cyan]nia link <ID> /path/to/folder[/cyan]")
    else:
        console.print("\n[dim]Run [cyan]nia[/cyan] to start syncing • [cyan]nia link <ID> <path>[/cyan] to link[/dim]")


@app.command(name="once")
def sync():
    """Run a one-time sync (then exit)."""
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)

    with spinner("fetch", "Loading sources..."):
        sources = get_sources()
    
    if not sources:
        warning("No sources configured.")
        dim("Add sources in the Nia web app first.")
        return

    with spinner("sync", "Syncing files..."):
        results = sync_all_sources(sources)

    for result in results:
        path = result.get("path", "unknown")
        sync_status = result.get("status", "unknown")
        if sync_status == "success":
            added = result.get("added", 0)
            success(f"{path} - {added} items synced")
        else:
            err = result.get("error", "unknown error")
            error(f"{path} - {err}")


@app.command()
def add(path: str = typer.Argument(..., help="Path to sync (folder or database)")):
    """Add a new source to sync."""
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)

    # Expand path
    expanded_path = os.path.expanduser(path)

    # Check if path exists
    if not os.path.exists(expanded_path):
        error(f"Path does not exist: {expanded_path}")
        raise typer.Exit(1)

    # Detect source type
    detected_type = detect_source_type(expanded_path)
    info(f"Detected type: [cyan]{detected_type}[/cyan]")

    # Add source via API
    with spinner("connect", "Adding source..."):
        result = add_source(path, detected_type)

    if result:
        folder_id = result.get('local_folder_id', '')
        short_id = folder_id[:8] if folder_id else 'unknown'
        success(f"Added: {result.get('display_name', path)}")
        dim(f"ID: {short_id}")
        dim("Run [cyan]nia[/cyan] to start syncing.")
    else:
        error("Failed to add source.")
        raise typer.Exit(1)


@app.command()
def remove(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
    """Remove a source from syncing."""
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)

    # Expand partial ID to full UUID
    with spinner("fetch", "Loading sources..."):
        sources = get_sources()
    matching = [s for s in sources if s.get("local_folder_id", "").startswith(source_id)]

    if not matching:
        error(f"Source not found: {source_id}")
        dim("Run [cyan]nia status[/cyan] to see sources.")
        raise typer.Exit(1)

    source = matching[0]
    full_id = source["local_folder_id"]
    display_name = source.get("display_name", source_id)

    with spinner("process", "Removing source..."):
        remove_success = remove_source(full_id)

    if remove_success:
        success(f"Removed: {display_name}")
    else:
        error("Failed to remove. Check the ID with [cyan]nia status[/cyan]")
        raise typer.Exit(1)


@app.command()
def link(
    source_id: str = typer.Argument(..., help="Source ID (from 'nia status')"),
    path: str = typer.Argument(..., help="Local path to link"),
):
    """Link a cloud source to a local folder."""
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)

    expanded_path = os.path.expanduser(path)

    if not os.path.exists(expanded_path):
        error(f"Path not found: {expanded_path}")
        raise typer.Exit(1)

    with spinner("fetch", "Loading sources..."):
        sources = get_sources()
    matching = [s for s in sources if s.get("local_folder_id", "").startswith(source_id)]

    if not matching:
        error(f"Source not found: {source_id}")
        dim("Run [cyan]nia status[/cyan] to see sources.")
        raise typer.Exit(1)

    source = matching[0]
    full_id = source["local_folder_id"]

    with spinner("connect", "Linking source..."):
        link_success = enable_source_sync(full_id, expanded_path)
    
    if link_success:
        success(f"Linked: {source.get('display_name', source_id)} → {expanded_path}")
        dim("Run [cyan]nia[/cyan] to start syncing.")
    else:
        error("Failed to link.")
        raise typer.Exit(1)


def _resolve_source_prefix(source_id: str) -> tuple[dict | None, list[dict]]:
    sources = get_sources()
    matching = [s for s in sources if s.get("local_folder_id", "").startswith(source_id)]
    if not matching:
        return None, []
    exact = [s for s in matching if s.get("local_folder_id") == source_id]
    if exact:
        return exact[0], matching
    return matching[0], matching


def _require_source(source_id: str) -> dict:
    source, matching = _resolve_source_prefix(source_id)
    if not source:
        console.print(f"[red]Source not found: {source_id}[/red]")
        console.print("[dim]Run [cyan]nia status[/cyan] to see sources.[/dim]")
        raise typer.Exit(1)
    if len(matching) > 1 and source.get("local_folder_id") != source_id:
        console.print(f"[red]Ambiguous source ID prefix: {source_id}[/red]")
        for match in matching[:5]:
            console.print(f"  • {match.get('local_folder_id')[:8]} {match.get('display_name')}")
        raise typer.Exit(1)
    return source


def _resolve_source_identifier(identifier: str, sources: list[dict]) -> dict:
    if not identifier:
        raise typer.Exit(1)
    matching = [s for s in sources if s.get("local_folder_id", "").startswith(identifier)]
    if matching:
        if len(matching) > 1 and matching[0].get("local_folder_id") != identifier:
            console.print(f"[red]Ambiguous source ID prefix: {identifier}[/red]")
            for match in matching[:5]:
                console.print(f"  • {match.get('local_folder_id')[:8]} {match.get('display_name')}")
            raise typer.Exit(1)
        return matching[0]
    name_matches = [
        s for s in sources
        if str(s.get("display_name", "")).lower() == identifier.lower()
    ]
    if name_matches:
        if len(name_matches) > 1:
            console.print(f"[red]Ambiguous source name: {identifier}[/red]")
            for match in name_matches[:5]:
                console.print(f"  • {match.get('local_folder_id')[:8]} {match.get('display_name')}")
            raise typer.Exit(1)
        return name_matches[0]
    console.print(f"[red]Source not found: {identifier}[/red]")
    console.print("[dim]Run [cyan]nia status[/cyan] to see sources.[/dim]")
    raise typer.Exit(1)


@app.command()
def search(
    query: str = typer.Argument(..., help="Search query"),
    local_folder: list[str] = typer.Option(None, "--local-folder", "-l", help="Local folder ID(s) to search"),
    search_mode: str = typer.Option("unified", "--search-mode", "-m", help="Search mode: unified, sources, or repositories"),
    raw: bool = typer.Option(False, "--raw", "-r", help="Raw mode: return search results without LLM processing"),
    fast: bool = typer.Option(False, "--fast/--full", help="Fast mode (skip LLM synthesis)"),
    stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream AI response"),
    markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render response as Markdown"),
    show_sources: bool = typer.Option(False, "--sources", help="Show source snippets"),
    json_output: bool = typer.Option(False, "--json", "-j", help="Output raw JSON"),
    no_cache: bool = typer.Option(False, "--no-cache", help="Bypass semantic cache"),
    limit: int = typer.Option(10, "--limit", "-n", help="Max results to display"),
):
    """Search your indexed sources from the CLI.

    Examples:

      nia search "how does auth work"                          # AI-powered answer (streaming)

      nia search "auth" -l my-vault --raw                      # Raw vector search, no LLM, JSON output

      nia search "auth" -m sources --no-stream --sources       # Sources-only mode with citations

      nia search "auth" --fast --no-stream --json              # Fast mode, JSON output
    """
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)

    valid_modes = ("unified", "sources", "repositories")
    if search_mode not in valid_modes:
        error(f"Invalid search mode [cyan]{search_mode}[/cyan]. Must be one of: {', '.join(valid_modes)}")
        raise typer.Exit(1)
    
    # Raw mode: no LLM, no stream, always include sources
    if raw:
        stream = False
        markdown = False
        show_sources = True
        json_output = True

    # Auto-disable stream and markdown when JSON output is requested
    if json_output:
        stream = False
        markdown = False

    resolved_local_folders: list[str] = []
    if local_folder:
        with spinner("fetch", "Loading sources..."):
            sources = get_sources()
        for identifier in local_folder:
            resolved = _resolve_source_identifier(identifier, sources)
            resolved_local_folders.append(resolved.get("local_folder_id"))

    payload = {
        "messages": [{"role": "user", "content": query}],
        "local_folders": resolved_local_folders or [],
        "search_mode": search_mode,
        "stream": stream,
        "fast_mode": False if stream else fast,
        "include_sources": bool(show_sources),
        "bypass_semantic_cache": no_cache,
        "skip_llm": raw,
    }
    if stream:
        api_key = get_api_key()
        url = f"{get_api_base_url().rstrip('/')}/v2/search/query"
        sources_payload: list[dict] = []
        full_content = ""
        with httpx.Client(timeout=httpx.Timeout(600, connect=10)) as client:
            with client.stream(
                "POST",
                url,
                headers={"Authorization": f"Bearer {api_key}"},
                json=payload,
            ) as response:
                if response.status_code != 200:
                    console.print(f"[red]API error: {response.text}[/red]")
                    raise typer.Exit(1)
                if markdown:
                    with Live(Markdown(""), console=console, refresh_per_second=8) as live:
                        for line in response.iter_lines():
                            if not line or not line.startswith("data: "):
                                continue
                            data_str = line[6:]
                            if data_str.strip() == "[DONE]":
                                break
                            try:
                                event = json.loads(data_str)
                            except json.JSONDecodeError:
                                continue
                            if "error" in event:
                                console.print(f"[red]{event['error']}[/red]")
                                raise typer.Exit(1)
                            if "content" in event:
                                full_content += event["content"]
                                live.update(Markdown(full_content))
                            if "sources" in event:
                                sources_payload = event["sources"] or []
                else:
                    for line in response.iter_lines():
                        if not line or not line.startswith("data: "):
                            continue
                        data_str = line[6:]
                        if data_str.strip() == "[DONE]":
                            break
                        try:
                            event = json.loads(data_str)
                        except json.JSONDecodeError:
                            continue
                        if "error" in event:
                            console.print(f"[red]{event['error']}[/red]")
                            raise typer.Exit(1)
                        if "content" in event:
                            print(event["content"], end="", flush=True)
                        if "sources" in event:
                            sources_payload = event["sources"] or []
        print()
        if show_sources and sources_payload:
            for src in sources_payload[:limit]:
                metadata = src.get("metadata") or {}
                identifier = (
                    metadata.get("file_path")
                    or metadata.get("path")
                    or metadata.get("source")
                    or metadata.get("identifier")
                    or metadata.get("local_folder_name")
                    or "unknown"
                )
                snippet = (src.get("content") or "").strip().replace("\n", " ")
                console.print(f"- {identifier}: {snippet[:120]}")
        return

    data, error = request_json("POST", "/v2/search/query", payload=payload)
    if error:
        console.print(f"[red]{error}[/red]")
        raise typer.Exit(1)

    if json_output:
        print(json.dumps(data, indent=2))
        return

    if isinstance(data, dict):
        content = (
            data.get("content")
            or data.get("answer")
            or data.get("response")
            or ""
        )
        if isinstance(content, str) and content.strip():
            if markdown:
                console.print(Markdown(content.strip()))
            else:
                console.print(content.strip())
        else:
            console.print("[yellow]No response content.[/yellow]")

        if show_sources:
            sources = data.get("sources") or data.get("results") or []
            if isinstance(sources, list) and sources:
                for src in sources[:limit]:
                    metadata = src.get("metadata") or {}
                    identifier = (
                        metadata.get("file_path")
                        or metadata.get("path")
                        or metadata.get("source")
                        or metadata.get("identifier")
                        or metadata.get("local_folder_name")
                        or "unknown"
                    )
                    snippet = (src.get("content") or src.get("text") or "").strip().replace("\n", " ")
                    console.print(f"- {identifier}: {snippet[:120]}")
    else:
        console.print("[yellow]Unexpected search response.[/yellow]")


@app.command()
def whoami():
    """Show the current authenticated user."""
    if not is_authenticated():
        console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
        raise typer.Exit(1)
    data, error = request_json("GET", "/v2/daemon/whoami")
    if error:
        console.print(f"[red]{error}[/red]")
        raise typer.Exit(1)
    print(json.dumps(data, indent=2))


@app.command(name="open")
def open_web(
    target: str = typer.Argument("dashboard", help="dashboard|activity|api-keys|local-sync|billing|organization|docs|<source_id>"),
):
    """Open the Nia web app."""
    base_url = os.getenv("NIA_WEB_URL", "https://app.trynia.ai").rstrip("/")
    routes = {
        "dashboard": "/overview",
        "overview": "/overview",
        "activity": "/activity",
        "api-keys": "/api-keys",
        "local-sync": "/settings/local-sync",
        "billing": "/billing",
        "organization": "/settings/organization",
    }
    if target == "docs":
        url = "https://docs.trynia.ai/welcome"
    elif target in routes:
        url = f"{base_url}{routes[target]}"
    else:
        url = f"{base_url}/local-folders/{target}"
    webbrowser.open(url)
    console.print(f"[green]✓ Opened[/green] {url}")


@app.command()
def info(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
    """Show detailed info for a source."""
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)
    source = _require_source(source_id)
    local_folder_id = source.get("local_folder_id")

    with spinner("fetch", "Loading source details..."):
        folder, folder_error = request_json("GET", f"/v2/local-folders/{local_folder_id}")
    if folder_error:
        error(folder_error)
        raise typer.Exit(1)

    with spinner("fetch", "Loading sync info..."):
        sync_info, sync_error = request_json("GET", f"/v2/local-folders/{local_folder_id}/continuous-sync")
    if sync_error:
        error(sync_error)
        raise typer.Exit(1)

    console.print(f"[bold cyan]{folder.get('display_name', 'Local Folder')}[/bold cyan]")
    table = Table(show_header=False)
    table.add_row("ID", str(local_folder_id))
    table.add_row("Status", str(folder.get("status")))
    table.add_row("Type", str(folder.get("db_type") or "folder"))
    table.add_row("Chunk count", str(folder.get("chunk_count", 0)))
    table.add_row("File count", str(folder.get("file_count", 0)))
    table.add_row("Last sync", str(sync_info.get("last_synced_at")))
    table.add_row("Last error", str(sync_info.get("last_sync_error") or folder.get("continuous_sync", {}).get("last_sync_error", "")))
    console.print(table)


@app.command()
def resync(
    source_id: str | None = typer.Argument(None, help="Source ID (from 'nia status')"),
    all_sources: bool = typer.Option(False, "--all", help="Resync all sources"),
):
    """Force a full resync by resetting the cursor."""
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)

    if all_sources:
        with spinner("fetch", "Loading sources..."):
            sources = get_sources()
        if not sources:
            warning("No sources configured.")
            return
        for src in sources:
            local_folder_id = src.get("local_folder_id")
            if not local_folder_id:
                continue
            _, resync_error = request_json("POST", f"/v2/daemon/sources/{local_folder_id}/resync")
            if resync_error:
                error(f"{src.get('display_name', local_folder_id[:8])}: {resync_error}")
            else:
                success(f"Resync requested: {src.get('display_name', local_folder_id[:8])}")
        return

    if not source_id:
        error("Source ID required unless using --all.")
        raise typer.Exit(1)
    source = _require_source(source_id)
    local_folder_id = source.get("local_folder_id")
    with spinner("process", "Requesting resync..."):
        _, resync_error = request_json("POST", f"/v2/daemon/sources/{local_folder_id}/resync")
    if resync_error:
        error(resync_error)
        raise typer.Exit(1)
    success("Resync requested")


def _render_logs(items: list[dict]) -> None:
    if not items:
        console.print("[yellow]No logs found.[/yellow]")
        return
    table = Table(show_header=True)
    table.add_column("Time")
    table.add_column("Source")
    table.add_column("Status")
    table.add_column("Stats")
    table.add_column("Error")
    for item in items:
        created_at = item.get("created_at") or item.get("timestamp") or ""
        source_id = item.get("local_folder_id", "")[:8]
        status = item.get("status", "")
        stats = item.get("stats") or {}
        stats_text = ", ".join([f"{k}:{v}" for k, v in stats.items()]) if isinstance(stats, dict) else ""
        error = item.get("error") or ""
        table.add_row(str(created_at), source_id, status, stats_text, error[:80])
    console.print(table)


@app.command()
def logs(
    source_id: str | None = typer.Argument(None, help="Source ID (from 'nia status')"),
    all_sources: bool = typer.Option(False, "--all", help="Show logs for all sources"),
    errors_only: bool = typer.Option(False, "--errors", help="Only show error logs"),
    tail: bool = typer.Option(False, "--tail", help="Tail logs"),
    limit: int = typer.Option(20, "--limit", "-n", help="Max logs to show"),
    interval: int = typer.Option(3, "--interval", help="Polling interval for --tail"),
):
    """Show sync logs for local folders."""
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)

    log_status = "error" if errors_only else None

    if source_id and all_sources:
        error("Use either a source ID or --all, not both.")
        raise typer.Exit(1)

    if source_id:
        source = _require_source(source_id)
        path = f"/v2/daemon/sources/{source.get('local_folder_id')}/logs"
    else:
        path = "/v2/daemon/logs"

    def fetch_logs(since: str | None = None) -> list[dict]:
        params: dict[str, Any] = {"limit": limit}
        if log_status:
            params["status"] = log_status
        if since:
            params["since"] = since
        data, error = request_json("GET", path, params=params)
        if error or not isinstance(data, list):
            if error:
                console.print(f"[red]{error}[/red]")
            return []
        return data

    if not tail:
        _render_logs(fetch_logs())
        return

    last_seen = None
    console.print("[dim]Tailing logs... Ctrl+C to stop[/dim]")
    try:
        while True:
            logs_batch = fetch_logs(last_seen)
            if logs_batch:
                _render_logs(list(reversed(logs_batch)))
                last_seen = logs_batch[0].get("created_at")
            time.sleep(interval)
    except KeyboardInterrupt:
        console.print("\n[dim]Stopped.[/dim]")


@app.command()
def pause(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
    """Pause continuous sync for a source."""
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)
    source = _require_source(source_id)
    local_folder_id = source.get("local_folder_id")
    
    with spinner("fetch", "Loading sync settings..."):
        sync_info, sync_error = request_json("GET", f"/v2/local-folders/{local_folder_id}/continuous-sync")
    if sync_error:
        error(sync_error)
        raise typer.Exit(1)
    payload = {
        "enabled": False,
        "interval": sync_info.get("interval", "6h"),
        "watched_path": sync_info.get("watched_path"),
    }
    with spinner("process", "Pausing sync..."):
        _, pause_error = request_json("PATCH", f"/v2/local-folders/{local_folder_id}/continuous-sync", payload=payload)
    if pause_error:
        error(pause_error)
        raise typer.Exit(1)
    success("Paused")


@app.command()
def resume(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
    """Resume continuous sync for a source."""
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)
    source = _require_source(source_id)
    local_folder_id = source.get("local_folder_id")
    
    with spinner("fetch", "Loading sync settings..."):
        sync_info, sync_error = request_json("GET", f"/v2/local-folders/{local_folder_id}/continuous-sync")
    if sync_error:
        error(sync_error)
        raise typer.Exit(1)
    payload = {
        "enabled": True,
        "interval": sync_info.get("interval", "6h"),
        "watched_path": sync_info.get("watched_path"),
    }
    with spinner("process", "Resuming sync..."):
        _, resume_error = request_json("PATCH", f"/v2/local-folders/{local_folder_id}/continuous-sync", payload=payload)
    if resume_error:
        error(resume_error)
        raise typer.Exit(1)
    success("Resumed")


@app.command()
def diff(
    source_id: str | None = typer.Argument(None, help="Source ID (from 'nia status')"),
    all_sources: bool = typer.Option(False, "--all", help="Show diffs for all sources"),
    limit: int = typer.Option(200, "--limit", help="Max items to extract"),
):
    """Show what would sync without uploading."""
    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)

    with spinner("fetch", "Loading sources..."):
        sources = get_sources()
    if not sources:
        warning("No sources configured.")
        return

    if source_id and all_sources:
        error("Use either a source ID or --all, not both.")
        raise typer.Exit(1)

    targets = sources
    if source_id:
        targets = [_require_source(source_id)]

    for src in targets:
        path = src.get("path")
        if not path:
            continue
        path = os.path.expanduser(path)
        if not os.path.exists(path):
            console.print(f"[yellow]Path not found:[/yellow] {path}")
            continue
        detected_type = src.get("detected_type") or detect_source_type(path)
        cursor = src.get("cursor", {})
        result = extract_incremental(path=path, source_type=detected_type, cursor=cursor, limit=limit)
        files = result.get("files", [])
        console.print(f"[cyan]{src.get('display_name', path)}[/cyan] - {len(files)} items")
        for item in files[:50]:
            console.print(f"  • {item.get('path')}")
        if len(files) > 50:
            console.print("  [dim]... truncated[/dim]")


@app.command()
def doctor():
    """Run diagnostics for common CLI issues."""
    print_header("Doctor", "Running diagnostics...")
    
    table = Table(show_header=True)
    table.add_column("Check")
    table.add_column("Status")
    table.add_column("Details")

    with spinner("process", "Checking authentication..."):
        api_key = get_api_key()
    if api_key:
        table.add_row("Auth", "[green]OK[/green]", "API key configured")
    else:
        table.add_row("Auth", "[red]Fail[/red]", "Run `nia login`")

    with spinner("connect", "Testing API connection..."):
        data, api_error = request_json("GET", "/v2/daemon/sources")
    if api_error:
        table.add_row("API", "[red]Fail[/red]", api_error)
    else:
        table.add_row("API", "[green]OK[/green]", f"{len(data)} source(s) found")

    # Check known DB paths for access
    for key, path in KNOWN_PATHS.items():
        expanded = os.path.expanduser(path)
        if "*" in expanded:
            continue
        if not os.path.exists(expanded):
            continue
        try:
            with open(expanded, "rb") as f:
                f.read(1)
            table.add_row(f"{key} access", "[green]OK[/green]", expanded)
        except PermissionError:
            table.add_row(f"{key} access", "[red]Fail[/red]", "Grant Full Disk Access")

    watch_dirs = get_watch_dirs()
    missing = [d for d in watch_dirs if not os.path.isdir(os.path.expanduser(d))]
    if missing:
        table.add_row("Watch dirs", "[yellow]Warn[/yellow]", f"{len(missing)} missing")
    else:
        table.add_row("Watch dirs", "[green]OK[/green]", f"{len(watch_dirs)} configured")

    console.print(table)


@app.command()
def version(check: bool = typer.Option(False, "--check", help="Check for updates")):
    """Show CLI version."""
    try:
        current = importlib_metadata.version("nia-sync")
    except importlib_metadata.PackageNotFoundError:
        current = "unknown"
    console.print(f"nia-sync {current}")
    if not check:
        return
    import subprocess
    import sys
    try:
        result = subprocess.run(
            [sys.executable, "-m", "pip", "index", "versions", "nia-sync"],
            capture_output=True,
            text=True,
        )
        if result.stdout:
            console.print(result.stdout.strip())
    except Exception as e:
        console.print(f"[yellow]Update check failed: {e}[/yellow]")


@config_app.command("list")
def config_list():
    """List current config."""
    config = get_config_value("") or {}
    if not isinstance(config, dict):
        config = {}
    print(json.dumps(config, indent=2))


@config_app.command("get")
def config_get(key: str = typer.Argument(..., help="Config key")):
    value = get_config_value(key)
    print(json.dumps({key: value}, indent=2))


@config_app.command("set")
def config_set(
    key: str = typer.Argument(..., help="Config key"),
    value: str = typer.Argument(..., help="Value (JSON or string)"),
):
    try:
        parsed = json.loads(value)
    except json.JSONDecodeError:
        parsed = value
    set_config_value(key, parsed)
    console.print("[green]✓ Updated[/green]")


@ignore_app.command("list")
def ignore_list():
    patterns = get_ignore_patterns()
    print(json.dumps(patterns, indent=2))


@ignore_app.command("add")
def ignore_add(
    dir: str | None = typer.Option(None, "--dir", help="Directory name to ignore"),
    file: str | None = typer.Option(None, "--file", help="File name to ignore"),
    ext: str | None = typer.Option(None, "--ext", help="File extension to ignore"),
    path: str | None = typer.Option(None, "--path", help="Path substring to ignore"),
):
    value_map = {"dir": dir, "file": file, "ext": ext, "path": path}
    provided = [(k, v) for k, v in value_map.items() if v]
    if len(provided) != 1:
        error("Provide exactly one of --dir, --file, --ext, --path")
        raise typer.Exit(1)
    kind, value = provided[0]
    if add_ignore_pattern(kind, value):
        success("Added")
    else:
        warning("No change")


@ignore_app.command("remove")
def ignore_remove(
    dir: str | None = typer.Option(None, "--dir", help="Directory name to remove"),
    file: str | None = typer.Option(None, "--file", help="File name to remove"),
    ext: str | None = typer.Option(None, "--ext", help="File extension to remove"),
    path: str | None = typer.Option(None, "--path", help="Path substring to remove"),
):
    value_map = {"dir": dir, "file": file, "ext": ext, "path": path}
    provided = [(k, v) for k, v in value_map.items() if v]
    if len(provided) != 1:
        error("Provide exactly one of --dir, --file, --ext, --path")
        raise typer.Exit(1)
    kind, value = provided[0]
    if remove_ignore_pattern(kind, value):
        success("Removed")
    else:
        warning("No change")


@watch_app.command("list")
def watch_list():
    """List watched directories."""
    config_dirs = get_config_value("watch_dirs") or []
    all_dirs = get_watch_dirs()
    if not all_dirs:
        console.print("[yellow]No watch directories configured.[/yellow]")
        return
    for d in all_dirs:
        marker = "[cyan]custom[/cyan]" if d in config_dirs else "[dim]default[/dim]"
        console.print(f"{d} {marker}")


@watch_app.command("add")
def watch_add(path: str = typer.Argument(..., help="Directory to watch")):
    if add_watch_dir(path):
        console.print("[green]✓ Added[/green]")
    else:
        console.print("[yellow]Already present[/yellow]")


@watch_app.command("remove")
def watch_remove(path: str = typer.Argument(..., help="Directory to remove")):
    if remove_watch_dir(path):
        console.print("[green]✓ Removed[/green]")
    else:
        console.print("[yellow]Not found[/yellow]")


def _resolve_sources(sources: list[dict], log_discoveries: bool = False) -> list[dict]:
    """Resolve paths for sources, auto-detecting known types and folders. Deduplicates by path."""
    resolved = []
    seen_paths = set()
    auto_linked = []

    for src in sources:
        path = src.get("path")
        detected_type = src.get("detected_type")
        display_name = src.get("display_name", "")

        # Priority 1: Use explicit path if set
        # Priority 2: Known database types (iMessage, Safari, etc.)
        # Priority 3: Auto-discover by folder name in watch directories

        if not path and detected_type and detected_type in KNOWN_PATHS:
            path = KNOWN_PATHS[detected_type]

        if not path and display_name:
            # Try to find folder by name in ~/Documents, ~/Projects, etc.
            found_path = find_folder_path(display_name)
            if found_path:
                path = found_path
                auto_linked.append((src, found_path))

        if path:
            expanded = os.path.abspath(os.path.expanduser(path))

            if expanded in seen_paths:
                continue

            if os.path.exists(expanded):
                src["path"] = expanded
                resolved.append(src)
                seen_paths.add(expanded)

    # Auto-enable sync for discovered folders (call API to persist the path)
    for src, found_path in auto_linked:
        if src.get("path") == found_path and not src.get("sync_enabled"):
            if enable_source_sync(src["local_folder_id"], found_path):
                if log_discoveries:
                    console.print(f"  [green]✓ Auto-discovered:[/green] {src.get('display_name')} → {found_path}")

    return resolved


def _get_watched_files(source: dict) -> set[str] | None:
    path = source.get("path")
    detected_type = source.get("detected_type")
    if not path or not detected_type:
        return None
    if detected_type in DB_SOURCE_TYPES:
        expanded = os.path.abspath(os.path.expanduser(path))
        watched = {expanded, f"{expanded}-wal", f"{expanded}-shm"}
        return watched
    return None


@app.command(name="start", hidden=True)
def daemon(
    watch: bool = typer.Option(True, "--watch/--poll", help="File watching (default) or polling"),
    fallback_interval: int = typer.Option(600, "--fallback", "-f", help="Fallback poll interval (seconds)"),
    refresh_interval: int = typer.Option(30, "--refresh", "-r", help="Source refresh interval (seconds)"),
):
    """Start the Nia Sync Engine."""
    import time
    import signal
    import threading
    from sync import sync_source

    if not is_authenticated():
        error("Not logged in. Run [cyan]nia login[/cyan] first.")
        raise typer.Exit(1)
    
    # Show logo on daemon start
    print_logo(compact=True)

    running = True
    pending_syncs: set[str] = set()  # source_ids pending sync
    sync_lock = threading.Lock()
    sources_by_id: dict[str, dict] = {}
    last_sync_times: dict[str, float] = {}
    last_heartbeat_time = 0.0
    heartbeat_interval = 30

    def handle_signal(signum, frame):
        nonlocal running
        console.print("\n[dim]Stopping...[/dim]")
        running = False

    signal.signal(signal.SIGINT, handle_signal)
    signal.signal(signal.SIGTERM, handle_signal)

    def on_source_changed(source_id: str):
        """Called by file watcher when changes detected."""
        with sync_lock:
            pending_syncs.add(source_id)

    def sync_pending_sources():
        """Process any pending syncs."""
        with sync_lock:
            to_sync = list(pending_syncs)
            pending_syncs.clear()

        if not to_sync:
            return

        # Only log if we're syncing
        total_added = 0
        errors = []

        for source_id in to_sync:
            if source_id not in sources_by_id:
                continue

            src = sources_by_id[source_id]
            result = sync_source(src)

            status = result.get("status", "unknown")

            if status == "success":
                added = result.get("added", 0)
                if added > 0:
                    total_added += added
                    console.print(f"[green]✓ {src.get('display_name', 'Unknown')}[/green] - {added} items synced")
                last_sync_times[source_id] = time.time()
            else:
                error = result.get("error", "unknown error")
                errors.append(f"{src.get('display_name', 'Unknown')}: {error}")

        # Log errors
        for err in errors:
            console.print(f"[red]✗ {err}[/red]")

    def refresh_sources(watcher=None, log_discoveries: bool = False) -> tuple[list[dict], list[str]]:
        """Refresh sources from API and update watchers.

        Returns:
            Tuple of (resolved_sources, newly_added_source_ids)
        """
        nonlocal sources_by_id

        sources = get_sources()
        resolved = _resolve_sources(sources, log_discoveries=log_discoveries)

        # Update sources dict
        new_sources_by_id = {src["local_folder_id"]: src for src in resolved}
        newly_added = []

        # Add watchers for new sources
        if watcher:
            current_watching = set(watcher.watching)
            new_source_ids = set(new_sources_by_id.keys())

            # Add new watchers
            for source_id in new_source_ids - current_watching:
                src = new_sources_by_id[source_id]
                watched_files = _get_watched_files(src)
                if watcher.watch(source_id, src["path"], on_source_changed, watched_files=watched_files):
                    console.print(f"  [dim]+ Watching {src.get('display_name', 'Unknown')}[/dim]")
                    newly_added.append(source_id)

            # Remove old watchers (source deleted from UI)
            for source_id in current_watching - new_source_ids:
                old_name = sources_by_id.get(source_id, {}).get("display_name", source_id[:8])
                try:
                    watcher.unwatch(source_id)
                    console.print(f"  [dim]- Stopped watching {old_name}[/dim]")
                except Exception as e:
                    logger.warning(f"Failed to unwatch {old_name}: {e}")

        sources_by_id = new_sources_by_id
        return resolved, newly_added

    # Mode selection
    if watch:
        try:
            from watcher import FileWatcher, DirectoryWatcher
            watcher = FileWatcher(debounce_sec=2.0)
            dir_watcher = DirectoryWatcher()
        except ImportError:
            console.print("[yellow]watchdog not installed, falling back to polling mode[/yellow]")
            watch = False
            watcher = None
            dir_watcher = None
    else:
        watcher = None
        dir_watcher = None

    # Initial setup - log any auto-discovered folders
    resolved, _ = refresh_sources(watcher, log_discoveries=True)

    # Track unlinked source names for instant folder detection
    def get_unlinked_names() -> set[str]:
        """Get display names of sources without a local path."""
        sources = get_sources()
        return {
            s.get("display_name", "").lower()
            for s in sources
            if not s.get("path") and s.get("display_name")
        }

    unlinked_names = get_unlinked_names()
    refresh_triggered = threading.Event()

    def on_new_folder(folder_name: str, folder_path: str):
        """Called when a new folder is created in watched directories."""
        if folder_name.lower() in unlinked_names:
            console.print(f"[cyan]Detected new folder:[/cyan] {folder_name}")
            refresh_triggered.set()

    if watch and watcher:
        mode_text = "real-time file watching"

        # Start directory watcher for instant folder detection
        if dir_watcher:
            dir_watcher.watch(get_watch_dirs(), on_new_folder)
            dir_watcher.start()

        console.print(Panel.fit(
            f"[bold cyan]Nia Sync Engine[/bold cyan]\n\n"
            f"[dim]●[/dim] {mode_text}\n"
            f"[dim]●[/dim] {len(resolved)} source(s) active\n"
            f"[dim]●[/dim] Auto-refresh every {refresh_interval}s\n\n"
            f"[dim]Ctrl+C to stop[/dim]",
            border_style="cyan",
        ))

        # Start file watcher
        watcher.start()

        # Do initial sync
        for src in resolved:
            pending_syncs.add(src["local_folder_id"])
        sync_pending_sources()

        # Main loop: process pending syncs + periodic refresh
        last_refresh = time.time()

        while running:
            # Process any pending syncs from file watcher
            sync_pending_sources()

            # Heartbeat to backend to mark daemon online
            now = time.time()
            if now - last_heartbeat_time >= heartbeat_interval:
                _send_heartbeat(list(sources_by_id.keys()))
                last_heartbeat_time = now

            # Sanity sync to catch missed events
            if fallback_interval > 0:
                for source_id in list(sources_by_id.keys()):
                    last_sync = last_sync_times.get(source_id, 0)
                    if now - last_sync >= fallback_interval:
                        pending_syncs.add(source_id)

            # Instant refresh if new folder detected matching an unlinked source
            if refresh_triggered.is_set():
                refresh_triggered.clear()
                _, newly_added = refresh_sources(watcher, log_discoveries=True)
                if newly_added:
                    console.print(f"[green]Linked {len(newly_added)} new source(s)[/green]")
                    # Trigger initial sync for new sources
                    for source_id in newly_added:
                        pending_syncs.add(source_id)
                unlinked_names.clear()
                unlinked_names.update(get_unlinked_names())
                last_refresh = time.time()

            # Periodic refresh to pick up new sources from web UI
            elif time.time() - last_refresh > refresh_interval:
                _, newly_added = refresh_sources(watcher, log_discoveries=True)
                if newly_added:
                    console.print(f"[green]Found {len(newly_added)} new source(s)[/green]")
                    # Trigger initial sync for new sources
                    for source_id in newly_added:
                        pending_syncs.add(source_id)
                unlinked_names.clear()
                unlinked_names.update(get_unlinked_names())
                last_refresh = time.time()

            time.sleep(0.5)

        # Cleanup
        watcher.stop()
        if dir_watcher:
            dir_watcher.stop()

    else:
        # Polling mode (fallback)
        console.print(Panel.fit(
            f"[bold cyan]Nia Sync Engine[/bold cyan] [dim](polling)[/dim]\n\n"
            f"[dim]●[/dim] Sync every {fallback_interval // 60} min\n"
            f"[dim]●[/dim] {len(resolved)} source(s) active\n\n"
            f"[dim]Ctrl+C to stop[/dim]",
            border_style="cyan",
        ))

        sync_count = 0
        while running:
            resolved, _ = refresh_sources()
            _send_heartbeat([src["local_folder_id"] for src in resolved])

            sync_count += 1
            console.print(f"\n[bold]Sync #{sync_count}[/bold] - {len(resolved)} source(s)")

            if not resolved:
                console.print("[dim]No syncable sources found locally.[/dim]")
            else:
                results = sync_all_sources(resolved)
                for result in results:
                    path = result.get("path", "unknown")
                    status = result.get("status", "unknown")
                    if status == "success":
                        added = result.get("added", 0)
                        if added > 0:
                            console.print(f"  [green]✓ {path}[/green] - {added} items")
                        else:
                            console.print(f"  [dim]- {path}[/dim] - no changes")
                    else:
                        error = result.get("error", "unknown error")
                        console.print(f"  [red]✗ {path}[/red] - {error}")

            for _ in range(fallback_interval):
                if not running:
                    break
                time.sleep(1)

    console.print("[green]✓ Stopped[/green]")


def _send_heartbeat(source_ids: list[str]) -> None:
    if not source_ids:
        return
    api_key = get_api_key()
    if not api_key:
        return
    try:
        with httpx.Client(timeout=10) as client:
            client.post(
                f"{get_api_base_url()}/v2/daemon/heartbeat",
                headers={"Authorization": f"Bearer {api_key}"},
                json={"source_ids": source_ids},
            )
    except Exception:
        logger.debug("Heartbeat failed", exc_info=True)


if __name__ == "__main__":
    app()
