"""
cortex/cli.py — evaos: CLI tool for querying and managing Cortex memories.

Entry point: ``evaos`` command (registered in pyproject.toml).

Architecture:
    All commands share a ``--db`` option (default: ./cortex.db) and an optional
    ``--config`` option pointing to a cortex.toml file. Each command instantiates
    CortexPlugin synchronously via asyncio.run(), keeping the CLI simple.

    Output formatting:
        - Uses ``rich`` when available for tables/panels/colours.
        - Falls back to plain-text when rich is not installed.

Commands:
    evaos init [--profile companion|coding|enterprise]
    evaos health
    evaos remember "text"
    evaos recall "query"
    evaos ask "What does the user prefer?" [--owner-id X]
    evaos cornerstones list
    evaos cornerstones seal --label "name" --content "text"
    evaos dream
    evaos stats
    evaos export [--format json|sql]
    evaos serve [--port 8000]
    evaos mcp
    evaos backup [--output path]

Dependencies: click (required), rich (optional), fastapi + uvicorn (optional for serve).
"""
from __future__ import annotations

import asyncio
import json
import logging
import os
import shutil
import sys

logger = logging.getLogger(__name__)
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, List, Optional

import click

# ---------------------------------------------------------------------------
# Rich helpers (graceful fallback to plain text)
# ---------------------------------------------------------------------------

try:
    from rich.console import Console
    from rich.table import Table
    from rich import print as rprint
    _RICH = True
    _console = Console()
except ImportError:
    _RICH = False
    _console = None  # type: ignore


def _print(msg: str) -> None:
    """Print a message — uses rich if available."""
    if _RICH:
        _console.print(msg)  # type: ignore[union-attr]
    else:
        click.echo(msg)


def _ok(msg: str) -> None:
    _print(f"[green]✓[/green] {msg}" if _RICH else f"✓ {msg}")


def _err(msg: str) -> None:
    _print(f"[red]✗[/red] {msg}" if _RICH else f"✗ {msg}")


def _warn(msg: str) -> None:
    _print(f"[yellow]⚠[/yellow]  {msg}" if _RICH else f"⚠  {msg}")


def _section(title: str) -> None:
    if _RICH:
        _console.rule(f"[bold]{title}[/bold]")  # type: ignore[union-attr]
    else:
        click.echo(f"\n── {title} ──")


def _kv_table(rows: list[tuple[str, str]], title: str = "") -> None:
    """Print key/value pairs as a table."""
    if _RICH:
        table = Table(title=title or None, show_header=True, header_style="bold cyan")
        table.add_column("Key", style="dim")
        table.add_column("Value")
        for k, v in rows:
            table.add_row(str(k), str(v))
        _console.print(table)  # type: ignore[union-attr]
    else:
        if title:
            click.echo(f"\n{title}")
        for k, v in rows:
            click.echo(f"  {k}: {v}")


# ---------------------------------------------------------------------------
# Async helper
# ---------------------------------------------------------------------------

def _run(coro: Any) -> Any:
    """Run an async coroutine from sync click command."""
    return asyncio.run(coro)


# ---------------------------------------------------------------------------
# Plugin factory
# ---------------------------------------------------------------------------

def _make_plugin(db: str, config: Optional[str]) -> Any:
    """Instantiate CortexPlugin (lazy import — optional heavy deps)."""
    try:
        from cortex.api.plugin import CortexPlugin
    except ImportError as exc:
        _err(f"Cannot import CortexPlugin: {exc}")
        sys.exit(1)
    return CortexPlugin(config_path=config, db_path=db)


async def _init_plugin(db: str, config: Optional[str]) -> Any:
    """Create plugin and initialize storage (async-ready for CLI commands)."""
    plugin = _make_plugin(db, config)
    if plugin.storage is not None:
        await plugin.storage.initialize()
    return plugin


# ---------------------------------------------------------------------------
# Profile templates
# ---------------------------------------------------------------------------

_PROFILES: dict[str, dict[str, Any]] = {
    "companion": {
        "description": "Warm, conversational companion focused on long-term memory",
        "extraction_model": "haiku",
        "token_budgets_cornerstone": 1200,
        "token_budgets_retrieval": 4000,
        "token_budgets_total_memory": 5500,
        "drift_check_threshold": 0.2,
    },
    "coding": {
        "description": "Engineering copilot focused on code patterns and decisions",
        "extraction_model": "haiku",
        "token_budgets_cornerstone": 600,
        "token_budgets_retrieval": 6000,
        "token_budgets_total_memory": 7000,
        "drift_check_threshold": 0.4,
        "extraction_coding_noise_filter": False,
    },
    "enterprise": {
        "description": "High-fidelity enterprise deployment with strict identity protection",
        "extraction_model": "sonnet",
        "token_budgets_cornerstone": 2000,
        "token_budgets_retrieval": 8000,
        "token_budgets_total_memory": 10000,
        "drift_check_threshold": 0.1,
        "drift_restore_threshold": 0.3,
    },
}

_TOML_TEMPLATE = """\
# cortex.toml — generated by evaos init
# Profile: {profile}
# {description}

[cortex]
version = "3.0"
storage_backend = "sqlite"
owner_id = "default"

[cortex.storage]
db_path = "{db_path}"
enable_wal = true
busy_timeout_ms = 5000

[cortex.extraction]
model = "{extraction_model}"
coding_noise_filter = {coding_noise_filter}

[cortex.token_budgets]
cornerstone = {token_budgets_cornerstone}
retrieval = {token_budgets_retrieval}
total_memory = {token_budgets_total_memory}

[cortex.drift]
check_threshold = {drift_check_threshold}
restore_threshold = {drift_restore_threshold}
"""


# ---------------------------------------------------------------------------
# CLI root
# ---------------------------------------------------------------------------

@click.group()
@click.option("--db", default=None,
              envvar="CORTEX_DB", help="Path to Cortex SQLite database (default: from config, or ./cortex.db).")
@click.option("--config", default=None, envvar="CORTEX_CONFIG",
              help="Path to cortex.toml config file.")
@click.option("--verbose", "-v", is_flag=True, default=False,
              help="Enable verbose output (sets log level to DEBUG).")
@click.option("--log-level", "log_level",
              type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
              default=None,
              help="Set logging level (overrides --verbose).")
@click.pass_context
def main(ctx: click.Context, db: str, config: Optional[str],
         verbose: bool, log_level: Optional[str]) -> None:
    """evaos — Cortex memory CLI.

    Query, manage, and operate Cortex cognitive memory from the command line.

    Common options (available on all commands):

    \b
        --db PATH       SQLite database path (default: ./cortex.db)
        --config PATH   cortex.toml config file
        -v, --verbose   Enable verbose/debug output
        --log-level LVL Set logging level (DEBUG, INFO, WARNING, ERROR)

    Environment variables: CORTEX_DB, CORTEX_CONFIG
    """
    ctx.ensure_object(dict)
    ctx.obj["db"] = db

    # Configure logging based on --verbose / --log-level
    if log_level is not None:
        level = getattr(logging, log_level.upper(), logging.INFO)
    elif verbose:
        level = logging.DEBUG
    else:
        level = logging.WARNING
    logging.basicConfig(level=level, format="%(levelname)s: %(name)s: %(message)s")

    # Auto-detect cortex.toml in cwd if --config not specified
    if config is None and Path("cortex.toml").exists():
        config = "cortex.toml"
    ctx.obj["config"] = config


# ---------------------------------------------------------------------------
# evaos init
# ---------------------------------------------------------------------------

@main.command("init")
@click.option("--profile", default="companion",
              type=click.Choice(["companion", "coding", "enterprise"]),
              show_default=True, help="Configuration profile to use.")
@click.pass_context
def cmd_init(ctx: click.Context, profile: str) -> None:
    """Initialise Cortex — create cortex.toml and cortex.db.

    Writes a cortex.toml with sensible defaults for the chosen profile and
    initialises the SQLite database schema.
    """
    db_path = ctx.obj["db"]
    config_path = ctx.obj["config"] or "cortex.toml"

    p = _PROFILES[profile]

    toml_content = _TOML_TEMPLATE.format(
        profile=profile,
        description=p["description"],
        db_path=db_path,
        extraction_model=p.get("extraction_model", "haiku"),
        coding_noise_filter=str(p.get("extraction_coding_noise_filter", True)).lower(),
        token_budgets_cornerstone=p.get("token_budgets_cornerstone", 800),
        token_budgets_retrieval=p.get("token_budgets_retrieval", 3000),
        token_budgets_total_memory=p.get("token_budgets_total_memory", 4000),
        drift_check_threshold=p.get("drift_check_threshold", 0.3),
        drift_restore_threshold=p.get("drift_restore_threshold", 0.5),
    )

    config_file = Path(config_path)
    if config_file.exists():
        _warn(f"Config already exists: {config_file} — skipping overwrite.")
    else:
        config_file.write_text(toml_content)
        _ok(f"Config written: {config_file}")

    # Initialise DB schema via CortexPlugin (storage adapter creates tables on connect)
    plugin = _make_plugin(db_path, config_path if config_file.exists() else None)

    async def _init_db() -> None:
        if plugin.storage is not None:
            await plugin.storage.initialize()
        _ok(f"Database initialised: {db_path}")
        _print(f"\n[bold]Profile:[/bold] {profile}" if _RICH else f"\nProfile: {profile}")
        _print(f"[dim]{p['description']}[/dim]" if _RICH else p["description"])
        _print("\nRun [bold]evaos health[/bold] to verify the installation." if _RICH
               else "\nRun 'evaos health' to verify the installation.")

    _run(_init_db())


# ---------------------------------------------------------------------------
# evaos health
# ---------------------------------------------------------------------------

@main.command("health")
@click.pass_context
def cmd_health(ctx: click.Context) -> None:
    """Check Cortex system health — storage, modules, connectivity."""
    plugin = _make_plugin(ctx.obj["db"], ctx.obj["config"])

    async def _check() -> None:
        if plugin.storage:
            await plugin.storage.initialize()
        report = await plugin.health()

        rows: list[tuple[str, str]] = [
            ("Overall", "✓ OK" if report.ok else "✗ DEGRADED"),
            ("Storage", report.storage),
            ("Timestamp", report.timestamp),
        ]
        for mod, status in report.modules.items():
            rows.append((f"Module: {mod}", status))
        if report.errors:
            for e in report.errors:
                rows.append(("Error", e))

        _kv_table(rows, title="Cortex Health")

        if not report.ok:
            sys.exit(1)

    _run(_check())


# ---------------------------------------------------------------------------
# evaos remember
# ---------------------------------------------------------------------------

@main.command("remember")
@click.argument("text")
@click.option("--session", default=None, help="Session ID (auto-generated if omitted).")
@click.pass_context
def cmd_remember(ctx: click.Context, text: str, session: Optional[str]) -> None:
    """Store TEXT as a memory.

    TEXT is wrapped in a user/assistant conversation turn before being sent
    through the Cortex extraction pipeline.

    Example:

    \b
        evaos remember "Andrew prefers dark mode in all editors"
    """
    plugin = _make_plugin(ctx.obj["db"], ctx.obj["config"])
    sid = session or f"cli-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}"

    messages = [
        {"role": "user", "content": text},
        {"role": "assistant", "content": "Understood, I'll remember that."},
    ]

    async def _store() -> None:
        if plugin.storage:
            await plugin.storage.initialize()
        await plugin.wake(session_id=sid)
        result = await plugin.remember(messages, session_id=sid)
        rows = [
            ("Session", result.session_id),
            ("Claims extracted", str(result.claims_extracted)),
            ("Claims stored", str(result.claims_stored)),
            ("Entities resolved", str(result.entities_resolved)),
            ("Noise filtered", str(result.noise_filtered)),
            ("OK", str(result.ok)),
        ]
        if result.errors:
            for e in result.errors:
                rows.append(("Warning", e))
        _kv_table(rows, title="Remember Result")
        if result.ok:
            _ok(f"Memory stored ({result.claims_stored} claim(s))")
        else:
            _warn("Memory pipeline completed with errors")

    _run(_store())


# ---------------------------------------------------------------------------
# evaos recall
# ---------------------------------------------------------------------------

@main.command("recall")
@click.argument("query")
@click.option("--limit", default=5, show_default=True, help="Max results to display.")
@click.pass_context
def cmd_recall(ctx: click.Context, query: str, limit: int) -> None:
    """Retrieve memories matching QUERY.

    Example:

    \b
        evaos recall "editor preferences"
    """
    plugin = _make_plugin(ctx.obj["db"], ctx.obj["config"])

    async def _retrieve() -> None:
        if plugin.storage:
            await plugin.storage.initialize()
        result = await plugin.retrieve(query)

        # RetrievalResult has .items (list of RetrievedItem)
        memories = getattr(result, "items", None) or getattr(result, "memories", None) or getattr(result, "claims", None) or []
        context_block = getattr(result, "context_block", None)
        token_count = getattr(result, "token_count", None)

        if not memories and context_block:
            # Some versions return a formatted text block
            _section("Retrieved Context")
            _print(context_block)
            if token_count is not None:
                _print(f"\n[dim]Tokens: {token_count}[/dim]" if _RICH
                       else f"\nTokens: {token_count}")
            return

        if not memories:
            _warn("No memories found for query.")
            return

        # Print as table
        if _RICH:
            table = Table(title=f'Recall: "{query}"', show_header=True,
                          header_style="bold cyan")
            table.add_column("#", style="dim", width=4)
            table.add_column("Memory")
            table.add_column("Score", width=8)
            for i, m in enumerate(memories[:limit], 1):
                content = (getattr(m, "content", None)
                           or getattr(m, "object_value", None)
                           or str(m))
                score = getattr(m, "score", getattr(m, "relevance", "—"))
                table.add_row(str(i), str(content), f"{score:.3f}" if isinstance(score, float) else str(score))
            _console.print(table)  # type: ignore[union-attr]
        else:
            click.echo(f'\nRecall: "{query}"')
            for i, m in enumerate(memories[:limit], 1):
                content = (getattr(m, "content", None)
                           or getattr(m, "object_value", None)
                           or str(m))
                click.echo(f"  {i}. {content}")

        if token_count is not None:
            _print(f"\n[dim]Tokens used: {token_count}[/dim]" if _RICH
                   else f"\nTokens used: {token_count}")

    _run(_retrieve())


# ---------------------------------------------------------------------------
# evaos ask — Dialectic Engine fast path (M14)
# ---------------------------------------------------------------------------

@main.command("ask")
@click.argument("query")
@click.option(
    "--owner-id",
    default=None,
    help="Memory owner namespace (defaults to config owner_id).",
)
@click.option(
    "--agentic",
    is_flag=True,
    default=False,
    help=(
        "Use the agentic path: multi-step retrieval loop for complex questions. "
        "Decomposes the question, retrieves per sub-query, and synthesises a final answer."
    ),
)
@click.option(
    "--max-steps",
    default=5,
    show_default=True,
    type=int,
    help="Maximum retrieval steps for --agentic mode.",
)
@click.pass_context
def cmd_ask(
    ctx: click.Context,
    query: str,
    owner_id: Optional[str],
    agentic: bool,
    max_steps: int,
) -> None:
    """Ask a question and get an answer synthesised from memory.

    Uses the Dialectic Engine (M14): retrieves relevant memories then calls
    an LLM to compose a grounded answer.

    Two modes:
        Fast path (default) — single-shot retrieval + synthesis.
        Agentic path (--agentic) — multi-step loop for complex questions.

    Example:

    \b
        evaos ask "What does the user prefer?"
        evaos ask "What is the user working on?" --owner-id user-42
        evaos ask "Summarise everything about this user" --agentic
        evaos ask "Complex question" --agentic --max-steps 8
    """
    plugin = _make_plugin(ctx.obj["db"], ctx.obj["config"])
    mode = "agentic" if agentic else "fast"

    async def _ask() -> None:
        if plugin.storage:
            await plugin.storage.initialize()

        result = await plugin.ask(
            query=query,
            owner_id=owner_id,
            mode=mode,
            max_steps=max_steps,
        )

        if _RICH:
            from rich.panel import Panel
            from rich.text import Text

            # Answer panel
            mode_label = "agentic" if agentic else "fast"
            _console.print(  # type: ignore[union-attr]
                Panel(
                    result.answer,
                    title=f"[bold cyan]Answer[/bold cyan] [dim]({mode_label})[/dim]",
                    subtitle=f"confidence: {result.confidence:.2f}",
                    border_style="cyan",
                )
            )

            # Sources table
            if result.sources:
                table = Table(title="Sources", show_header=True, header_style="bold dim")
                table.add_column("#", style="dim", width=4)
                table.add_column("Memory")
                table.add_column("Score", width=8)
                for i, src in enumerate(result.sources, 1):
                    subj = src.get("subject", "")
                    pred = src.get("predicate", "")
                    obj = src.get("object_value", "")
                    content = f"{subj} {pred} {obj}".strip() or src.get("content", str(src))
                    score = src.get("score") or src.get("confidence")
                    score_str = f"{score:.3f}" if isinstance(score, float) else str(score or "—")
                    table.add_row(str(i), content, score_str)
                _console.print(table)  # type: ignore[union-attr]

            if result.reasoning:
                _print(f"\n[dim]{result.reasoning}[/dim]")

        else:
            click.echo(f'\nAnswer: {result.answer}')
            click.echo(f'Confidence: {result.confidence:.2f}')
            if result.sources:
                click.echo(f'\nSources ({len(result.sources)}):')
                for i, src in enumerate(result.sources, 1):
                    subj = src.get("subject", "")
                    pred = src.get("predicate", "")
                    obj = src.get("object_value", "")
                    content = f"{subj} {pred} {obj}".strip() or src.get("content", str(src))
                    click.echo(f"  {i}. {content}")

        if not result.ok:
            for err in result.errors:
                _err(err)

    _run(_ask())


# ---------------------------------------------------------------------------
# evaos cornerstones (subgroup)
# ---------------------------------------------------------------------------

@main.group("cornerstones")
@click.pass_context
def cornerstones(ctx: click.Context) -> None:
    """Manage cornerstone memories — the sealed identity anchors."""


@cornerstones.command("list")
@click.pass_context
def cornerstones_list(ctx: click.Context) -> None:
    """List all sealed cornerstone memories."""
    plugin = _make_plugin(ctx.obj["db"], ctx.obj["config"])

    async def _list() -> None:
        if plugin.storage:
            await plugin.storage.initialize()
        items = await plugin.get_cornerstones()
        if not items:
            _warn("No cornerstones found.")
            return

        if _RICH:
            table = Table(title="Cornerstones", show_header=True,
                          header_style="bold magenta")
            table.add_column("ID", style="dim", max_width=12)
            table.add_column("Label")
            table.add_column("Content", max_width=60)
            table.add_column("Sealed at", width=20)
            for cs in items:
                cs_id = str(getattr(cs, "id", getattr(cs, "cs_id", "?")))[:12]
                label = getattr(cs, "label", "")
                content = getattr(cs, "content", "")[:80]
                sealed = str(getattr(cs, "sealed_at", ""))
                table.add_row(cs_id, label, content, sealed)
            _console.print(table)  # type: ignore[union-attr]
        else:
            click.echo(f"\nCornerstones ({len(items)}):")
            for cs in items:
                label = getattr(cs, "label", "")
                content = getattr(cs, "content", "")[:80]
                click.echo(f"  [{label}] {content}")

    _run(_list())


@cornerstones.command("seal")
@click.option("--label", required=True, help="Short label for this cornerstone.")
@click.option("--content", required=True, help="Cornerstone content text.")
@click.pass_context
def cornerstones_seal(ctx: click.Context, label: str, content: str) -> None:
    """Seal a new cornerstone memory.

    Example:

    \b
        evaos cornerstones seal --label "identity" --content "I am Eva, engineering AI"
    """
    plugin = _make_plugin(ctx.obj["db"], ctx.obj["config"])

    async def _seal() -> None:
        if plugin.storage:
            await plugin.storage.initialize()
        cs = await plugin.seal_cornerstone(label=label, content=content)
        cs_id = getattr(cs, "id", getattr(cs, "cs_id", "?"))
        rows = [
            ("ID", str(cs_id)),
            ("Label", label),
            ("Content", content[:100]),
            ("Status", "sealed"),
        ]
        _kv_table(rows, title="Cornerstone Sealed")
        _ok(f"Cornerstone sealed: {label}")

    _run(_seal())


# ---------------------------------------------------------------------------
# evaos dream
# ---------------------------------------------------------------------------

@main.command("dream")
@click.option("--session", default=None, help="Session ID to close before dreaming.")
@click.pass_context
def cmd_dream(ctx: click.Context, session: Optional[str]) -> None:
    """Trigger a dream cycle — consolidate memories and run curiosity engine."""
    plugin = _make_plugin(ctx.obj["db"], ctx.obj["config"])
    sid = session or f"cli-dream-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}"

    async def _dream() -> None:
        if plugin.storage:
            await plugin.storage.initialize()
        _print("[cyan]Starting dream cycle…[/cyan]" if _RICH else "Starting dream cycle…")
        # Wake first to set up session context, then sleep to trigger consolidation
        wake_report = await plugin.wake(session_id=sid)
        _ok(f"Session open: {wake_report.session_id} "
            f"({wake_report.cornerstones_loaded} cornerstone(s) loaded)")

        sleep_report = await plugin.sleep(session_id=sid)
        rows = [
            ("Session", sleep_report.session_id),
            ("Memories summarised", str(sleep_report.memories_summarised)),
        ]
        if sleep_report.errors:
            for e in sleep_report.errors:
                rows.append(("Error", e))
        _kv_table(rows, title="Dream Cycle Complete")

    _run(_dream())


# ---------------------------------------------------------------------------
# evaos stats
# ---------------------------------------------------------------------------

@main.command("stats")
@click.pass_context
def cmd_stats(ctx: click.Context) -> None:
    """Show memory statistics — claim counts, cornerstones, entities, drift."""
    plugin = _make_plugin(ctx.obj["db"], ctx.obj["config"])

    async def _stats() -> None:
        if plugin.storage:
            await plugin.storage.initialize()
        health = await plugin.health()

        rows: list[tuple[str, str]] = [
            ("Health", "OK" if health.ok else "DEGRADED"),
            ("Storage backend", health.storage),
        ]

        # Pull storage-level stats
        storage = getattr(plugin, "storage", None)
        if storage is not None:
            # Try various stats method names
            for method in ("get_stats", "stats"):
                fn = getattr(storage, method, None)
                if callable(fn):
                    try:
                        raw = fn()
                        if asyncio.iscoroutine(raw):
                            raw = await raw
                        if isinstance(raw, dict):
                            for k, v in raw.items():
                                rows.append((k.replace("_", " ").title(), str(v)))
                    except Exception as e:
                        logger.warning("cortex_cli: unhandled exception: %s", e)
                    break

            # Try direct count queries via SQL
            try:
                async with storage._get_connection() as conn:  # type: ignore[attr-defined]
                    for table, label in [
                        ("semantic_claims", "Total claims"),
                        ("cornerstone_memory", "Cornerstones"),
                        ("entities", "Entities"),
                        ("sessions", "Sessions"),
                    ]:
                        try:
                            async with conn.execute(f"SELECT COUNT(*) FROM {table}") as cur:  # noqa: S608
                                row = await cur.fetchone()
                                if row:
                                    rows.append((label, str(row[0])))
                        except Exception as e:
                            logger.warning("cortex_cli: unhandled exception: %s", e)

                    # Last dream timestamp
                    try:
                        async with conn.execute(
                            "SELECT ended_at FROM sessions WHERE status='sleeping' "
                            "ORDER BY ended_at DESC LIMIT 1"
                        ) as cur:
                            row = await cur.fetchone()
                            rows.append(("Last dream", str(row[0]) if row else "never"))
                    except Exception as e:
                        logger.warning("cortex_cli: unhandled exception: %s", e)
            except Exception as e:
                logger.warning("cortex_cli: unhandled exception: %s", e)

            # Cornerstone drift scores
            try:
                cornerstones = await plugin.get_cornerstones()
                rows.append(("Cornerstones (sealed)", str(len(cornerstones))))
                drifted = [
                    cs for cs in cornerstones
                    if getattr(cs, "drift_score", 0) and getattr(cs, "drift_score", 0) > 0.1
                ]
                if drifted:
                    _warn(f"{len(drifted)} cornerstone(s) with elevated drift")
                    for cs in drifted:
                        rows.append((
                            f"  Drift [{getattr(cs, 'label', '?')}]",
                            f"{getattr(cs, 'drift_score', '?'):.3f}",
                        ))
            except Exception as e:
                logger.warning("cortex_cli: unhandled exception: %s", e)

        _kv_table(rows, title="Cortex Stats")

    _run(_stats())


# ---------------------------------------------------------------------------
# evaos export
# ---------------------------------------------------------------------------

@main.command("export")
@click.option("--format", "fmt", default="json",
              type=click.Choice(["json", "sql", "sqlite"]), show_default=True,
              help="Export format.")
@click.option("--output", "-o", default="-", show_default=True,
              help="Output file path (default: stdout).")
@click.pass_context
def cmd_export(ctx: click.Context, fmt: str, output: str) -> None:
    """Export all memories as JSON, SQL INSERT statements, or SQLite database copy.

    Example:

    \b
        evaos export --format json --output memories.json
        evaos export --format sql | gzip > backup.sql.gz
        evaos export --format sqlite --output backup.db
    """
    plugin = _make_plugin(ctx.obj["db"], ctx.obj["config"])

    async def _export() -> None:
        if plugin.storage:
            await plugin.storage.initialize()
        storage = getattr(plugin, "storage", None)
        if storage is None:
            _err("Storage not available")
            sys.exit(1)

        # SQLite binary export — uses backup API, no claim iteration needed
        if fmt == "sqlite":
            if output == "-":
                _err("SQLite export requires --output path (cannot write binary to stdout)")
                sys.exit(1)
            from cortex.tools.export_import import export_sqlite
            try:
                result_path = await export_sqlite(storage, output_path=output)
                size_kb = Path(result_path).stat().st_size // 1024
                _ok(f"SQLite database exported to {result_path} ({size_kb} KB)")
            except Exception as exc:
                _err(f"SQLite export failed: {exc}")
                sys.exit(1)
            return

        claims: List[dict] = []

        try:
            async with storage._get_connection() as conn:  # type: ignore[attr-defined]
                async with conn.execute("SELECT * FROM semantic_claims") as cur:
                    column_names = [d[0] for d in cur.description]
                    async for row in cur:
                        claims.append(dict(zip(column_names, row)))
        except Exception as exc:
            _err(f"Failed to read claims: {exc}")
            sys.exit(1)

        if fmt == "json":
            text = json.dumps(claims, indent=2, default=str)
        else:  # sql
            lines = [f"-- Cortex export — {datetime.now(timezone.utc).isoformat()}",
                     "-- Format: SQL INSERT"]
            if claims:
                cols = ", ".join(f'"{c}"' for c in claims[0].keys())
                for claim in claims:
                    vals = ", ".join(
                        "NULL" if v is None else f"'{str(v).replace(chr(39), chr(39)+chr(39))}'"
                        for v in claim.values()
                    )
                    lines.append(f"INSERT INTO semantic_claims ({cols}) VALUES ({vals});")
            text = "\n".join(lines) + "\n"

        if output == "-":
            click.echo(text)
        else:
            Path(output).write_text(text)
            _ok(f"Exported {len(claims)} claim(s) to {output}")

    _run(_export())


# ---------------------------------------------------------------------------
# evaos export (enhanced — json|csv|markdown)
# ---------------------------------------------------------------------------

@main.command("export-memory")
@click.option("--format", "fmt", default="json",
              type=click.Choice(["json", "csv", "markdown"]), show_default=True,
              help="Export format.")
@click.option("--output", "-o", required=True,
              help="Output file path.")
@click.option("--owner-id", default="default", show_default=True,
              help="Owner ID to export memories for.")
@click.pass_context
def cmd_export_memory(ctx: click.Context, fmt: str, output: str, owner_id: str) -> None:
    """Export memories as JSON, CSV, or Markdown.

    \b
        evaos export-memory --format json --output memories.json
        evaos export-memory --format csv --output claims.csv
        evaos export-memory --format markdown --output memories.md --owner-id eva
    """
    from cortex.tools.export_import import export_json, export_csv, export_markdown

    async def _run_export() -> None:
        plugin = await _init_plugin(ctx.obj["db"], ctx.obj["config"])
        storage = getattr(plugin, "storage", None)
        if storage is None:
            _err("Storage not available")
            sys.exit(1)

        try:
            if fmt == "json":
                count = await export_json(storage, owner_id=owner_id, output_path=output)
            elif fmt == "csv":
                count = await export_csv(storage, owner_id=owner_id, output_path=output)
            else:
                count = await export_markdown(storage, owner_id=owner_id, output_path=output)
            _ok(f"Exported {count} claim(s) [{fmt}] → {output}")
        except Exception as exc:
            _err(f"Export failed: {exc}")
            sys.exit(1)

    _run(_run_export())


# ---------------------------------------------------------------------------
# evaos import-memory
# ---------------------------------------------------------------------------

@main.command("import-memory")
@click.option("--format", "fmt", default="evaos",
              type=click.Choice(["evaos", "mem0"]), show_default=True,
              help="Import format: evaos (JSON) or mem0 (JSONL).")
@click.option("--input", "-i", "input_path", required=True,
              help="Input file path.")
@click.option("--owner-id", default="default", show_default=True,
              help="Target owner ID for imported memories.")
@click.option("--dry-run", is_flag=True, default=False,
              help="Validate and count without writing to storage.")
@click.option("--on-conflict", "on_conflict", default="skip",
              type=click.Choice(["skip", "overwrite", "merge"]), show_default=True,
              help="Conflict resolution: skip (default), overwrite, or merge.")
@click.pass_context
def cmd_import_memory(
    ctx: click.Context, fmt: str, input_path: str, owner_id: str,
    dry_run: bool, on_conflict: str,
) -> None:
    """Import memories from evaOS JSON or Mem0 JSONL export.

    \b
        evaos import-memory --format evaos --input memories.json
        evaos import-memory --format mem0 --input mem0_export.jsonl --dry-run
        evaos import-memory --format evaos --input export.json --on-conflict overwrite
    """
    from cortex.tools.export_import import import_json, import_mem0

    async def _run_import() -> None:
        plugin = await _init_plugin(ctx.obj["db"], ctx.obj["config"])
        storage = getattr(plugin, "storage", None)
        if storage is None:
            _err("Storage not available")
            sys.exit(1)

        if dry_run:
            _warn("DRY RUN — no data will be written")

        try:
            if fmt == "evaos":
                result = await import_json(
                    storage, input_path=input_path, owner_id=owner_id,
                    dry_run=dry_run, on_conflict=on_conflict,
                )
            else:
                result = await import_mem0(
                    storage, input_path=input_path, owner_id=owner_id, dry_run=dry_run
                )
        except Exception as exc:
            _err(f"Import failed: {exc}")
            sys.exit(1)

        _ok(f"Imported: {result.imported}  Skipped: {result.skipped}  Errors: {len(result.errors)}")
        if result.errors:
            _warn(f"Errors ({len(result.errors)}):")
            for err in result.errors[:10]:
                click.echo(f"  • {err}")
            if len(result.errors) > 10:
                click.echo(f"  … and {len(result.errors) - 10} more")

    _run(_run_import())


# ---------------------------------------------------------------------------
# evaos serve
# ---------------------------------------------------------------------------

@main.command("serve")
@click.option("--host", default="127.0.0.1", show_default=True, help="Bind host.")
@click.option("--port", default=8000, show_default=True, help="Bind port.")
@click.pass_context
def cmd_serve(ctx: click.Context, host: str, port: int) -> None:
    """Start the Cortex HTTP API server.

    Requires: fastapi, uvicorn (install with: pip install evaos[api])
    """
    try:
        import uvicorn  # noqa: F401
    except ImportError:
        _err("uvicorn is not installed. Run: pip install 'evaos[api]'")
        sys.exit(1)

    try:
        from cortex.api.http_server import create_app
    except ImportError as exc:
        _err(f"Cannot import HTTP server: {exc}")
        sys.exit(1)

    db_path = ctx.obj["db"]
    config_path = ctx.obj["config"]

    # Inject DB path via environment for the HTTP server
    if db_path:
        os.environ.setdefault("CORTEX_DB_PATH", db_path)
    if config_path:
        os.environ.setdefault("CORTEX_CONFIG_PATH", config_path)

    _ok(f"Starting Cortex HTTP API on http://{host}:{port}")
    import uvicorn
    app = create_app(config_path=config_path, db_path=db_path)
    uvicorn.run(app, host=host, port=port)


# ---------------------------------------------------------------------------
# evaos dashboard
# ---------------------------------------------------------------------------

@main.command("dashboard")
@click.option("--port", default=8421, show_default=True, help="Dashboard port.")
@click.option(
    "--api-url",
    default="http://localhost:8420",
    show_default=True,
    help="evaOS API server URL to proxy /api/ requests to.",
)
@click.pass_context
def cmd_dashboard(ctx: click.Context, port: int, api_url: str) -> None:
    """Serve the evaOS dashboard UI with API proxy.

    Starts a static file server for the dashboard/ directory and proxies
    /api/ requests to the main evaOS API server (avoids CORS issues).

    Usage: evaos dashboard --port 8421 --api-url http://localhost:8420
    """
    import http.server
    import urllib.request
    import urllib.error

    dashboard_dir = Path(__file__).resolve().parent.parent / "dashboard"
    if not dashboard_dir.is_dir():
        _err(f"Dashboard directory not found: {dashboard_dir}")
        sys.exit(1)

    api_url_clean = api_url.rstrip("/")

    class DashboardHandler(http.server.SimpleHTTPRequestHandler):
        """Serves static files from dashboard/ and proxies /api/ to evaOS."""

        def __init__(self, *args, **kwargs):
            super().__init__(*args, directory=str(dashboard_dir), **kwargs)

        def do_GET(self):
            if self.path.startswith("/api/"):
                self._proxy()
            else:
                super().do_GET()

        def do_POST(self):
            self._proxy()

        def do_PUT(self):
            self._proxy()

        def do_PATCH(self):
            self._proxy()

        def do_DELETE(self):
            self._proxy()

        def _proxy(self):
            target = f"{api_url_clean}{self.path}"
            body = None
            content_length = int(self.headers.get("Content-Length", 0))
            if content_length > 0:
                body = self.rfile.read(content_length)

            # Forward headers (except Host)
            headers = {}
            for key in self.headers:
                if key.lower() != "host":
                    headers[key] = self.headers[key]

            try:
                req = urllib.request.Request(
                    target,
                    data=body,
                    headers=headers,
                    method=self.command,
                )
                with urllib.request.urlopen(req, timeout=30) as resp:
                    self.send_response(resp.status)
                    for k, v in resp.getheaders():
                        if k.lower() not in ("transfer-encoding",):
                            self.send_header(k, v)
                    self.end_headers()
                    self.wfile.write(resp.read())
            except urllib.error.HTTPError as e:
                self.send_response(e.code)
                self.send_header("Content-Type", "application/json")
                self.end_headers()
                self.wfile.write(e.read())
            except Exception as e:
                self.send_response(502)
                self.send_header("Content-Type", "text/plain")
                self.end_headers()
                self.wfile.write(f"Proxy error: {e}".encode())

        def log_message(self, format, *args):
            logger.info(format % args)

    _ok(f"evaOS Dashboard: http://localhost:{port}")
    _ok(f"API proxy → {api_url_clean}")
    server = http.server.HTTPServer(("0.0.0.0", port), DashboardHandler)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        _ok("Dashboard stopped.")
        server.server_close()


# ---------------------------------------------------------------------------
# evaos mcp
# ---------------------------------------------------------------------------

@main.command("mcp")
@click.pass_context
def cmd_mcp(ctx: click.Context) -> None:
    """Start the Cortex MCP server (stdio transport).

    Requires: mcp (install with: pip install evaos[mcp])
    """
    try:
        from cortex.api.mcp_server import main as mcp_main
    except ImportError as exc:
        _err(f"Cannot import MCP server: {exc}")
        _err("Run: pip install 'evaos[mcp]'")
        sys.exit(1)

    db_path = ctx.obj["db"]
    config_path = ctx.obj["config"]
    if db_path:
        os.environ.setdefault("CORTEX_DB_PATH", db_path)
    if config_path:
        os.environ.setdefault("CORTEX_CONFIG_PATH", config_path)

    _ok("Starting Cortex MCP server (stdio)…")
    mcp_main()


# ---------------------------------------------------------------------------
# evaos backup
# ---------------------------------------------------------------------------

@main.command("backup")
@click.option("--output", "-o", default=None,
              help="Output path for backup file (default: cortex-backup-<timestamp>.db).")
@click.pass_context
def cmd_backup(ctx: click.Context, output: Optional[str]) -> None:
    """Create a backup copy of the Cortex database.

    Example:

    \b
        evaos backup
        evaos backup --output ~/backups/cortex-$(date +%Y%m%d).db
    """
    db_path = ctx.obj["db"]
    source = Path(db_path)

    if not source.exists():
        _err(f"Database not found: {db_path}")
        sys.exit(1)

    if output is None:
        ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
        output = f"cortex-backup-{ts}.db"

    dest = Path(output)
    shutil.copy2(source, dest)
    size_kb = dest.stat().st_size // 1024
    _ok(f"Backup written: {dest} ({size_kb} KB)")


# ---------------------------------------------------------------------------
# GDPR forget command (#108)
# ---------------------------------------------------------------------------

@main.command("forget")
@click.option(
    "--entity-id", default=None,
    help="Entity ID to forget (cascade-delete all claims and brain nodes).",
)
@click.option(
    "--owner-id", default=None,
    help="Owner ID scope (required for entity forget; used alone for nuclear owner forget).",
)
@click.option(
    "--confirm-delete-all", is_flag=True, default=False,
    help="Required flag when using --owner-id alone (nuclear owner forget).",
)
@click.option(
    "--dry-run", is_flag=True, default=False,
    help="Show what would be deleted without touching the database.",
)
@click.pass_context
def cmd_forget(
    ctx: click.Context,
    entity_id: Optional[str],
    owner_id: Optional[str],
    confirm_delete_all: bool,
    dry_run: bool,
) -> None:
    """GDPR-compliant entity or owner forget.

    \b
    Entity forget (cascade-delete one entity + its claims):
        evaos forget --entity-id <ID> --owner-id <OWNER>
        evaos forget --entity-id <ID> --owner-id <OWNER> --dry-run

    \b
    Owner forget (delete ALL data for an owner):
        evaos forget --owner-id <OWNER> --confirm-delete-all
        evaos forget --owner-id <OWNER> --confirm-delete-all --dry-run
    """
    from cortex.tools.gdpr import forget_entity, forget_owner

    if entity_id and owner_id:
        # Single-entity forget
        async def _run_entity():
            plugin = _make_plugin(ctx.obj["db"], ctx.obj.get("config"))
            async with plugin:
                result = await forget_entity(
                    plugin.storage,
                    entity_id=entity_id,
                    owner_id=owner_id,
                    dry_run=dry_run,
                    emitter=getattr(plugin, "emitter", None),
                )
            return result

        result = _run(_run_entity())
        label = "[DRY RUN] " if dry_run else ""
        _ok(
            f"{label}Entity forget complete: "
            f"claims={result.claims_deleted}, "
            f"entities={result.entities_deleted}, "
            f"brain_nodes={result.brain_nodes_deleted}"
        )
        if result.entity_name:
            _print(f"  Entity name: {result.entity_name}")

    elif owner_id and confirm_delete_all:
        # Nuclear owner forget
        async def _run_owner():
            plugin = _make_plugin(ctx.obj["db"], ctx.obj.get("config"))
            async with plugin:
                result = await forget_owner(
                    plugin.storage,
                    owner_id=owner_id,
                    dry_run=dry_run,
                    emitter=getattr(plugin, "emitter", None),
                )
            return result

        result = _run(_run_owner())
        label = "[DRY RUN] " if dry_run else ""
        _ok(
            f"{label}Owner forget complete for {owner_id!r}: "
            f"claims={result.claims_deleted}, "
            f"entities={result.entities_deleted}, "
            f"sessions={result.sessions_deleted}, "
            f"events={result.events_deleted}, "
            f"request_logs={result.request_logs_deleted}"
        )

    elif owner_id and not confirm_delete_all:
        _err(
            "Owner forget requires --confirm-delete-all flag. "
            "This operation deletes ALL data for the owner."
        )
        sys.exit(1)

    else:
        _err("Provide --entity-id (with --owner-id) or --owner-id (with --confirm-delete-all).")
        sys.exit(1)


# ---------------------------------------------------------------------------
# extraction-instructions subcommand group (#177)
# ---------------------------------------------------------------------------

@main.group("extraction-instructions")
@click.pass_context
def extraction_instructions(ctx: click.Context) -> None:
    """Manage custom extraction instructions for the Cortex memory pipeline."""


@extraction_instructions.command("get")
@click.pass_context
def extraction_instructions_get(ctx: click.Context) -> None:
    """Print the current custom extraction instructions."""
    plugin, _ = _make_plugin(ctx)

    instructions = getattr(plugin.config, "extraction_custom_instructions", "")
    if instructions:
        click.echo(instructions)
    else:
        click.echo("(no custom extraction instructions set)")


@extraction_instructions.command("set")
@click.argument("instructions")
@click.pass_context
def extraction_instructions_set(ctx: click.Context, instructions: str) -> None:
    """Set custom extraction instructions.

    INSTRUCTIONS is the operator instruction string to append to the
    extraction system prompt.  Pass an empty string to clear.

    Example::

        evaos extraction-instructions set "Always extract cooking preferences."
        evaos extraction-instructions set ""  # clears instructions
    """
    from cortex.extraction.extractor import ExtractionPipeline
    from cortex.extraction.prompts import EXTRACTION_SYSTEM_PROMPT

    plugin, _ = _make_plugin(ctx)

    instructions = instructions.strip()
    plugin.config.extraction_custom_instructions = instructions

    # Also update the live extractor so it takes effect immediately
    extractor = getattr(plugin, "extractor", None)
    if extractor is not None and isinstance(extractor, ExtractionPipeline):
        extractor.custom_instructions = instructions
        if instructions:
            extractor._system_prompt = (
                EXTRACTION_SYSTEM_PROMPT
                + "\n\n## Operator Instructions\n"
                + instructions
            )
        else:
            extractor._system_prompt = EXTRACTION_SYSTEM_PROMPT

    if instructions:
        _ok(f"Extraction instructions updated:\n{instructions}")
    else:
        _ok("Extraction instructions cleared.")


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    main()
