"""Command-line interface for stablestack."""

from __future__ import annotations

import json
import os
import sys
from pathlib import Path
from typing import List, Optional

import click

from stablestack import __version__
from stablestack.config import Config
from stablestack.formatters import TextFormatter, JSONFormatter
from stablestack.licensing import get_cached_license, LicenseError
from stablestack.models import Severity, Tier
from stablestack.runner import run_checks
from stablestack.telemetry import send_event as _send_telemetry


# ANSI color codes
YELLOW = "\033[93m"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
CYAN = "\033[96m"
RED = "\033[91m"
GREEN = "\033[92m"
BLUE = "\033[94m"


def _detect_source_paths() -> tuple[str, ...]:
    """Auto-detect where source code lives when no path is given.

    Looks for common source directories. If none found, falls back to
    current directory and tells the user.
    """
    cwd = Path.cwd()
    # Common source directory names, in priority order
    candidates = ["src", "app", "lib", "pkg", "packages", "server", "api", "backend", "frontend"]
    found = []
    for name in candidates:
        candidate = cwd / name
        if candidate.is_dir():
            # Only count it if it has source files
            extensions = {".py", ".js", ".ts", ".jsx", ".tsx", ".vue", ".go", ".rs", ".java", ".rb"}
            has_source = any(f.suffix in extensions for f in candidate.rglob("*") if f.is_file())
            if has_source:
                found.append(name)

    if found:
        dirs = ", ".join(found)
        click.echo(f"{CYAN}Auto-detected source: {dirs}{RESET}", err=True)
        return tuple(found)

    # No common dirs found — check if cwd itself has source files
    extensions = {".py", ".js", ".ts", ".jsx", ".tsx", ".vue", ".go", ".rs", ".java", ".rb"}
    has_source = any(f.suffix in extensions for f in cwd.iterdir() if f.is_file())
    if has_source:
        click.echo(f"{CYAN}No src/ directory found, scanning current directory{RESET}", err=True)
    else:
        click.echo(f"{CYAN}No src/ directory found, scanning current directory{RESET}", err=True)
    return (".",)


def _list_rules(no_color: bool) -> None:
    """List all available rules grouped by tier."""
    from stablestack.checkers import get_all_checkers

    all_checkers = get_all_checkers()

    # Group checkers by tier
    by_tier: dict[Tier, list] = {t: [] for t in Tier}
    for checker_cls in all_checkers:
        tier = getattr(checker_cls, 'tier', Tier.RECOMMENDED)
        by_tier[tier].append(checker_cls)

    # Sort each tier's checkers by rule_id
    for tier in by_tier:
        by_tier[tier].sort(key=lambda c: c.rule_id)

    total = len(all_checkers)

    # Color helpers
    if no_color:
        b, d, c, r, g, y, rst = "", "", "", "", "", "", ""
    else:
        b, d, c, r, g, y, rst = BOLD, DIM, CYAN, RED, GREEN, YELLOW, RESET

    click.echo(f"\n{b}Available rules ({total} total){rst}\n")

    tier_colors = {
        Tier.CRITICAL: r,
        Tier.RECOMMENDED: g,
        Tier.OPTIONAL: y,
        Tier.PREFERENCE: c,
    }

    tier_descriptions = {
        Tier.CRITICAL: "Security issues - always enabled",
        Tier.RECOMMENDED: "Common issues - enabled by default",
        Tier.OPTIONAL: "Nice-to-have checks",
        Tier.PREFERENCE: "Opinionated style rules - opt-in only",
    }

    for tier in Tier:
        checkers = by_tier[tier]
        if not checkers:
            continue

        color = tier_colors.get(tier, "")
        click.echo(f"{color}{b}{tier.name}{rst} {d}({len(checkers)} rules - {tier_descriptions[tier]}){rst}")
        click.echo()

        for checker_cls in checkers:
            rule_id = checker_cls.rule_id
            rule_name = checker_cls.rule_name
            desc = checker_cls.description[:60] + "..." if len(checker_cls.description) > 60 else checker_cls.description
            click.echo(f"  {c}{rule_id}{rst}  {rule_name:<30} {d}{desc}{rst}")

        click.echo()

    click.echo(f"{d}Use --enable RULE_ID or --disable RULE_ID to customize.{rst}")
    click.echo(f"{d}Use --tier TIER to run only that tier, or --up-to TIER for cumulative.{rst}\n")


class DefaultGroup(click.Group):
    """A Click group that runs a default command if no subcommand is given."""

    def __init__(self, *args, default_cmd: str = "check", **kwargs):
        super().__init__(*args, **kwargs)
        self.default_cmd = default_cmd

    def parse_args(self, ctx, args):
        # If no args or first arg looks like an option/path (not a subcommand), use default
        if not args or args[0].startswith("-") or args[0].startswith(".") or "/" in args[0] or Path(args[0]).exists():
            args = [self.default_cmd] + list(args)
        return super().parse_args(ctx, args)


@click.group(cls=DefaultGroup, default_cmd="check", invoke_without_command=True)
@click.option("--version", "-v", is_flag=True, help="Show version and exit")
@click.pass_context
def main(ctx, version: bool) -> None:
    """
    stablestack - Your AI-generated code, double-checked.

    Catches subtle bugs that AI assistants often introduce, like non-deterministic
    iteration and silently swallowed exceptions.

    \b
    Commands:
      check (default)  Run checks on files/directories
      activate         Activate a paid license key
      status           Show version, license, and checker info
      init             Initialize stablestack in a project
      explain          Show detailed explanation of a rule
      hook             Manage git hooks
      notifications    Set up sound & visual notifications (macOS)
      dashboard        Live status of all Claude Code instances

    \b
    Examples:
      stablestack src/                     # Check all files in src/
      stablestack . --ci                   # CI mode (exit 1 on errors)
      stablestack activate sk_live_abc123  # Activate a license
      stablestack status                   # Show setup info
      stablestack explain DET001           # Learn about a rule
    """
    if version:
        click.echo(f"stablestack {__version__}")
        sys.exit(0)


@main.command("check")
@click.argument("paths", nargs=-1, type=click.Path())
@click.option(
    "--format", "-f",
    "output_format",
    type=click.Choice(["text", "json", "claude"]),
    default="text",
    help="Output format (default: text)",
)
@click.option(
    "--config", "-c",
    "config_path",
    type=click.Path(exists=True, path_type=Path),
    help="Path to pyproject.toml config file",
)
@click.option("--enable", help="Comma-separated list of rule IDs to enable")
@click.option("--disable", help="Comma-separated list of rule IDs to disable")
@click.option("--ci", is_flag=True, help="CI mode: exit with code 1 if any errors found")
@click.option("--no-color", is_flag=True, help="Disable colored output")
@click.option("--no-why", is_flag=True, help="Don't show 'why this matters' explanations")
@click.option("--quiet", "-q", is_flag=True, help="Minimal output (just summary)")
@click.option(
    "--tier", "-t",
    type=click.Choice(["critical", "recommended", "optional", "preference"]),
    default=None,
    help="Run ONLY rules from this tier",
)
@click.option(
    "--up-to", "-u", "up_to",
    type=click.Choice(["critical", "recommended", "optional", "preference"]),
    default=None,
    help="Run rules up to and including this tier",
)
@click.option("--list-rules", is_flag=True, help="List all available rules and exit")
@click.option("--stats", is_flag=True, help="Show codebase statistics")
@click.option(
    "--baseline",
    type=click.Path(path_type=Path),
    help="Baseline file - only report issues not in baseline",
)
@click.option("--generate-baseline", is_flag=True, help="Generate baseline file from current issues")
@click.option("--cache/--no-cache", default=True, help="Use file caching for faster repeated runs (default: enabled)")
@click.option("--clear-cache", is_flag=True, help="Clear the cache before running")
@click.option("--fix", is_flag=True, help="Automatically fix issues where possible")
@click.option("--dry-run", is_flag=True, help="Show what --fix would do without making changes")
@click.option("--fix-rule", "fix_rule", help="Only fix issues from specific rule ID (use with --fix)")
@click.option("--watch", "-w", is_flag=True, help="Watch for file changes and re-run checks")
@click.option("--top", "top_n", type=int, default=0, help="Show only the top N findings (errors first)")
def check_command(
    paths: tuple[str, ...],
    output_format: str,
    config_path: Optional[Path],
    enable: Optional[str],
    disable: Optional[str],
    ci: bool,
    no_color: bool,
    no_why: bool,
    quiet: bool,
    tier: Optional[str],
    up_to: Optional[str],
    list_rules: bool,
    stats: bool,
    baseline: Optional[Path],
    generate_baseline: bool,
    cache: bool,
    clear_cache: bool,
    fix: bool,
    dry_run: bool,
    fix_rule: Optional[str],
    watch: bool,
    top_n: int,
) -> None:
    """Run checks on files or directories."""
    if list_rules:
        _list_rules(no_color)
        sys.exit(0)

    # Auto-initialize on first run if not yet set up
    _auto_init_if_needed(quiet)

    # Clear cache if requested
    if clear_cache:
        from stablestack.cache import clear_cache as do_clear_cache
        project_root = Path(paths[0]).resolve() if paths else Path.cwd()
        if project_root.is_file():
            project_root = project_root.parent
        if do_clear_cache(project_root):
            click.echo(f"{GREEN}Cache cleared{RESET}")
        else:
            click.echo(f"{YELLOW}No cache to clear{RESET}")
        if not paths:
            sys.exit(0)

    # Check license
    is_licensed, license_info, license_error = get_cached_license()
    if not is_licensed and not quiet:
        click.echo(
            f"{YELLOW}Warning: Unlicensed — running free-tier checks only.{RESET}\n"
            f"  Run {CYAN}stablestack activate <key>{RESET} to unlock all checkers.\n"
            f"  Purchase a license at https://stablestack.ai\n",
            err=True
        )
    elif license_info and not quiet:
        if license_info.license_type == "trial":
            days_left = (license_info.expires_at - __import__('datetime').datetime.now()).days
            if days_left <= 7:
                click.echo(
                    f"{YELLOW}Trial expires in {days_left} days. "
                    f"Visit https://stablestack.ai to purchase.{RESET}\n",
                    err=True
                )

    if not paths:
        paths = _detect_source_paths()

    # Load configuration — search from the target path so we pick up
    # the right project's pyproject.toml, not one from cwd
    search_from = Path(paths[0]) if paths else None
    config = Config.load(config_path, search_from=search_from)

    # Apply CLI overrides
    if enable:
        config.enable = set(enable.split(","))
    if disable:
        config.disable = set(disable.split(","))

    # Set tier filters
    tier_map = {
        "critical": Tier.CRITICAL,
        "recommended": Tier.RECOMMENDED,
        "optional": Tier.OPTIONAL,
        "preference": Tier.PREFERENCE,
    }

    if tier and up_to:
        click.echo("Error: Cannot use both --tier and --up-to together.", err=True)
        sys.exit(1)

    if tier:
        config.exact_tier = tier_map[tier]
    elif up_to:
        config.max_tier = tier_map[up_to]
    else:
        config.max_tier = Tier.OPTIONAL

    # Convert paths to Path objects, warn about missing ones
    path_list: List[Path] = []
    for p in paths:
        pp = Path(p)
        if not pp.exists():
            click.echo(
                f"{YELLOW}Warning: '{p}' does not exist, skipping.{RESET}\n"
                f"  Run {CYAN}stablestack{RESET} with no arguments to auto-detect your source code.\n",
                err=True,
            )
        else:
            path_list.append(pp)

    if not path_list:
        click.echo(f"{YELLOW}No valid paths to check.{RESET}", err=True)
        sys.exit(0)

    # Watch mode
    if watch:
        _run_watch_mode(
            path_list, config, no_color, quiet, cache, baseline
        )
        # watch mode runs forever until interrupted
        sys.exit(0)

    # Run checks
    findings, is_free_tier, total_checkers = run_checks(path_list, config, use_cache=cache)

    # Telemetry (fire-and-forget, respects DO_NOT_TRACK)
    _send_telemetry(
        "check",
        files_scanned=len(path_list),
        errors=sum(1 for f in findings if f.severity == Severity.ERROR),
        warnings=sum(1 for f in findings if f.severity == Severity.WARNING),
        infos=sum(1 for f in findings if f.severity == Severity.INFO),
        checkers_loaded=total_checkers,
        is_licensed=not is_free_tier,
    )

    # Load baseline and filter findings
    if baseline and baseline.exists():
        baseline_issues = _load_baseline(baseline)
        findings = _filter_baseline(findings, baseline_issues)

    # Fix mode
    if fix or dry_run:
        fixed_count, unfixable_count = _apply_fixes(
            findings, fix_rule, dry_run, no_color
        )
        if not dry_run:
            click.echo(f"\n{GREEN}Fixed {fixed_count} issues.{RESET}" if fixed_count else "")
            if unfixable_count:
                click.echo(f"{YELLOW}{unfixable_count} issues could not be auto-fixed.{RESET}")
        sys.exit(0)

    # Generate baseline mode
    if generate_baseline:
        _output_baseline(findings)
        sys.exit(0)

    # Stats mode
    if stats:
        _show_stats(findings, path_list, total_checkers, no_color)
        sys.exit(0)

    # Quiet mode
    if quiet:
        error_count = sum(1 for f in findings if f.severity == Severity.ERROR)
        warning_count = sum(1 for f in findings if f.severity == Severity.WARNING)
        info_count = sum(1 for f in findings if f.severity == Severity.INFO)
        files_with_issues = len(set(f.file for f in findings))
        click.echo(f"{error_count} errors, {warning_count} warnings, {info_count} info in {files_with_issues} files")
        if ci and error_count > 0:
            sys.exit(1)
        sys.exit(0)

    # Format output
    if output_format == "json":
        formatter = JSONFormatter(pretty=True)
    elif output_format == "claude":
        _format_claude(findings, no_color)
        if ci:
            error_count = sum(1 for f in findings if f.severity == Severity.ERROR)
            if error_count > 0:
                sys.exit(1)
        sys.exit(0)
    else:
        formatter = TextFormatter(
            use_color=not no_color,
            show_why=not no_why,
            top_n=top_n,
        )

    formatter.format(findings)

    # Show upgrade message for free tier
    if is_free_tier and output_format != "json" and not quiet:
        from stablestack.checkers import FREE_CHECKERS
        free_count = len(FREE_CHECKERS)
        click.echo(
            f"\n{YELLOW}Free tier: Running {free_count} of {total_checkers} rules.{RESET}\n"
            f"Run {CYAN}stablestack activate <key>{RESET} to unlock all {total_checkers} checks including:\n"
            f"  - Determinism issues (non-reproducible code)\n"
            f"  - Performance problems (N+1 queries)\n"
            f"  - Code quality (exception handling, type safety)\n"
            f"  - And {total_checkers - free_count} more rules\n"
            f"Purchase at https://stablestack.ai\n",
            err=True
        )

    # Exit with appropriate code
    if ci:
        error_count = sum(1 for f in findings if f.severity == Severity.ERROR)
        if error_count > 0:
            sys.exit(1)

    sys.exit(0)


def _load_baseline(baseline_path: Path) -> set[tuple[str, int, str]]:
    """Load baseline file and return set of (file, line, rule_id) tuples."""
    try:
        data = json.loads(baseline_path.read_text())
        return {(f["file"], f["line"], f["rule_id"]) for f in data.get("findings", [])}
    except (json.JSONDecodeError, KeyError, OSError):
        return set()


def _filter_baseline(findings: list, baseline: set[tuple[str, int, str]]) -> list:
    """Filter out findings that are in the baseline."""
    return [f for f in findings if (f.file, f.line, f.rule_id) not in baseline]


def _output_baseline(findings: list) -> None:
    """Output findings as a baseline JSON file."""
    baseline = {
        "version": "1.0",
        "findings": [
            {"file": f.file, "line": f.line, "rule_id": f.rule_id}
            for f in findings
        ]
    }
    click.echo(json.dumps(baseline, indent=2))


def _show_stats(findings: list, paths: List[Path], total_checkers: int, no_color: bool) -> None:
    """Show codebase statistics."""
    if no_color:
        b, d, g, y, r, rst = "", "", "", "", "", ""
    else:
        b, d, g, y, r, rst = BOLD, DIM, GREEN, YELLOW, RED, RESET

    # Count files
    file_count = 0
    line_count = 0
    for path in paths:
        if path.is_file():
            file_count += 1
            try:
                line_count += len(path.read_text().splitlines())
            except OSError:
                pass
        elif path.is_dir():
            for f in path.rglob("*"):
                if f.is_file() and f.suffix in {".py", ".js", ".ts", ".jsx", ".tsx", ".vue"}:
                    file_count += 1
                    try:
                        line_count += len(f.read_text().splitlines())
                    except OSError:
                        pass

    # Count by severity
    error_count = sum(1 for f in findings if f.severity == Severity.ERROR)
    warning_count = sum(1 for f in findings if f.severity == Severity.WARNING)
    info_count = sum(1 for f in findings if f.severity == Severity.INFO)

    # Count by tier
    by_tier: dict[str, int] = {}
    for f in findings:
        # Extract tier prefix from rule_id
        prefix = f.rule_id[:3] if len(f.rule_id) >= 3 else f.rule_id
        by_tier[prefix] = by_tier.get(prefix, 0) + 1

    # Count by rule
    by_rule: dict[str, int] = {}
    for f in findings:
        by_rule[f.rule_id] = by_rule.get(f.rule_id, 0) + 1

    # Files with issues
    files_with_issues = len(set(f.file for f in findings))
    clean_files = file_count - files_with_issues
    clean_pct = (clean_files / file_count * 100) if file_count > 0 else 100

    click.echo(f"\n{b}Codebase Statistics{rst}\n")
    click.echo(f"  Files checked:     {file_count}")
    click.echo(f"  Lines of code:     {line_count:,}")
    click.echo(f"  Rules available:   {total_checkers}")
    click.echo()
    click.echo(f"{b}Issues by severity:{rst}")
    click.echo(f"  {r}Errors:   {error_count}{rst}")
    click.echo(f"  {y}Warnings: {warning_count}{rst}")
    click.echo(f"  {d}Info:     {info_count}{rst}")
    click.echo()
    if by_rule:
        click.echo(f"{b}Top rules triggered:{rst}")
        for rule_id, count in sorted(by_rule.items(), key=lambda x: -x[1])[:5]:
            click.echo(f"  {rule_id}: {count}")
        click.echo()
    click.echo(f"{b}Clean files:{rst} {clean_files}/{file_count} ({clean_pct:.0f}%)")
    click.echo()


def _format_claude(findings: list, no_color: bool) -> None:
    """Format findings for pasting into Claude conversations."""
    if not findings:
        click.echo("No issues found.")
        return

    # Group by file
    by_file: dict[str, list] = {}
    for f in findings:
        if f.file not in by_file:
            by_file[f.file] = []
        by_file[f.file].append(f)

    error_count = sum(1 for f in findings if f.severity == Severity.ERROR)
    warning_count = sum(1 for f in findings if f.severity == Severity.WARNING)
    info_count = sum(1 for f in findings if f.severity == Severity.INFO)

    click.echo(f"Found {len(findings)} issues in {len(by_file)} files:")
    if error_count:
        click.echo(f"  {error_count} errors (must fix)")
    if warning_count:
        click.echo(f"  {warning_count} warnings")
    if info_count:
        click.echo(f"  {info_count} info")
    click.echo()

    for file_path, file_findings in sorted(by_file.items()):
        click.echo(f"## {file_path}")
        click.echo()
        for f in sorted(file_findings, key=lambda x: x.line):
            severity_icon = {"error": "ERROR", "warning": "WARNING", "info": "INFO"}.get(f.severity.value, "")
            click.echo(f"**Line {f.line}: {f.rule_id} - {f.rule_name}** ({severity_icon})")
            click.echo(f"```")
            click.echo(f"{f.code}")
            click.echo(f"```")
            click.echo(f"Fix: {f.fix_hint.split(chr(10))[0]}")  # First line of fix hint
            click.echo()


def _apply_fixes(
    findings: list,
    fix_rule: Optional[str],
    dry_run: bool,
    no_color: bool,
) -> tuple[int, int]:
    """
    Apply automatic fixes to findings.

    Returns:
        Tuple of (fixed_count, unfixable_count)
    """
    from stablestack.checkers import get_all_checkers

    if no_color:
        g, y, c, rst = "", "", "", ""
    else:
        g, y, c, rst = GREEN, YELLOW, CYAN, RESET

    # Build a map of rule_id -> checker instance
    checker_map = {}
    for checker_cls in get_all_checkers():
        checker_map[checker_cls.rule_id] = checker_cls()

    # Filter findings by rule if specified
    if fix_rule:
        fix_rule = fix_rule.upper()
        findings = [f for f in findings if f.rule_id == fix_rule]

    # Group findings by file
    by_file: dict[str, list] = {}
    for f in findings:
        if f.file not in by_file:
            by_file[f.file] = []
        by_file[f.file].append(f)

    fixed_count = 0
    unfixable_count = 0

    for file_path, file_findings in sorted(by_file.items()):
        path = Path(file_path)
        if not path.exists():
            continue

        try:
            content = path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue

        original_content = content
        # Sort findings by line (descending) to fix from bottom up
        # This prevents line number shifting issues
        file_findings_sorted = sorted(file_findings, key=lambda x: x.line, reverse=True)

        for finding in file_findings_sorted:
            checker = checker_map.get(finding.rule_id)
            if not checker:
                unfixable_count += 1
                continue

            if not checker.supports_autofix:
                unfixable_count += 1
                continue

            # Try to apply the fix
            fixed_content = checker.autofix(path, content, finding)
            if fixed_content is not None and fixed_content != content:
                if dry_run:
                    click.echo(f"{c}{file_path}:{finding.line}{rst} - Would fix {finding.rule_id}")
                    # Show diff preview
                    _show_fix_preview(content, fixed_content, finding.line)
                content = fixed_content
                fixed_count += 1
            else:
                unfixable_count += 1

        # Write the fixed content back if not dry-run
        if not dry_run and content != original_content:
            try:
                path.write_text(content, encoding="utf-8")
                click.echo(f"{g}Fixed{rst} {file_path}")
            except OSError as e:
                click.echo(f"{y}Could not write {file_path}: {e}{rst}", err=True)

    return fixed_count, unfixable_count


def _show_fix_preview(old_content: str, new_content: str, line_no: int) -> None:
    """Show a preview of what the fix will change."""
    old_lines = old_content.splitlines()
    new_lines = new_content.splitlines()

    # Find the area around the change (show 2 lines context)
    start = max(0, line_no - 2)
    end = min(len(old_lines), line_no + 2)

    # Simple diff: show old and new for the affected region
    click.echo(f"  {DIM}--- before{RESET}")
    for i in range(start, min(end, len(old_lines))):
        prefix = "  " if i + 1 != line_no else "> "
        click.echo(f"  {prefix}{old_lines[i]}")

    click.echo(f"  {DIM}+++ after{RESET}")
    for i in range(start, min(end, len(new_lines))):
        prefix = "  " if i + 1 != line_no else "> "
        click.echo(f"  {prefix}{new_lines[i]}")
    click.echo()


def _run_watch_mode(
    paths: List[Path],
    config,
    no_color: bool,
    quiet: bool,
    use_cache: bool,
    baseline: Optional[Path],
) -> None:
    """Run in watch mode, re-checking files when they change."""
    import time
    from datetime import datetime

    from stablestack.config import Config
    from stablestack.models import Severity
    from stablestack.runner import run_checks

    if no_color:
        g, y, c, d, rst = "", "", "", "", ""
    else:
        g, y, c, d, rst = GREEN, YELLOW, CYAN, DIM, RESET

    # Load baseline if provided
    baseline_issues: set = set()
    if baseline and baseline.exists():
        baseline_issues = _load_baseline(baseline)

    # Track file modification times
    file_mtimes: dict[Path, float] = {}

    def get_files(paths: List[Path]) -> List[Path]:
        """Get all source files from paths."""
        files = []
        extensions = {".py", ".js", ".ts", ".jsx", ".tsx", ".vue"}
        for p in paths:
            if p.is_file():
                if p.suffix in extensions:
                    files.append(p)
            elif p.is_dir():
                for f in p.rglob("*"):
                    if f.is_file() and f.suffix in extensions:
                        files.append(f)
        return files

    def check_for_changes(files: List[Path]) -> List[Path]:
        """Check which files have changed since last check."""
        changed = []
        for f in files:
            try:
                mtime = f.stat().st_mtime
                if f not in file_mtimes or file_mtimes[f] != mtime:
                    file_mtimes[f] = mtime
                    changed.append(f)
            except OSError:
                continue
        return changed

    def format_time() -> str:
        return datetime.now().strftime("%H:%M:%S")

    # Initial check
    files = get_files(paths)
    click.echo(f"{c}Watching {len(files)} files... (Ctrl+C to stop){rst}")

    # Initialize file mtimes
    for f in files:
        try:
            file_mtimes[f] = f.stat().st_mtime
        except OSError:
            continue

    # Run initial check
    findings, _, _ = run_checks(paths, config, use_cache=use_cache)
    if baseline_issues:
        findings = _filter_baseline(findings, baseline_issues)

    error_count = sum(1 for f in findings if f.severity == Severity.ERROR)
    warning_count = sum(1 for f in findings if f.severity == Severity.WARNING)

    if quiet:
        click.echo(f"{d}[{format_time()}]{rst} {error_count} errors, {warning_count} warnings")
    else:
        if findings:
            click.echo(f"\n{d}[{format_time()}]{rst} Found {len(findings)} issues:")
            for f in findings[:10]:  # Show first 10
                severity_color = {Severity.ERROR: RED, Severity.WARNING: YELLOW, Severity.INFO: DIM}.get(f.severity, "")
                click.echo(f"  {severity_color}{f.severity.value.upper()}{rst} {f.file}:{f.line} - {f.message[:60]}")
            if len(findings) > 10:
                click.echo(f"  {d}...and {len(findings) - 10} more{rst}")
        else:
            click.echo(f"{d}[{format_time()}]{rst} {g}No issues found{rst}")

    # Watch loop
    try:
        while True:
            time.sleep(1)  # Check every second

            # Re-scan files (in case new files were added)
            current_files = get_files(paths)
            changed = check_for_changes(current_files)

            if changed:
                click.echo(f"\n{d}[{format_time()}]{rst} {c}Files changed:{rst} {', '.join(str(f.name) for f in changed[:3])}")

                # Re-run checks (cache will handle unchanged files)
                findings, _, _ = run_checks(paths, config, use_cache=use_cache)
                if baseline_issues:
                    findings = _filter_baseline(findings, baseline_issues)

                error_count = sum(1 for f in findings if f.severity == Severity.ERROR)
                warning_count = sum(1 for f in findings if f.severity == Severity.WARNING)

                if quiet:
                    click.echo(f"  {error_count} errors, {warning_count} warnings")
                else:
                    if findings:
                        click.echo(f"  Found {len(findings)} issues:")
                        for f in findings[:5]:
                            severity_color = {Severity.ERROR: RED, Severity.WARNING: YELLOW, Severity.INFO: DIM}.get(f.severity, "")
                            click.echo(f"    {severity_color}{f.severity.value.upper()}{rst} {f.file}:{f.line} - {f.message[:50]}")
                        if len(findings) > 5:
                            click.echo(f"    {d}...and {len(findings) - 5} more{rst}")
                    else:
                        click.echo(f"  {g}No issues found{rst}")

    except KeyboardInterrupt:
        click.echo(f"\n{d}Watch mode stopped.{rst}")


@main.command("init")
@click.option("--minimal", is_flag=True, help="Only create pyproject.toml config")
@click.option("--no-commands", is_flag=True, help="Skip installing Claude Code slash commands")
def init_command(minimal: bool, no_commands: bool) -> None:
    """Initialize StableStack in the current project.

    \b
    Sets up:
      - pyproject.toml config
      - .stablestackignore
      - Claude Code slash commands (/check, /fix-production)
      - CLAUDE.md section with rules and conventions

    \b
    Examples:
      stablestack init              # Full setup
      stablestack init --minimal    # Just pyproject.toml config
      stablestack init --no-commands  # Skip Claude Code commands
    """
    cwd = Path.cwd()

    # Check for existing pyproject.toml
    pyproject_path = cwd / "pyproject.toml"
    created_files = []

    # Create or update pyproject.toml
    if pyproject_path.exists():
        content = pyproject_path.read_text()
        if "[tool.stablestack]" in content:
            click.echo(f"{YELLOW}StableStack config already exists in pyproject.toml{RESET}")
        else:
            config_section = _get_pyproject_config()
            with open(pyproject_path, "a") as f:
                f.write("\n" + config_section)
            click.echo(f"{GREEN}Added [tool.stablestack] to pyproject.toml{RESET}")
            created_files.append("pyproject.toml")
    else:
        config_section = _get_pyproject_config()
        pyproject_path.write_text(config_section)
        click.echo(f"{GREEN}Created pyproject.toml with StableStack config{RESET}")
        created_files.append("pyproject.toml")

    if minimal:
        _show_init_summary(created_files)
        return

    # Create .stablestackignore
    ignore_path = cwd / ".stablestackignore"
    if not ignore_path.exists():
        ignore_path.write_text(_get_ignore_content())
        click.echo(f"{GREEN}Created .stablestackignore{RESET}")
        created_files.append(".stablestackignore")

    # Install Claude Code slash commands (default: yes)
    if not no_commands:
        commands_dir = cwd / ".claude" / "commands"
        commands_dir.mkdir(parents=True, exist_ok=True)

        for name, content_fn in _get_claude_commands().items():
            command_path = commands_dir / f"{name}.md"
            if command_path.exists():
                click.echo(f"{YELLOW}.claude/commands/{name}.md already exists, skipping{RESET}")
            else:
                command_path.write_text(content_fn)
                click.echo(f"{GREEN}Created .claude/commands/{name}.md{RESET}")
                created_files.append(f".claude/commands/{name}.md")

    # Add StableStack section to CLAUDE.md
    claude_md_path = cwd / "CLAUDE.md"
    claude_section = _get_claude_md_section()
    if claude_md_path.exists():
        content = claude_md_path.read_text()
        if "stablestack" not in content.lower():
            with open(claude_md_path, "a") as f:
                f.write("\n\n" + claude_section)
            click.echo(f"{GREEN}Added StableStack section to CLAUDE.md{RESET}")
            created_files.append("CLAUDE.md")
    else:
        claude_md_path.write_text(f"# Project Documentation\n\n{claude_section}")
        click.echo(f"{GREEN}Created CLAUDE.md with StableStack section{RESET}")
        created_files.append("CLAUDE.md")

    _show_init_summary(created_files)


def _get_pyproject_config() -> str:
    """Get default StableStack config for pyproject.toml."""
    return '''[tool.stablestack]
# Rules to disable (e.g., ["QUAL005", "QUAL006"])
disable = []

# Paths to exclude
exclude = [
    "**/node_modules/**",
    "**/__pycache__/**",
    "**/.git/**",
    "**/venv/**",
    "**/.venv/**",
    "**/dist/**",
    "**/build/**",
]

# Paths to include (empty = all supported files)
# include = ["src/**/*.py", "src/**/*.ts"]
'''


def _get_ignore_content() -> str:
    """Get default .stablestackignore content."""
    return '''# stablestack ignore patterns
# Files matching these patterns will be skipped

# Dependencies
node_modules/
venv/
.venv/
__pycache__/

# Build outputs
dist/
build/
*.egg-info/

# Test fixtures and mocks
**/fixtures/**
**/mocks/**

# Generated files
*.generated.*
*.g.dart
'''


def _get_claude_md_section() -> str:
    """Get StableStack section for CLAUDE.md."""
    return '''## Code Quality (StableStack)

This project uses StableStack for code quality checks.

### Quick Reference

```bash
stablestack              # Run checks (auto-detects source dirs)
stablestack --fix        # Auto-fix issues where possible
stablestack --watch      # Re-run on file changes
stablestack explain DET001  # Explain a specific rule
stablestack --list-rules    # List all available rules
```

### Slash Commands

StableStack installs Claude Code slash commands:
- `/check` — Run StableStack checks and fix issues
- `/fix-production` — Monitor or fix CI/CD builds until production is green
- `/deterministic-ai` — Add `@cache` decorators to uncached LLM API calls
- `/llm-timing` — Add timing instrumentation to LLM API calls
- `/rate-limiter` — Add Redis-backed rate limiting to endpoints
- `/token-manager` — Migrate hardcoded tokens to encrypted storage with rotation
- `/admin-dashboard` — Wire up admin monitoring dashboard with health checks

### Token Manager

If `stablestack_token_manager/` exists, use it for encrypted OAuth token storage:
```python
from stablestack_token_manager import TokenEncryptor, TokenRotationJob
```

Install with: `stablestack add-token-manager`

### Admin Dashboard

If `stablestack_admin_dashboard/` exists, use it for infrastructure monitoring:
```python
from stablestack_admin_dashboard import router
app.include_router(router, prefix="/api")
```

Install with: `stablestack add-admin-dashboard`

### Rate Limiting

If `stablestack_rate_limiter.py` exists in the project root, decorate endpoints with `@rate_limit`:
```python
from stablestack_rate_limiter import rate_limit

@rate_limit(requests=100, window=60)
async def my_endpoint(request):
    ...
```

Install with: `stablestack add-rate-limiter`

### LLM Timing Instrumentation

If `stablestack_timer.py` exists in the project root, wrap LLM API calls with `ChatTimer`:
```python
from stablestack_timer import ChatTimer

timer = ChatTimer("operation_name")
with timer.measure("llm_call"):
    response = client.chat.completions.create(...)
timer.report()
```

Install with: `stablestack add-llm-timing`

### Deterministic AI Caching

If `stablestack_cache.py` exists in the project root, wrap LLM API calls with `@cache`:
```python
from stablestack_cache import cache

@cache
def call_llm(prompt: str) -> str:
    return client.chat.completions.create(...)
```

Install with: `stablestack deterministic-ai`

### Key Rules to Follow

**Determinism (DET001-DET008):**
- Always sort dict/set iteration: `for k in sorted(d.keys())`
- Use tiebreakers when sorting: `sorted(items, key=lambda x: (x.priority, x.id))`
- Don't use `list(set(...))` for deduplication — it scrambles order

**Security (SEC001-SEC011):**
- Never hardcode secrets — use environment variables
- Use parameterized queries for SQL, never string concatenation
- Avoid `eval()` and `exec()`

**Quality (QUAL001-QUAL011):**
- Don't silently swallow exceptions with empty except blocks
- Name threads for easier debugging
- Use dataclasses instead of tuples with 3+ elements
'''


def _get_token_manager_command() -> str:
    """Get the /token-manager slash command content."""
    from stablestack.scaffolds.token_manager import get_slash_command
    return get_slash_command()


def _get_admin_dashboard_command() -> str:
    """Get the /admin-dashboard slash command content."""
    from stablestack.scaffolds.admin_dashboard import get_slash_command
    return get_slash_command()


def _get_claude_commands() -> dict[str, str]:
    """Get all Claude Code slash commands to install."""
    return {
        "check": _get_check_command(),
        "fix-production": _get_fix_production_command(),
        "deterministic-ai": _get_deterministic_ai_command(),
        "llm-timing": _get_llm_timing_command(),
        "rate-limiter": _get_rate_limiter_command(),
        "token-manager": _get_token_manager_command(),
        "admin-dashboard": _get_admin_dashboard_command(),
    }


def _get_check_command() -> str:
    """Get the /check slash command content."""
    return '''---
description: "Run StableStack code quality checks on the project"
version: 1.0.0
---

# /check - Run StableStack Checks

<objective>
Run StableStack code quality checkers on the current project and fix the issues found.
</objective>

<usage>
- `/check` — Run all checks
- `/check security` — Run only security checkers (SEC*)
- `/check quality` — Run only quality checkers (QUAL*)
- `/check types` — Run only type safety checkers (TYPE*)
- `/check [rule-id]` — Run a specific checker (e.g., `/check SEC001`)
</usage>

<steps>
1. **Run checks**
   - Execute `stablestack .` (or with category filter if specified)
   - If stablestack is not installed, run `pip install stablestack` first

2. **Review findings**
   - Group by severity: errors first, then warnings, then info
   - Identify patterns (same rule across multiple files)

3. **Fix issues**
   - Start with errors — these are the most impactful
   - For each fix, explain what you changed and why
   - Run `stablestack .` again to verify fixes

4. **Report results**
   - Show summary: how many issues found, how many fixed
   - List any remaining issues that need manual review
</steps>

<common-fixes>
- **DET001**: Add `sorted()` around dict iteration
- **SEC001**: Replace hardcoded secrets with `os.environ.get()`
- **SEC002**: Use parameterized queries instead of string concatenation
- **QUAL001**: Add logging to empty except blocks
- **QUAL004**: Use `None` as default, create mutable inside function body
</common-fixes>
'''


def _get_fix_production_command() -> str:
    """Get the /fix-production slash command content."""
    return '''---
description: "Monitor or trigger a production build and don\\'t stop until it succeeds"
version: 1.0.0
---

# /fix-production - Get Production Building and Deployed

<objective>
Ensure the production build is passing and deployed successfully. Do whatever it takes — commit, push, monitor, fix — and don't stop until you see a successful production build or you need something from me to get there.
</objective>

<steps>
1. **Detect the CI/CD platform**
   - Look for config files to identify the platform:
     - `.github/workflows/` — GitHub Actions
     - `.gitlab-ci.yml` — GitLab CI
     - `amplify.yml` — AWS Amplify
     - `vercel.json` or `.vercel/` — Vercel
     - `netlify.toml` — Netlify
     - `.circleci/` — CircleCI
     - `Jenkinsfile` — Jenkins
     - `bitbucket-pipelines.yml` — Bitbucket Pipelines
     - `fly.toml` — Fly.io
     - `render.yaml` — Render
   - Check for deployment CLIs available: `gh`, `vercel`, `netlify`, `aws`, `flyctl`, `railway`
   - If you can't determine the platform, ask the user immediately

2. **Check current build status**
   - Use the platform's CLI or API to check if a build is running
   - Platform-specific commands:
     - **GitHub Actions:** `gh run list --limit 5` and `gh run view <id>`
     - **GitLab CI:** `glab ci list` or check via API
     - **AWS Amplify:** `aws amplify list-jobs --app-id <ID> --branch-name <BRANCH> --region <REGION>`
     - **Vercel:** `vercel ls` or check dashboard
     - **Netlify:** `netlify status` or `netlify watch`
     - **Fly.io:** `flyctl status`
   - If a build is running, skip to step 5 (monitor it)

3. **If no build is running, check for changes to push**
   - Run `git status` and `git diff` to see uncommitted changes
   - Identify the deploy branch (usually `main` or `master`)
   - Check if local is ahead of remote: `git log origin/<branch>..HEAD`

4. **Trigger a build**
   - If there are uncommitted changes: review, commit, and push
   - If local is ahead of remote: push to trigger CI
   - If the last build failed: investigate the failure, fix it, commit and push
   - If the last build succeeded and nothing changed: tell the user — production may already be fine
   - Always run the project's build command locally first to catch errors before pushing

5. **Monitor the build**
   - Poll build status every 30-60 seconds
   - Report phase transitions as they happen
   - Platform-specific monitoring:
     - **GitHub Actions:** `gh run watch <id>` or poll `gh run view <id>`
     - **AWS Amplify:** poll `aws amplify get-job`
     - **Vercel:** `vercel ls` or poll deployment status
     - **Netlify:** `netlify watch`

6. **If the build fails**
   - Pull logs and identify the root cause
   - Fix the issue locally
   - Verify the fix by running the build locally
   - Commit and push to trigger a new build
   - Go back to step 5
   - If the same error persists after 2 attempts, pause and ask the user

7. **If the build succeeds**
   - Confirm the deployment is live
   - Report the build ID/URL and deployment status
</steps>

<rules>
- Do NOT stop until you see a successful build or you genuinely need input from the user
- If you can't figure out how to check build status or trigger a build, ask immediately
- Always build locally before pushing to catch errors early
- Make minimal, targeted fixes for build failures — don't refactor unrelated code
- Never force-push or rewrite history on the deploy branch
- If credentials or permissions are missing, ask the user
</rules>
'''


def _auto_init_if_needed(quiet: bool = False) -> None:
    """Auto-initialize StableStack on first run if not yet set up.

    Checks for the presence of .claude/commands/check.md as the marker.
    If missing, runs the equivalent of `stablestack init` silently.
    """
    cwd = Path.cwd()
    marker = cwd / ".claude" / "commands" / "check.md"

    if marker.exists():
        return  # Already initialized

    if not quiet:
        click.echo(f"{CYAN}First run detected — setting up StableStack...{RESET}", err=True)

    created = []

    # Install Claude Code slash commands
    commands_dir = cwd / ".claude" / "commands"
    commands_dir.mkdir(parents=True, exist_ok=True)
    for name, content in _get_claude_commands().items():
        command_path = commands_dir / f"{name}.md"
        if not command_path.exists():
            command_path.write_text(content)
            created.append(f".claude/commands/{name}.md")

    # Add StableStack section to CLAUDE.md
    claude_md_path = cwd / "CLAUDE.md"
    claude_section = _get_claude_md_section()
    if claude_md_path.exists():
        existing = claude_md_path.read_text()
        if "stablestack" not in existing.lower():
            with open(claude_md_path, "a") as f:
                f.write("\n\n" + claude_section)
            created.append("CLAUDE.md")
    else:
        claude_md_path.write_text(f"# Project Documentation\n\n{claude_section}")
        created.append("CLAUDE.md")

    if created and not quiet:
        click.echo(
            f"{GREEN}Installed:{RESET} {', '.join(created)}\n"
            f"  Slash commands available: {CYAN}/check{RESET}, {CYAN}/fix-production{RESET}, {CYAN}/deterministic-ai{RESET}\n",
            err=True,
        )


def _show_init_summary(created_files: list[str]) -> None:
    """Show summary after init."""
    click.echo()
    if created_files:
        click.echo(f"{BOLD}StableStack initialized!{RESET}")
        click.echo()
        click.echo("What was set up:")
        for f in created_files:
            click.echo(f"  {GREEN}+{RESET} {f}")
        click.echo()
        click.echo("Next steps:")
        click.echo(f"  Run {CYAN}stablestack{RESET} to check your code")
        click.echo(f"  Use {CYAN}/check{RESET} in Claude Code to run checks and fix issues")
        click.echo(f"  Use {CYAN}/fix-production{RESET} in Claude Code to monitor CI/CD builds")
    else:
        click.echo("StableStack was already configured — nothing to do.")


@main.command("explain")
@click.argument("rule_id")
@click.option("--for-ai", is_flag=True, help="Format explanation for AI assistants")
@click.option("--no-color", is_flag=True, help="Disable colored output")
def explain_command(rule_id: str, for_ai: bool, no_color: bool) -> None:
    """Show detailed explanation of a rule.

    \b
    Examples:
      stablestack explain DET001
      stablestack explain SEC001 --for-ai
    """
    from stablestack.checkers import get_all_checkers

    # Find the checker
    rule_id_upper = rule_id.upper()
    checker_cls = None
    for cls in get_all_checkers():
        if cls.rule_id == rule_id_upper:
            checker_cls = cls
            break

    if not checker_cls:
        click.echo(f"Unknown rule: {rule_id}", err=True)
        click.echo(f"Run 'stablestack --list-rules' to see available rules.", err=True)
        sys.exit(1)

    if no_color:
        b, d, c, r, g, y, rst = "", "", "", "", "", "", ""
    else:
        b, d, c, r, g, y, rst = BOLD, DIM, CYAN, RED, GREEN, YELLOW, RESET

    tier = getattr(checker_cls, 'tier', Tier.RECOMMENDED)
    severity = getattr(checker_cls, 'severity', Severity.ERROR)

    if for_ai:
        # Concise format for AI
        click.echo(f"# {checker_cls.rule_id}: {checker_cls.rule_name}")
        click.echo()
        click.echo(f"**Severity:** {severity.value}")
        click.echo(f"**Tier:** {tier.name}")
        click.echo()
        click.echo(f"## What it detects")
        click.echo(checker_cls.description)
        click.echo()
        click.echo(f"## Why it matters")
        click.echo(checker_cls.why)
        click.echo()
        click.echo(f"## How to fix")
        click.echo(_get_fix_examples(checker_cls.rule_id))
    else:
        # Human-readable format
        click.echo()
        click.echo(f"{b}{c}{checker_cls.rule_id}{rst} {b}{checker_cls.rule_name}{rst}")
        click.echo()
        click.echo(f"{b}Severity:{rst} {severity.value}")
        click.echo(f"{b}Tier:{rst}     {tier.name}")
        click.echo()
        click.echo(f"{b}What it detects:{rst}")
        click.echo(f"  {checker_cls.description}")
        click.echo()
        click.echo(f"{b}Why it matters:{rst}")
        # Word wrap the 'why' text
        why_lines = checker_cls.why.split(". ")
        for line in why_lines:
            if line:
                click.echo(f"  {line.strip()}.")
        click.echo()
        click.echo(f"{b}How to fix:{rst}")
        click.echo(_get_fix_examples(checker_cls.rule_id))
        click.echo()


def _get_fix_examples(rule_id: str) -> str:
    """Get fix examples for a rule."""
    examples = {
        "DET001": '''
  Before:
    for key in config.keys():
        process(key)

  After:
    for key in sorted(config.keys()):
        process(key)''',
        "DET002": '''
  Before:
    for item in my_set:
        process(item)

  After:
    for item in sorted(my_set):
        process(item)''',
        "DET003": '''
  Before:
    sorted(items, key=lambda x: x.priority)

  After:
    sorted(items, key=lambda x: (x.priority, x.id))''',
        "DET004": '''
  Before:
    unique_items = list(set(items))

  After:
    unique_items = list(dict.fromkeys(items))''',
        "SEC001": '''
  Before:
    API_KEY = "sk-1234567890"

  After:
    API_KEY = os.environ.get("API_KEY")''',
        "SEC002": '''
  Before:
    query = f"SELECT * FROM users WHERE id = {user_id}"

  After:
    query = "SELECT * FROM users WHERE id = ?"
    cursor.execute(query, (user_id,))''',
        "QUAL001": '''
  Before:
    try:
        risky_operation()
    except:
        pass

  After:
    try:
        risky_operation()
    except Exception as e:
        logger.error(f"Operation failed: {e}")''',
    }
    return examples.get(rule_id, "  See rule documentation for fix examples.")


@main.command("hook")
@click.argument("action", type=click.Choice(["install", "uninstall"]))
@click.option("--pre-push", is_flag=True, help="Use pre-push hook instead of pre-commit")
def hook_command(action: str, pre_push: bool) -> None:
    """Manage git hooks for stablestack.

    \b
    Examples:
      stablestack hook install        # Install pre-commit hook
      stablestack hook install --pre-push  # Install pre-push hook
      stablestack hook uninstall      # Remove hooks
    """
    git_dir = Path.cwd() / ".git"
    if not git_dir.exists():
        click.echo(f"{RED}Error: Not a git repository{RESET}", err=True)
        sys.exit(1)

    hooks_dir = git_dir / "hooks"
    hooks_dir.mkdir(exist_ok=True)

    hook_name = "pre-push" if pre_push else "pre-commit"
    hook_path = hooks_dir / hook_name

    if action == "install":
        hook_content = f'''#!/bin/sh
# stablestack {hook_name} hook

echo "Running stablestack..."
stablestack . --ci --quiet

if [ $? -ne 0 ]; then
    echo ""
    echo "stablestack found errors. Run 'stablestack .' for details."
    echo "To bypass this hook, use --no-verify"
    exit 1
fi
'''
        hook_path.write_text(hook_content)
        hook_path.chmod(0o755)
        click.echo(f"{GREEN}Installed {hook_name} hook{RESET}")
        click.echo(f"stablestack will run before each {hook_name.replace('-', ' ')}.")

    elif action == "uninstall":
        if hook_path.exists():
            # Check if it's our hook
            content = hook_path.read_text()
            if "stablestack" in content:
                hook_path.unlink()
                click.echo(f"{GREEN}Removed {hook_name} hook{RESET}")
            else:
                click.echo(f"{YELLOW}Hook exists but wasn't created by stablestack{RESET}")
        else:
            click.echo(f"No {hook_name} hook found")


@main.command("activate")
@click.argument("key")
def activate_command(key: str) -> None:
    """Activate a paid license and download compiled checkers.

    \b
    Examples:
      stablestack activate sk_live_abc123...
    """
    from stablestack.licensing import (
        validate_license_server,
        save_license_cache,
        invalidate_license_cache,
        LicenseError,
    )
    from stablestack.checkers.downloader import download_checker_bundle, DownloadError
    from stablestack.checkers.loader import invalidate_cache as invalidate_checker_cache

    click.echo(f"Validating license key...")

    # 1. Validate key with server
    try:
        data = validate_license_server(key)
    except LicenseError as e:
        click.echo(f"{RED}Activation failed: {e}{RESET}", err=True)
        sys.exit(1)

    # 2. Save license locally
    save_license_cache(key, data)
    invalidate_license_cache()

    customer = data.get("customer_name", "")
    license_type = data.get("license_type", "individual")
    click.echo(f"{GREEN}License valid!{RESET} ({license_type})")
    if customer:
        click.echo(f"  Licensed to: {customer}")

    # 3. Download compiled checker bundle
    download_url = data.get("download_url", "")
    if download_url:
        click.echo(f"\nDownloading paid checkers...")
        try:
            checkers_dir = download_checker_bundle(download_url)
            invalidate_checker_cache()

            from stablestack.checkers.loader import load_paid_checkers
            paid = load_paid_checkers()
            click.echo(f"{GREEN}Unlocked {len(paid)} additional checkers.{RESET}")
        except DownloadError as e:
            click.echo(
                f"{YELLOW}Warning: Could not download checkers: {e}{RESET}\n"
                f"  Your license is saved. Try running {CYAN}stablestack activate {key[:20]}...{RESET} again.",
                err=True,
            )
    else:
        click.echo(
            f"\n{YELLOW}No checker bundle available for this platform yet.{RESET}\n"
            f"  License saved. Paid checkers will load when a bundle becomes available.",
        )

    click.echo(f"\nRun {CYAN}stablestack status{RESET} to see your setup.")


@main.command("status")
def status_command() -> None:
    """Show version, license status, and checker counts.

    \b
    Examples:
      stablestack status
    """
    from stablestack import __version__
    from stablestack.checkers import FREE_CHECKERS, get_all_checkers
    from stablestack.licensing import get_cached_license, load_license_cache

    click.echo(f"{BOLD}StableStack v{__version__}{RESET}\n")

    # License info
    is_licensed, license_info, error = get_cached_license()
    if is_licensed and license_info:
        click.echo(f"  License:  {GREEN}{license_info.license_type}{RESET}")
        click.echo(f"  Customer: {license_info.customer_name or '—'}")
        if license_info.expires_at is not None:
            days_left = (license_info.expires_at - __import__('datetime').datetime.now()).days
            click.echo(f"  Expires:  {license_info.expires_at.date()} ({days_left} days)")
        else:
            click.echo(f"  Expires:  Never")
    else:
        click.echo(f"  License:  {YELLOW}Free tier{RESET}")
        if error:
            click.echo(f"  Reason:   {DIM}{error}{RESET}")

    # Checker counts
    all_checkers = get_all_checkers()
    free_count = len(FREE_CHECKERS)
    paid_count = len(all_checkers) - free_count

    click.echo()
    click.echo(f"  Free checkers:  {free_count}")
    click.echo(f"  Paid checkers:  {paid_count}")
    click.echo(f"  Total loaded:   {len(all_checkers)}")

    if not is_licensed:
        click.echo(
            f"\n  Run {CYAN}stablestack activate <key>{RESET} to unlock paid checkers."
        )


@main.command("mcp-server")
@click.option(
    "--transport", "-t",
    type=click.Choice(["stdio", "sse"]),
    default="stdio",
    help="Transport type (default: stdio)",
)
@click.option(
    "--port", "-p",
    type=int,
    default=None,
    help="Port for SSE transport (default: 8080)",
)
def mcp_server_command(transport: str, port: Optional[int]) -> None:
    """Start stablestack as an MCP (Model Context Protocol) server.

    This allows AI assistants like Claude to use stablestack as a tool.

    \b
    Examples:
      stablestack mcp-server              # Start with stdio transport
      stablestack mcp-server --transport sse --port 8080  # Start with SSE

    \b
    MCP Tools exposed:
      stablestack_run        Run checks on paths
      stablestack_explain    Get rule explanation
      stablestack_fix        Apply auto-fix for a finding
      stablestack_list_rules List available rules
    """
    try:
        from stablestack.mcp_server import run_server
        run_server(transport=transport, port=port)
    except ImportError as e:
        click.echo(
            f"{RED}Error: MCP server requires the 'mcp' package.{RESET}\n"
            f"Install with: pip install 'stablestack[mcp]' or pip install mcp\n"
            f"Details: {e}",
            err=True,
        )
        sys.exit(1)


@main.command("deterministic-ai")
@click.option("--force", is_flag=True, help="Overwrite existing files")
def deterministic_ai_command(force: bool) -> None:
    """Install deterministic AI caching for LLM calls.

    Installs a self-contained cache module that eliminates redundant LLM API
    calls. Same prompt = same result, served from disk cache.

    \b
    What gets installed:
      - stablestack_cache.py — Drop-in @cache decorator for any function
      - .cache/ added to .gitignore
      - .claude/commands/deterministic-ai.md slash command

    \b
    Examples:
      stablestack deterministic-ai          # Install cache module
      stablestack deterministic-ai --force  # Overwrite existing files
    """
    cwd = Path.cwd()
    created = []

    # 1. Write stablestack_cache.py
    cache_module_path = cwd / "stablestack_cache.py"
    if cache_module_path.exists() and not force:
        click.echo(f"{YELLOW}stablestack_cache.py already exists (use --force to overwrite){RESET}")
    else:
        cache_module_path.write_text(_get_cache_module_content())
        click.echo(f"{GREEN}Created stablestack_cache.py{RESET}")
        created.append("stablestack_cache.py")

    # 2. Add .cache/ to .gitignore
    gitignore_path = cwd / ".gitignore"
    if gitignore_path.exists():
        content = gitignore_path.read_text()
        if ".cache/" not in content and ".cache\n" not in content:
            with open(gitignore_path, "a") as f:
                f.write("\n# StableStack deterministic AI cache\n.cache/\n")
            click.echo(f"{GREEN}Added .cache/ to .gitignore{RESET}")
            created.append(".gitignore")
        else:
            click.echo(f"{DIM}.cache/ already in .gitignore{RESET}")
    else:
        gitignore_path.write_text("# StableStack deterministic AI cache\n.cache/\n")
        click.echo(f"{GREEN}Created .gitignore with .cache/{RESET}")
        created.append(".gitignore")

    # 3. Install slash command
    commands_dir = cwd / ".claude" / "commands"
    commands_dir.mkdir(parents=True, exist_ok=True)
    command_path = commands_dir / "deterministic-ai.md"
    if command_path.exists() and not force:
        click.echo(f"{YELLOW}.claude/commands/deterministic-ai.md already exists{RESET}")
    else:
        command_path.write_text(_get_deterministic_ai_command())
        click.echo(f"{GREEN}Created .claude/commands/deterministic-ai.md{RESET}")
        created.append(".claude/commands/deterministic-ai.md")

    # 4. Show usage instructions
    click.echo()
    click.echo(f"{BOLD}Deterministic AI caching installed!{RESET}")
    click.echo()
    click.echo("Usage:")
    click.echo(f"  {CYAN}from stablestack_cache import cache{RESET}")
    click.echo()
    click.echo(f"  {CYAN}@cache{RESET}")
    click.echo(f"  {CYAN}def call_llm(prompt: str) -> str:{RESET}")
    click.echo(f"  {CYAN}    return client.chat.completions.create(...){RESET}")
    click.echo()
    click.echo("Same arguments = same result from disk. No API call.")
    click.echo()
    click.echo("Utilities:")
    click.echo(f"  {CYAN}from stablestack_cache import clear_cache, get_cache_stats{RESET}")
    click.echo(f"  {CYAN}clear_cache()          {DIM}# Wipe all cached results{RESET}")
    click.echo(f"  {CYAN}get_cache_stats()      {DIM}# See cache size and file counts{RESET}")
    click.echo()
    click.echo(f"Use {CYAN}/deterministic-ai{RESET} in Claude Code to add caching to existing LLM calls.")


def _get_deterministic_ai_command() -> str:
    """Get the /deterministic-ai slash command content."""
    return '''---
description: "Add deterministic caching to LLM API calls in your project"
version: 1.0.0
---

# /deterministic-ai - Cache LLM API Calls

<objective>
Find all uncached LLM API calls in the project and wrap them with the @cache decorator from stablestack_cache. This eliminates redundant API calls — same prompt, same result, served from disk.
</objective>

<steps>
1. **Verify cache module is installed**
   - Check that `stablestack_cache.py` exists in the project root
   - If not, run `stablestack deterministic-ai` to install it

2. **Find uncached LLM calls**
   - Search for patterns: `.chat.completions.create(`, `.messages.create(`, `.completions.create(`
   - Identify which calls are NOT wrapped with `@cache`

3. **Add caching**
   - For each uncached LLM call, extract it into a cached function if inline
   - Add `from stablestack_cache import cache` to the file's imports
   - Add `@cache` decorator to the function containing the LLM call
   - If the function does more than just the LLM call, extract the LLM call into its own cached helper

4. **Verify**
   - Run `stablestack .` to confirm DET009 findings are resolved
   - Test that cached functions work: first call hits API, second call returns from cache
</steps>

<example>
Before:
```python
def get_summary(text: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": f"Summarize: {text}"}]
    )
    return response.choices[0].message.content
```

After:
```python
from stablestack_cache import cache

@cache
def get_summary(text: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4",
        messages=[{"role": "user", "content": f"Summarize: {text}"}]
    )
    return response.choices[0].message.content
```
</example>

<rules>
- Only cache pure LLM calls where the same input should give the same output
- Don't cache calls that intentionally need different results (e.g., creative generation with temperature > 0)
- Don't cache streaming responses — only cache calls that return complete responses
- Always add `@cache` to the innermost function that wraps the LLM call
</rules>
'''


def _get_cache_module_content() -> str:
    """Get the full stablestack_cache.py module content.

    This is a self-contained, zero-dependency cache module that customers
    drop into their project. Ported from signal-mesh's disk_cache.py,
    simplified to remove file tracking, external logging, and stats dataclasses.
    """
    return '''"""
Deterministic disk cache for expensive function calls.

Drop-in decorator that caches function results to disk using pickle.
Same arguments = same result, served from .cache/ directory.

Usage:
    from stablestack_cache import cache, clear_cache, get_cache_stats

    @cache
    def call_llm(prompt: str) -> str:
        return client.chat.completions.create(...)

    @cache("my_subdir")
    def expensive_function(x, y):
        ...

    clear_cache()           # Wipe all cached results
    get_cache_stats()       # Dict with cache size info

Installed by: stablestack deterministic-ai
"""

import os
import pickle
import hashlib
import json
import re
import sys
import io
from pathlib import Path
from functools import wraps
from typing import Any, Callable, Optional, TypeVar, cast

F = TypeVar('F', bound=Callable[..., Any])

_VERBOSE = os.environ.get('STABLESTACK_CACHE_VERBOSE', '').lower() in ('1', 'true', 'yes')


def _log(msg: str) -> None:
    """Print cache debug message if verbose mode is enabled."""
    if _VERBOSE:
        print(f"[cache] {msg}", file=sys.stderr)


class DiskCache:
    """Persistent disk-based cache for function results."""

    def __init__(self, cache_dir: str = ".cache"):
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(exist_ok=True)

    def _create_cache_key(self, *args: Any, **kwargs: Any) -> str:
        """Create a unique cache key from function arguments."""
        cache_data = {
            'args': [str(arg) for arg in args],
            'kwargs': {k: str(v) for k, v in sorted(kwargs.items(), key=lambda x: (x[0], str(x[1])))}
        }
        cache_string = json.dumps(cache_data, sort_keys=True)
        return hashlib.md5(cache_string.encode()).hexdigest()

    def _check_for_memory_addresses(self, cache_string: str, func_name: str) -> bool:
        """Check if cache string contains memory addresses and warn if found."""
        memory_pattern = r'(?:0x[0-9a-fA-F]{8,}|at 0x[0-9a-fA-F]+)'
        if re.search(memory_pattern, cache_string):
            _log(
                f"WARNING: Cache key for '{func_name}' contains memory addresses. "
                f"This will prevent cache reuse across runs. Consider using @staticmethod "
                f"or ensure the cache decorator properly skips 'self' parameter."
            )
            return True
        return False

    def decorator(self, cache_subdir: Optional[str] = None) -> Callable[[F], F]:
        """Decorator to cache function results to disk."""
        def cache_decorator(func: F) -> F:
            if cache_subdir is not None:
                subdir = cache_subdir
            else:
                if hasattr(func, '__qualname__'):
                    subdir = func.__qualname__
                else:
                    subdir = func.__name__

            cache_path = self.cache_dir / subdir

            @wraps(func)
            def wrapper(*args: Any, **kwargs: Any) -> Any:
                cache_args = args

                # Detect if this is a method (not a static method) and skip self/cls
                is_method = '.' in func.__qualname__
                is_static = False

                if is_method and args:
                    first_arg = args[0]
                    if isinstance(first_arg, (int, float, str, bool, list, dict, tuple)):
                        is_static = True
                    elif not hasattr(first_arg, '__dict__'):
                        is_static = True

                if is_method and not is_static and args:
                    cache_args = args[1:]

                cache_key = self._create_cache_key(*cache_args, **kwargs)
                cache_file = cache_path / f"{func.__name__}_{cache_key}.pkl"

                # Check for memory addresses in cache key
                if not is_static and is_method:
                    cache_data = {
                        'args': [str(arg) for arg in cache_args],
                        'kwargs': {k: str(v) for k, v in sorted(kwargs.items(), key=lambda x: (x[0], str(x[1])))}
                    }
                    cache_string = json.dumps(cache_data, sort_keys=True)
                    self._check_for_memory_addresses(cache_string, func.__name__)

                # Try to load from cache
                if cache_file.exists():
                    try:
                        with open(cache_file, 'rb') as f:
                            cached_data = pickle.load(f)

                        if isinstance(cached_data, dict) and 'result' in cached_data:
                            result = cached_data['result']
                            cached_stdout = cached_data.get('stdout', '')
                            cached_stderr = cached_data.get('stderr', '')

                            if cached_stdout:
                                print(cached_stdout, end='')
                            if cached_stderr:
                                print(cached_stderr, end='', file=sys.stderr)
                        else:
                            result = cached_data

                        _log(f"Cache hit for {func.__name__}")
                        return result
                    except Exception as e:
                        _log(f"Cache read error for {func.__name__}: {e}, will re-compute")

                # Cache miss - compute result
                _log(f"Cache miss for {func.__name__} - computing...")

                old_stdout = sys.stdout
                old_stderr = sys.stderr
                stdout_buffer = io.StringIO()
                stderr_buffer = io.StringIO()
                sys.stdout = stdout_buffer
                sys.stderr = stderr_buffer
                try:
                    result = func(*args, **kwargs)
                finally:
                    captured_stdout = stdout_buffer.getvalue()
                    captured_stderr = stderr_buffer.getvalue()
                    sys.stdout = old_stdout
                    sys.stderr = old_stderr
                    if captured_stdout:
                        print(captured_stdout, end='')
                    if captured_stderr:
                        print(captured_stderr, end='', file=sys.stderr)

                # Save to cache
                try:
                    cache_path.mkdir(parents=True, exist_ok=True)
                    cache_data = {
                        'result': result,
                        'stdout': captured_stdout,
                        'stderr': captured_stderr,
                    }
                    with open(cache_file, 'wb') as f:
                        pickle.dump(cache_data, f)
                    _log(f"Cached result for {func.__name__}")
                except Exception as e:
                    _log(f"Cache write error for {func.__name__}: {e}")

                return result
            return cast(F, wrapper)
        return cache_decorator

    def clear_cache(self, function_name: Optional[str] = None, cache_subdir: Optional[str] = None) -> None:
        """Clear cached results."""
        if cache_subdir:
            cache_path = self.cache_dir / cache_subdir
        else:
            cache_path = self.cache_dir

        if not cache_path.exists():
            _log(f"No cache found at {cache_path}")
            return

        files_removed = 0
        if function_name:
            pattern = f"{function_name}_*.pkl"
            for cache_file in cache_path.glob(pattern):
                cache_file.unlink()
                files_removed += 1
        else:
            for cache_file in cache_path.rglob("*.pkl"):
                cache_file.unlink()
                files_removed += 1
        _log(f"Cleared {files_removed} cache files")

    def list_cache_info(self) -> None:
        """Display information about cached results."""
        if not self.cache_dir.exists():
            print("No cache directory found")
            return

        print("Cache Information:")
        for subdir in sorted(self.cache_dir.iterdir()):
            if subdir.is_dir():
                cache_files = list(subdir.glob("*.pkl"))
                if cache_files:
                    print(f"  {subdir.name}/")
                    for cache_file in cache_files:
                        size_mb = cache_file.stat().st_size / (1024 * 1024)
                        print(f"    {cache_file.name} ({size_mb:.2f} MB)")

        total_files = len(list(self.cache_dir.rglob("*.pkl")))
        if total_files > 0:
            total_size = sum(f.stat().st_size for f in self.cache_dir.rglob("*.pkl")) / (1024 * 1024)
            print(f"Total: {total_files} cached files, {total_size:.2f} MB")
        else:
            print("No cache files found")

    def get_cache_stats(self) -> dict:
        """Get cache statistics as a plain dict."""
        if not self.cache_dir.exists():
            return {"total_files": 0, "total_size_mb": 0.0, "subdirs": {}}

        subdirs = {}
        for subdir in self.cache_dir.iterdir():
            if subdir.is_dir():
                cache_files = list(subdir.glob("*.pkl"))
                if cache_files:
                    subdir_size = sum(f.stat().st_size for f in cache_files) / (1024 * 1024)
                    subdirs[subdir.name] = {
                        "files": len(cache_files),
                        "size_mb": round(subdir_size, 2),
                    }

        total_files = len(list(self.cache_dir.rglob("*.pkl")))
        total_size = sum(f.stat().st_size for f in self.cache_dir.rglob("*.pkl")) / (1024 * 1024)

        return {
            "total_files": total_files,
            "total_size_mb": round(total_size, 2),
            "subdirs": subdirs,
        }


# Global cache instance
_default_cache = DiskCache()

F = TypeVar('F', bound=Callable[..., Any])


def cache(func_or_subdir: Any = None) -> Any:
    """Standalone cache decorator. Supports @cache, @cache(), and @cache("subdir").

    Usage:
        @cache
        def my_function():
            ...

        @cache()
        def another_function():
            ...

        @cache("custom_subdir")
        def special_function():
            ...
    """
    if callable(func_or_subdir):
        return _default_cache.decorator(None)(func_or_subdir)
    else:
        return _default_cache.decorator(func_or_subdir)


def clear_cache(function_name: Optional[str] = None, cache_subdir: Optional[str] = None) -> None:
    """Clear cached results using default cache instance."""
    _default_cache.clear_cache(function_name, cache_subdir)


def list_cache_info() -> None:
    """Display cache information using default cache instance."""
    _default_cache.list_cache_info()


def get_cache_stats() -> dict:
    """Get cache statistics using default cache instance."""
    return _default_cache.get_cache_stats()
'''


@main.command("add-llm-timing")
@click.option("--force", is_flag=True, help="Overwrite existing files")
def add_llm_timing_command(force: bool) -> None:
    """Install LLM timing instrumentation for latency visibility.

    Installs a self-contained timing module with ChatTimer context manager,
    console waterfall reports, and HTML visualization.

    \b
    What gets installed:
      - stablestack_timer.py — ChatTimer with measure() context manager
      - .stablestack/timing/ added to .gitignore
      - .claude/commands/llm-timing.md slash command

    \b
    Examples:
      stablestack add-llm-timing          # Install timing module
      stablestack add-llm-timing --force  # Overwrite existing files
    """
    cwd = Path.cwd()
    created = []

    # 1. Write stablestack_timer.py
    timer_module_path = cwd / "stablestack_timer.py"
    if timer_module_path.exists() and not force:
        click.echo(f"{YELLOW}stablestack_timer.py already exists (use --force to overwrite){RESET}")
    else:
        timer_module_path.write_text(_get_timer_module_content())
        click.echo(f"{GREEN}Created stablestack_timer.py{RESET}")
        created.append("stablestack_timer.py")

    # 2. Add .stablestack/timing/ to .gitignore
    gitignore_path = cwd / ".gitignore"
    ignore_entry = ".stablestack/timing/"
    if gitignore_path.exists():
        content = gitignore_path.read_text()
        if ignore_entry not in content:
            with open(gitignore_path, "a") as f:
                f.write(f"\n# StableStack LLM timing reports\n{ignore_entry}\n")
            click.echo(f"{GREEN}Added {ignore_entry} to .gitignore{RESET}")
            created.append(".gitignore")
        else:
            click.echo(f"{DIM}{ignore_entry} already in .gitignore{RESET}")
    else:
        gitignore_path.write_text(f"# StableStack LLM timing reports\n{ignore_entry}\n")
        click.echo(f"{GREEN}Created .gitignore with {ignore_entry}{RESET}")
        created.append(".gitignore")

    # 3. Install slash command
    commands_dir = cwd / ".claude" / "commands"
    commands_dir.mkdir(parents=True, exist_ok=True)
    command_path = commands_dir / "llm-timing.md"
    if command_path.exists() and not force:
        click.echo(f"{YELLOW}.claude/commands/llm-timing.md already exists{RESET}")
    else:
        command_path.write_text(_get_llm_timing_command())
        click.echo(f"{GREEN}Created .claude/commands/llm-timing.md{RESET}")
        created.append(".claude/commands/llm-timing.md")

    # 4. Show usage instructions
    click.echo()
    click.echo(f"{BOLD}LLM timing instrumentation installed!{RESET}")
    click.echo()
    click.echo("Usage:")
    click.echo(f"  {CYAN}from stablestack_timer import ChatTimer{RESET}")
    click.echo()
    click.echo(f"  {CYAN}timer = ChatTimer('summarize'){RESET}")
    click.echo(f"  {CYAN}with timer.measure('prompt_build'):{RESET}")
    click.echo(f"  {CYAN}    messages = build_messages(text){RESET}")
    click.echo(f"  {CYAN}with timer.measure('llm_call'):{RESET}")
    click.echo(f"  {CYAN}    response = client.chat.completions.create(...){RESET}")
    click.echo(f"  {CYAN}with timer.measure('parse_response'):{RESET}")
    click.echo(f"  {CYAN}    result = parse(response){RESET}")
    click.echo(f"  {CYAN}timer.report()           {DIM}# ASCII waterfall in terminal{RESET}")
    click.echo(f"  {CYAN}timer.render_html_report() {DIM}# Opens browser visualization{RESET}")
    click.echo()
    click.echo(f"Use {CYAN}/llm-timing{RESET} in Claude Code to instrument existing LLM calls.")


def _get_llm_timing_command() -> str:
    """Get the /llm-timing slash command content."""
    return '''---
description: "Add timing instrumentation to LLM API calls"
version: 1.0.0
---

# /llm-timing - Instrument LLM API Calls

<objective>
Find all uninstrumented LLM API calls in the project and wrap them with ChatTimer
from stablestack_timer. This makes latency visible — you'll see exactly where time
goes across prompt building, LLM calls, and response parsing.
</objective>

<steps>
1. **Verify timing module is installed**
   - Check that `stablestack_timer.py` exists in the project root
   - If not, run `stablestack add-llm-timing` to install it

2. **Find uninstrumented LLM calls**
   - Search for patterns: `.chat.completions.create(`, `.messages.create(`, `.completions.create(`
   - Also check for LangChain: `.invoke(`, `.ainvoke(` on chain/llm/model/agent objects
   - Identify calls NOT wrapped in a `with timer.measure()` block

3. **Add timing instrumentation**
   - For each uninstrumented call:
     a. Create a `ChatTimer` instance at the start of the function/method
     b. Wrap prompt building in `with timer.measure("prompt_build"):`
     c. Wrap the LLM API call in `with timer.measure("llm_call"):`
     d. Wrap response parsing in `with timer.measure("parse_response"):`
     e. Add `timer.report()` after all phases
   - Add `from stablestack_timer import ChatTimer` to imports

4. **Verify**
   - Run `stablestack .` to confirm PERF002 findings are resolved
   - Test that timing reports appear in the console on execution
</steps>

<example>
Before:
```python
def summarize(text: str) -> str:
    messages = [{"role": "user", "content": f"Summarize: {text}"}]
    response = client.chat.completions.create(
        model="gpt-4",
        messages=messages
    )
    return response.choices[0].message.content
```

After:
```python
from stablestack_timer import ChatTimer

def summarize(text: str) -> str:
    timer = ChatTimer("summarize")
    with timer.measure("prompt_build"):
        messages = [{"role": "user", "content": f"Summarize: {text}"}]
    with timer.measure("llm_call"):
        response = client.chat.completions.create(
            model="gpt-4",
            messages=messages
        )
    with timer.measure("parse_response"):
        result = response.choices[0].message.content
    timer.report()
    return result
```
</example>

<rules>
- Always separate prompt building, LLM call, and response parsing into distinct phases
- Use descriptive operation names for ChatTimer (e.g., "summarize", "classify", "extract")
- Use consistent phase names: "prompt_build", "llm_call", "parse_response"
- For additional phases (retries, validation), use descriptive names like "retry_1", "validate"
- Add timer.report() at the end of the function for console output
- For async functions, the same pattern works — ChatTimer.measure() is a sync context manager
</rules>
'''


def _get_timer_module_content() -> str:
    """Get the full stablestack_timer.py module content.

    Self-contained, zero-dependency timing module with ChatTimer context
    manager, ASCII console reports, and HTML waterfall visualization.
    """
    return '''"""
LLM call timing instrumentation.

Zero-dependency module for measuring where time goes in LLM operations.
Wraps phases (prompt building, API calls, response parsing) and produces
console waterfall reports and self-contained HTML visualizations.

Usage:
    from stablestack_timer import ChatTimer

    timer = ChatTimer("summarize")
    with timer.measure("prompt_build"):
        messages = build_messages(text)
    with timer.measure("llm_call", category="llm", metadata={"model": "gpt-4"}):
        response = client.chat.completions.create(...)
    with timer.measure("parse_response"):
        result = parse(response)
    timer.report()              # ASCII waterfall in terminal
    timer.render_html_report()  # Opens HTML in browser

Installed by: stablestack add-llm-timing
"""

import time
import os
import json
import webbrowser
import html as html_module
from contextlib import contextmanager
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import List, Optional, Dict, Any


BOTTLENECK_THRESHOLD_MS = 1000

CATEGORY_COLORS = {
    "llm": {"console": "\\033[96m", "html": "#06b6d4", "label": "LLM"},
    "db": {"console": "\\033[94m", "html": "#3b82f6", "label": "SQL/DB"},
    "stream": {"console": "\\033[35m", "html": "#a78bfa", "label": "Stream"},
    "guardrail": {"console": "\\033[91m", "html": "#f87171", "label": "Guardrail"},
    "context": {"console": "\\033[95m", "html": "#ec4899", "label": "Context"},
    "other": {"console": "\\033[90m", "html": "#94a3b8", "label": "Other"},
}


@dataclass
class Phase:
    """A single timed phase within an operation."""
    name: str
    start_ms: float = 0.0
    end_ms: float = 0.0
    category: str = "other"
    metadata: Dict[str, Any] = field(default_factory=dict)

    @property
    def duration_ms(self) -> float:
        return self.end_ms - self.start_ms

    @property
    def is_bottleneck(self) -> bool:
        return self.duration_ms > BOTTLENECK_THRESHOLD_MS


class ChatTimer:
    """Context-manager-based timer for LLM operation phases.

    Usage:
        timer = ChatTimer("summarize")
        with timer.measure("prompt_build"):
            ...
        with timer.measure("llm_call", category="llm", metadata={"model": "gpt-4"}):
            ...
        timer.report()
    """

    def __init__(self, operation: str):
        self.operation = operation
        self.phases: List[Phase] = []
        self._start_time: float = time.monotonic()

    @contextmanager
    def measure(self, phase_name: str, category: str = "other", metadata: Optional[Dict[str, Any]] = None):
        """Time a phase of the operation.

        Args:
            phase_name: Human-readable name for this phase.
            category: Phase type for color-coding. One of: llm, db, stream,
                      guardrail, context, other.
            metadata: Arbitrary key/value data (model name, token count, etc.).
        """
        origin = self._start_time
        phase = Phase(name=phase_name, category=category, metadata=metadata or {})
        phase.start_ms = (time.monotonic() - origin) * 1000
        try:
            yield phase
        finally:
            phase.end_ms = (time.monotonic() - origin) * 1000
            self.phases.append(phase)

    @property
    def total_ms(self) -> float:
        if not self.phases:
            return 0.0
        return max(p.end_ms for p in self.phases) - min(p.start_ms for p in self.phases)

    @property
    def sum_of_phases_ms(self) -> float:
        """Sum of all phase durations. Exceeds total_ms when phases overlap."""
        return sum(p.duration_ms for p in self.phases)

    @property
    def parallelism_pct(self) -> float:
        """sum_of_phases_ms / total_ms * 100. >100% means parallelism detected."""
        total = self.total_ms
        if total <= 0:
            return 0.0
        return self.sum_of_phases_ms / total * 100

    @property
    def categories(self) -> Dict[str, List["Phase"]]:
        """Map of category name to list of phases in that category."""
        result: Dict[str, List[Phase]] = {}
        for p in self.phases:
            result.setdefault(p.category, []).append(p)
        return result

    @property
    def bottlenecks(self) -> List[Phase]:
        return [p for p in self.phases if p.is_bottleneck]

    def to_dict(self) -> Dict[str, Any]:
        """JSON-serializable representation."""
        return {
            "operation": self.operation,
            "total_ms": round(self.total_ms, 1),
            "sum_of_phases_ms": round(self.sum_of_phases_ms, 1),
            "parallelism_pct": round(self.parallelism_pct, 1),
            "phases": [
                {
                    "name": p.name,
                    "start_ms": round(p.start_ms, 1),
                    "end_ms": round(p.end_ms, 1),
                    "duration_ms": round(p.duration_ms, 1),
                    "is_bottleneck": p.is_bottleneck,
                    "category": p.category,
                    "metadata": p.metadata,
                }
                for p in self.phases
            ],
        }

    @staticmethod
    def _fmt_offset(ms: float) -> str:
        """Format a millisecond offset for display (e.g. 0ms, 312ms, 3.12s)."""
        if ms < 1000:
            return f"{ms:.0f}ms"
        return f"{ms / 1000:.2f}s"

    def report(self) -> None:
        """Print an ASCII waterfall report to the console."""
        if not self.phases:
            print(f"LLM Timing: {self.operation} (no phases recorded)")
            return

        total = self.total_ms
        print(f"\\nLLM Timing: {self.operation} ({total:.0f}ms)")

        # Find longest phase name and offset for alignment
        max_name = max(len(p.name) for p in self.phases)
        offsets = [self._fmt_offset(p.start_ms) for p in self.phases]
        max_offset = max(len(o) for o in offsets)
        bar_width = 16

        reset = "\\033[0m"

        for phase, offset_str in zip(self.phases, offsets):
            duration = phase.duration_ms
            pct = (duration / total * 100) if total > 0 else 0
            filled = int(bar_width * duration / total) if total > 0 else 0
            bar = "\\u2588" * filled + "\\u2591" * (bar_width - filled)

            # Category dot color
            cat_info = CATEGORY_COLORS.get(phase.category, CATEGORY_COLORS["other"])
            cat_color = cat_info["console"]

            # Bar color: green < 500, orange < 1000, yellow < 2000, red >= 2000
            if duration < 500:
                bar_color = "\\033[92m"  # green
            elif duration < 1000:
                bar_color = "\\033[33m"  # orange
            elif duration < 2000:
                bar_color = "\\033[93m"  # yellow
            else:
                bar_color = "\\033[91m"  # red

            bottleneck = "  \\u26a0 bottleneck" if phase.is_bottleneck else ""
            name_padded = phase.name.ljust(max_name)
            offset_padded = ("@ " + offset_str).ljust(max_offset + 2)
            print(
                f"  {cat_color}\\u25cf{reset} {name_padded}  {offset_padded}"
                f"  {bar_color}{bar}{reset}"
                f"  {duration:>6.0f}ms  {pct:>5.1f}%{bottleneck}"
            )

        # Sum of phases footer
        sum_ms = self.sum_of_phases_ms
        par_pct = self.parallelism_pct
        print(f"  Sum of phases: {sum_ms:.0f}ms ({par_pct:.1f}%)")
        print()

    def render_html_report(self, open_browser: bool = True) -> Path:
        """Generate a self-contained HTML waterfall report.

        Saves to .stablestack/timing/<operation>_<timestamp>.html
        and optionally opens it in the default browser.

        Returns the path to the generated file.
        """
        timing_dir = Path(".stablestack") / "timing"
        timing_dir.mkdir(parents=True, exist_ok=True)

        timestamp = int(time.time())
        filename = f"{self.operation}_{timestamp}.html"
        filepath = timing_dir / filename

        html_content = self._build_html()
        filepath.write_text(html_content)

        if open_browser:
            webbrowser.open(f"file://{filepath.resolve()}")

        return filepath

    def _build_html(self) -> str:
        """Build a self-contained HTML waterfall visualization."""
        total = self.total_ms
        data = self.to_dict()
        phases_json = json.dumps(data["phases"])
        op_name = html_module.escape(self.operation)
        sum_ms = data["sum_of_phases_ms"]
        par_pct = data["parallelism_pct"]

        cat_colors_json = json.dumps({k: v["html"] for k, v in CATEGORY_COLORS.items()})
        cat_labels_json = json.dumps({k: v["label"] for k, v in CATEGORY_COLORS.items()})

        return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Timing: {op_name}</title>
<style>
  * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  body {{
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
    background: #0f172a; color: #e2e8f0; padding: 2rem;
    min-height: 100vh;
  }}
  .header {{
    margin-bottom: 2rem; border-bottom: 1px solid #334155; padding-bottom: 1rem;
    display: flex; align-items: baseline; gap: 1rem;
  }}
  .header-icon {{ font-size: 1.75rem; }}
  .header-text h1 {{ font-size: 1.5rem; color: #f8fafc; }}
  .header-text .total {{ color: #06b6d4; font-size: 1.75rem; font-weight: 700; }}
  .header-text .brand {{ color: #64748b; font-size: 0.75rem; margin-top: 0.25rem; }}
  .overview-bar {{
    background: #1e293b; border-radius: 8px; padding: 1.25rem 1.5rem;
    margin-bottom: 1.5rem;
  }}
  .overview-bar h2 {{ font-size: 0.875rem; color: #94a3b8; margin-bottom: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }}
  .stacked-bar {{
    height: 32px; border-radius: 6px; overflow: hidden; display: flex;
    background: #0f172a;
  }}
  .stacked-segment {{
    height: 100%; display: flex; align-items: center; justify-content: center;
    font-size: 0.7rem; color: #fff; font-weight: 600;
    transition: width 0.3s ease; min-width: 1px;
  }}
  .legend {{
    display: flex; gap: 1.25rem; margin-top: 0.75rem; flex-wrap: wrap;
  }}
  .legend-item {{
    display: flex; align-items: center; gap: 0.375rem;
    font-size: 0.8rem; color: #94a3b8;
  }}
  .legend-dot {{
    width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
  }}
  .timeline {{
    background: #1e293b; border-radius: 8px; padding: 1.5rem;
    margin-bottom: 1.5rem;
  }}
  .timeline h2 {{ font-size: 0.875rem; color: #94a3b8; margin-bottom: 1rem; text-transform: uppercase; letter-spacing: 0.05em; }}
  .phase-row {{
    display: flex; align-items: center; margin-bottom: 0.75rem;
    gap: 0.75rem;
  }}
  .phase-dot {{
    width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
  }}
  .phase-name {{
    width: 150px; text-align: right; font-size: 0.875rem;
    color: #cbd5e1; flex-shrink: 0;
  }}
  .phase-offset {{
    width: 70px; font-size: 0.75rem; color: #64748b; flex-shrink: 0;
    font-family: monospace;
  }}
  .bar-container {{
    flex: 1; height: 28px; background: #0f172a; border-radius: 4px;
    position: relative; overflow: hidden;
  }}
  .bar {{
    height: 100%; border-radius: 4px; position: absolute;
    display: flex; align-items: center; padding-left: 8px;
    font-size: 0.75rem; color: #fff; font-weight: 600;
    min-width: 2px; transition: width 0.3s ease;
  }}
  .phase-stats {{
    width: 180px; font-size: 0.8rem; color: #94a3b8; flex-shrink: 0;
  }}
  .bottleneck-badge {{
    display: inline-block; background: #451a03; color: #fbbf24;
    font-size: 0.7rem; padding: 2px 6px; border-radius: 4px;
    margin-left: 0.5rem;
  }}
  .summary {{
    background: #1e293b; border-radius: 8px; padding: 1.25rem 1.5rem;
    margin-bottom: 1.5rem; font-size: 0.875rem; color: #94a3b8;
    display: flex; gap: 2rem;
  }}
  .summary strong {{ color: #e2e8f0; }}
  .bottleneck-section {{
    background: #1e293b; border-radius: 8px; padding: 1.5rem;
    border-left: 3px solid #f59e0b;
  }}
  .bottleneck-section h2 {{ font-size: 1rem; color: #fbbf24; margin-bottom: 0.75rem; }}
  .bottleneck-item {{
    display: flex; justify-content: space-between;
    padding: 0.5rem 0; border-bottom: 1px solid #334155;
    font-size: 0.875rem;
  }}
  .bottleneck-item:last-child {{ border-bottom: none; }}
  .severity-green {{ color: #4ade80; }}
  .severity-orange {{ color: #fb923c; }}
  .severity-yellow {{ color: #fbbf24; }}
  .severity-red {{ color: #f87171; }}
  .meta-tooltip {{
    position: relative; cursor: help;
  }}
  .meta-tooltip .meta-content {{
    display: none; position: absolute; bottom: 100%; left: 0;
    background: #334155; border: 1px solid #475569; border-radius: 6px;
    padding: 0.5rem 0.75rem; font-size: 0.75rem; color: #e2e8f0;
    white-space: pre; z-index: 10; min-width: 200px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.3);
  }}
  .meta-tooltip:hover .meta-content {{ display: block; }}
  .meta-icon {{
    color: #64748b; font-size: 0.7rem; margin-left: 4px;
    vertical-align: middle;
  }}
</style>
</head>
<body>

<div class="header">
  <div class="header-icon">\\u23f1\\ufe0f</div>
  <div class="header-text">
    <h1>Wall-Time Waterfall</h1>
    <div class="total">{total:.0f}ms</div>
    <div class="brand">Generated by StableStack &middot; {op_name}</div>
  </div>
</div>

<div class="overview-bar">
  <h2>Time Distribution by Category</h2>
  <div class="stacked-bar" id="stacked-bar"></div>
  <div class="legend" id="legend"></div>
</div>

<div class="timeline">
  <h2>Phase Timeline</h2>
  <div id="phases"></div>
</div>

<div class="summary">
  <div>Sum of phases: <strong>{sum_ms:.0f}ms ({par_pct:.1f}%)</strong></div>
  <div>Wall time: <strong>{total:.0f}ms</strong></div>
</div>

<div id="bottleneck-container"></div>

<script>
(function() {{
  const phases = {phases_json};
  const total = {total:.1f};
  const catColors = {cat_colors_json};
  const catLabels = {cat_labels_json};

  function severityClass(ms) {{
    if (ms < 500) return 'severity-green';
    if (ms < 1000) return 'severity-orange';
    if (ms < 2000) return 'severity-yellow';
    return 'severity-red';
  }}

  function fmtOffset(ms) {{
    if (ms < 1000) return '@ ' + ms.toFixed(0) + 'ms';
    return '@ ' + (ms / 1000).toFixed(2) + 's';
  }}

  // --- Stacked overview bar ---
  var catTotals = {{}};
  phases.forEach(function(p) {{
    catTotals[p.category] = (catTotals[p.category] || 0) + p.duration_ms;
  }});
  var stackedBar = document.getElementById('stacked-bar');
  var legendEl = document.getElementById('legend');
  var usedCats = [];
  Object.keys(catTotals).forEach(function(cat) {{
    usedCats.push(cat);
    var pct = total > 0 ? (catTotals[cat] / total * 100) : 0;
    var seg = document.createElement('div');
    seg.className = 'stacked-segment';
    seg.style.width = Math.max(pct, 0.5) + '%';
    seg.style.background = catColors[cat] || catColors['other'];
    if (pct > 8) seg.textContent = Math.round(pct) + '%';
    stackedBar.appendChild(seg);
  }});
  usedCats.forEach(function(cat) {{
    var item = document.createElement('div');
    item.className = 'legend-item';
    var dot = document.createElement('div');
    dot.className = 'legend-dot';
    dot.style.background = catColors[cat] || catColors['other'];
    var label = document.createTextNode((catLabels[cat] || cat) + ' (' + catTotals[cat].toFixed(0) + 'ms)');
    item.appendChild(dot);
    item.appendChild(label);
    legendEl.appendChild(item);
  }});

  // --- Phase rows ---
  var container = document.getElementById('phases');

  phases.forEach(function(p) {{
    var pct = total > 0 ? (p.duration_ms / total * 100) : 0;
    var leftPct = total > 0 ? (p.start_ms / total * 100) : 0;
    var widthPct = total > 0 ? (p.duration_ms / total * 100) : 0;
    var color = catColors[p.category] || catColors['other'];

    var row = document.createElement('div');
    row.className = 'phase-row';

    var dot = document.createElement('div');
    dot.className = 'phase-dot';
    dot.style.background = color;

    var name = document.createElement('div');
    name.className = 'phase-name';
    name.textContent = p.name;

    var offset = document.createElement('div');
    offset.className = 'phase-offset';
    offset.textContent = fmtOffset(p.start_ms);

    var barContainer = document.createElement('div');
    barContainer.className = 'bar-container';

    var bar = document.createElement('div');
    bar.className = 'bar';
    bar.style.left = leftPct + '%';
    bar.style.width = Math.max(widthPct, 0.5) + '%';
    bar.style.background = color;
    if (widthPct > 8) bar.textContent = p.duration_ms.toFixed(0) + 'ms';

    barContainer.appendChild(bar);

    var stats = document.createElement('div');
    stats.className = 'phase-stats';
    var statsHtml = '<span class="' + severityClass(p.duration_ms) + '">'
      + p.duration_ms.toFixed(0) + 'ms</span> (' + pct.toFixed(1) + '%)';
    if (p.is_bottleneck) {{
      statsHtml += '<span class="bottleneck-badge">\\u26a0 bottleneck</span>';
    }}
    // Metadata tooltip
    if (p.metadata && Object.keys(p.metadata).length > 0) {{
      var metaLines = Object.keys(p.metadata).map(function(k) {{
        return k + ': ' + p.metadata[k];
      }}).join('\\n');
      statsHtml += '<span class="meta-tooltip"><span class="meta-icon">\\u2139\\ufe0f</span>'
        + '<span class="meta-content">' + metaLines + '</span></span>';
    }}
    stats.innerHTML = statsHtml;

    row.appendChild(dot);
    row.appendChild(name);
    row.appendChild(offset);
    row.appendChild(barContainer);
    row.appendChild(stats);
    container.appendChild(row);
  }});

  // --- Bottleneck section ---
  var bottlenecks = phases.filter(function(p) {{ return p.is_bottleneck; }});
  if (bottlenecks.length > 0) {{
    var bc = document.getElementById('bottleneck-container');
    var section = document.createElement('div');
    section.className = 'bottleneck-section';
    var html = '<h2>\\u26a0 Bottleneck Analysis</h2>';
    bottlenecks.forEach(function(p) {{
      var pct = total > 0 ? (p.duration_ms / total * 100) : 0;
      html += '<div class="bottleneck-item">'
        + '<span>' + p.name + '</span>'
        + '<span class="severity-red">' + p.duration_ms.toFixed(0)
        + 'ms (' + pct.toFixed(1) + '% of total)</span>'
        + '</div>';
    }});
    section.innerHTML = html;
    bc.appendChild(section);
  }}
}})();
</script>
</body>
</html>"""
'''


@main.command("notifications")
@click.option("--uninstall", is_flag=True, help="Remove notification hooks")
def notifications_command(uninstall: bool) -> None:
    """Set up sound & visual notifications for Claude Code.

    Installs macOS notification hooks so you hear distinct sounds when Claude
    needs permission, finishes a task, or compacts context. Click any
    notification to bring your terminal to the foreground.

    \b
    Requires macOS and terminal-notifier (installed via Homebrew).

    \b
    Examples:
      stablestack notifications            # Set up notifications
      stablestack notifications --uninstall  # Remove notification hooks
    """
    import platform
    import shutil
    import subprocess

    if platform.system() != "Darwin":
        click.echo(f"{RED}Notifications are currently macOS-only.{RESET}", err=True)
        sys.exit(1)

    settings_path = Path.home() / ".claude" / "settings.json"

    if uninstall:
        _remove_notification_hooks(settings_path)
        return

    # 1. Check for terminal-notifier
    if not shutil.which("terminal-notifier"):
        click.echo(f"terminal-notifier is not installed.")
        click.echo(f"  Install it with: {CYAN}brew install terminal-notifier{RESET}")
        click.echo()
        try:
            click.confirm("Install it now?", default=True)
            click.echo("Installing terminal-notifier...")
            result = subprocess.run(
                ["brew", "install", "terminal-notifier"],
                capture_output=True, text=True, timeout=120,
            )
            if result.returncode != 0:
                click.echo(f"{RED}Installation failed:{RESET} {result.stderr.strip()}", err=True)
                sys.exit(1)
            click.echo(f"{GREEN}Installed terminal-notifier{RESET}")
        except (click.Abort, KeyboardInterrupt):
            click.echo(f"\nInstall it manually: {CYAN}brew install terminal-notifier{RESET}")
            sys.exit(1)
        except FileNotFoundError:
            click.echo(f"{RED}Homebrew not found.{RESET} Install terminal-notifier manually.", err=True)
            sys.exit(1)

    # 2. Detect terminal
    bundle_id = _detect_terminal_bundle_id()
    click.echo(f"Detected terminal: {CYAN}{bundle_id}{RESET}")

    # 3. Add hooks to settings.json
    _install_notification_hooks(settings_path, bundle_id)

    # 4. Test notification
    click.echo()
    click.echo(f"{BOLD}Notifications installed!{RESET}")
    click.echo()
    click.echo("You'll now get:")
    click.echo(f"  {CYAN}Funk{RESET}   — Claude needs permission (action required)")
    click.echo(f"  {CYAN}Hero{RESET}   — Task complete (ready for input)")
    click.echo(f"  {CYAN}Sosumi{RESET} — Context compaction (no action needed)")
    click.echo()
    click.echo(f"Tip: Go to {CYAN}System Settings → Notifications → terminal-notifier{RESET}")
    click.echo(f"     and change Banners to {BOLD}Alerts{RESET} to make notifications persist.")
    click.echo()

    # Send a test notification
    subprocess.run(
        [
            "terminal-notifier",
            "-title", "Claude Code",
            "-subtitle", f"Notifications enabled [{Path.cwd().name}]",
            "-message", "You'll hear sounds when Claude needs you.",
            "-sound", "Hero",
            "-activate", bundle_id,
        ],
        capture_output=True,
    )
    click.echo(f"{GREEN}Test notification sent!{RESET}")


def _detect_terminal_bundle_id() -> str:
    """Detect the current terminal's bundle ID."""
    import subprocess

    term_program = os.environ.get("TERM_PROGRAM", "")

    known_ids = {
        "iTerm.app": "com.googlecode.iterm2",
        "Apple_Terminal": "com.apple.Terminal",
        "ghostty": "com.mitchellh.ghostty",
        "WezTerm": "com.github.wez.wezterm",
        "vscode": "com.microsoft.VSCode",
    }

    if term_program in known_ids:
        return known_ids[term_program]

    # Try osascript to detect from common terminal names
    for app_name, bundle_id in [
        ("Ghostty", "com.mitchellh.ghostty"),
        ("iTerm", "com.googlecode.iterm2"),
        ("Kitty", "net.kovidgoyal.kitty"),
        ("Warp", "dev.warp.Warp-Stable"),
        ("Alacritty", "org.alacritty"),
        ("WezTerm", "com.github.wez.wezterm"),
    ]:
        try:
            result = subprocess.run(
                ["osascript", "-e", f'id of app "{app_name}"'],
                capture_output=True, text=True, timeout=5,
            )
            if result.returncode == 0 and result.stdout.strip():
                return result.stdout.strip()
        except (subprocess.TimeoutExpired, FileNotFoundError):
            continue

    # Fallback
    return "com.apple.Terminal"


def _install_notification_hooks(settings_path: Path, bundle_id: str) -> None:
    """Add notification hooks to Claude Code settings."""
    settings_path.parent.mkdir(parents=True, exist_ok=True)

    # Install the dashboard status script
    _install_dashboard_status_script(settings_path.parent)

    if settings_path.exists():
        try:
            settings = json.loads(settings_path.read_text())
        except (json.JSONDecodeError, OSError):
            settings = {}
    else:
        settings = {}

    hooks = settings.get("hooks", {})
    status_cmd = "python3 ~/.claude/hooks/update-status.py"

    # Build notification hooks (sound + visual + dashboard status)
    notification_hooks = [
        {
            "matcher": "permission",
            "hooks": [
                {
                    "type": "command",
                    "command": f"terminal-notifier -title 'Claude Code' -subtitle \"Permission Request [$(basename $PWD)]\" -message 'Approval needed — check your terminal' -sound Funk -activate {bundle_id}",
                },
                {
                    "type": "command",
                    "command": f"{status_cmd} permission",
                },
            ],
        },
        {
            "matcher": "",
            "hooks": [
                {
                    "type": "command",
                    "command": f"terminal-notifier -title 'Claude Code' -subtitle \"Done [$(basename $PWD)]\" -message 'Task complete — ready for your input' -sound Hero -activate {bundle_id}",
                },
                {
                    "type": "command",
                    "command": f"{status_cmd} done",
                },
            ],
        },
    ]

    hooks["Notification"] = notification_hooks

    # Compaction hooks (PreCompact fires before context compaction — separate event from Notification)
    hooks["PreCompact"] = [{"matcher": "", "hooks": [
        {
            "type": "command",
            "command": f"terminal-notifier -title 'Claude Code' -subtitle \"Compaction [$(basename $PWD)]\" -message 'Compacting context window — no action needed' -sound Sosumi -activate {bundle_id}",
        },
        {
            "type": "command",
            "command": f"{status_cmd} compact",
        },
    ]}]

    # Tool tracking hooks (for dashboard)
    hooks["PreToolUse"] = [{"matcher": "", "hooks": [
        {"type": "command", "command": f"{status_cmd} tool_start"},
    ]}]
    hooks["PostToolUse"] = [{"matcher": "", "hooks": [
        {"type": "command", "command": f"{status_cmd} tool_end"},
    ]}]
    hooks["Stop"] = [{"hooks": [
        {"type": "command", "command": f"{status_cmd} idle"},
    ]}]
    hooks["UserPromptSubmit"] = [{"hooks": [
        {"type": "command", "command": f"{status_cmd} working"},
    ]}]

    settings["hooks"] = hooks

    settings_path.write_text(json.dumps(settings, indent=2) + "\n")
    click.echo(f"{GREEN}Updated {settings_path}{RESET}")


def _install_dashboard_status_script(claude_dir: Path) -> None:
    """Install the dashboard status hook script."""
    hooks_dir = claude_dir / "hooks"
    hooks_dir.mkdir(parents=True, exist_ok=True)
    dashboard_dir = claude_dir / "dashboard"
    dashboard_dir.mkdir(parents=True, exist_ok=True)

    # Copy the full status script
    script_path = hooks_dir / "update-status.py"
    script_content = (Path(__file__).parent / "hooks" / "update-status.py").read_text() \
        if (Path(__file__).parent / "hooks" / "update-status.py").exists() \
        else _get_status_script_content()
    script_path.write_text(script_content)
    script_path.chmod(0o755)


def _get_status_script_content() -> str:
    """Fallback: return the status script content inline."""
    return '''#!/usr/bin/env python3
"""Write Claude Code instance status to ~/.claude/dashboard/."""

import hashlib, json, os, sys, time

event = sys.argv[1] if len(sys.argv) > 1 else "unknown"
pwd = os.environ.get("PWD", os.getcwd())
key = hashlib.md5(pwd.encode()).hexdigest()[:12]
dashboard_dir = os.path.expanduser("~/.claude/dashboard")
os.makedirs(dashboard_dir, exist_ok=True)
status_file = os.path.join(dashboard_dir, f"{key}.json")

existing = {}
try:
    with open(status_file) as f:
        existing = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
    pass

hook_data = {}
tool_name = ""
tool_detail = ""
try:
    if not sys.stdin.isatty():
        stdin_text = sys.stdin.read(8192)
        if stdin_text.strip():
            hook_data = json.loads(stdin_text)
            tool_name = hook_data.get("tool_name", "")
            tool_input = hook_data.get("tool_input", {})
            if tool_name == "Bash":
                cmd = tool_input.get("command", "")
                tool_detail = cmd.split("\\n")[0][:80] if cmd else ""
            elif tool_name in ("Read", "Write", "Edit"):
                fp = tool_input.get("file_path", "")
                tool_detail = os.path.basename(fp) if fp else ""
            elif tool_name in ("Grep", "Glob"):
                tool_detail = tool_input.get("pattern", "")[:60]
            elif tool_name == "Task":
                tool_detail = tool_input.get("description", "")[:60]
            elif tool_name == "WebSearch":
                tool_detail = tool_input.get("query", "")[:60]
except Exception:
    pass

iterm_sid = os.environ.get("ITERM_SESSION_ID", "")
entry = {"pwd": pwd, "project": os.path.basename(pwd),
         "ts": int(time.time()), "pid": os.getppid(),
         "iterm_session_id": iterm_sid or existing.get("iterm_session_id", ""),
         "last_tool": existing.get("last_tool", ""),
         "last_detail": existing.get("last_detail", "")}

if event == "tool_start":
    entry.update(status="working", tool=tool_name, detail=tool_detail,
                 message=f"{tool_name}", last_tool=tool_name, last_detail=tool_detail)
elif event == "tool_end":
    entry.update(status="working", tool="", detail="", message="Thinking...")
    if tool_name: entry["last_tool"] = tool_name
elif event == "working":
    entry.update(status="working", tool="", detail="", message="Processing prompt...")
elif event == "idle" or event == "done":
    entry.update(status="idle", tool="", detail="", message="Waiting for input")
elif event == "permission":
    entry.update(status="permission", tool=existing.get("tool", ""),
                 detail=existing.get("detail", ""), message="Approval needed")
elif event == "compact":
    entry.update(status="compact", tool="", detail="", message="Compacting context")

with open(status_file, "w") as f:
    json.dump(entry, f)
'''


def _remove_notification_hooks(settings_path: Path) -> None:
    """Remove notification hooks from Claude Code settings."""
    if not settings_path.exists():
        click.echo("No settings file found — nothing to remove.")
        return

    try:
        settings = json.loads(settings_path.read_text())
    except (json.JSONDecodeError, OSError):
        click.echo(f"{RED}Could not read settings file.{RESET}", err=True)
        return

    hooks = settings.get("hooks", {})
    if "Notification" in hooks:
        del hooks["Notification"]
        if not hooks:
            del settings["hooks"]
        else:
            settings["hooks"] = hooks
        settings_path.write_text(json.dumps(settings, indent=2) + "\n")
        click.echo(f"{GREEN}Removed notification hooks from {settings_path}{RESET}")
    else:
        click.echo("No notification hooks found — nothing to remove.")


@main.command("dashboard")
@click.option("--once", is_flag=True, help="Print once and exit (no live refresh)")
def dashboard_command(once: bool) -> None:
    """Live dashboard showing all running Claude Code instances.

    Reads status from notification hooks to display which Claude instances
    are active, what they're doing, and when they last reported.

    \b
    Requires: stablestack notifications (sets up the status hooks)

    \b
    Examples:
      stablestack dashboard         # Live-updating dashboard
      stablestack dashboard --once  # Print current status and exit
    """
    import time as _time

    dashboard_dir = Path.home() / ".claude" / "dashboard"

    if not dashboard_dir.exists():
        click.echo(f"{YELLOW}No dashboard data found.{RESET}")
        click.echo(f"Run {CYAN}stablestack notifications{RESET} first to set up status hooks.")
        sys.exit(1)

    def _is_pid_alive(pid: int) -> bool:
        """Check if a process is still running."""
        try:
            os.kill(pid, 0)
            return True
        except (OSError, ProcessLookupError):
            return False

    def _time_ago(ts: int) -> str:
        """Human-readable time delta."""
        delta = int(_time.time()) - ts
        if delta < 0:
            return "just now"
        if delta < 5:
            return "just now"
        if delta < 60:
            return f"{delta}s ago"
        if delta < 3600:
            return f"{delta // 60}m ago"
        if delta < 86400:
            return f"{delta // 3600}h ago"
        return f"{delta // 86400}d ago"

    STATUS_ICONS = {
        "permission": f"{YELLOW}\u25c9{RESET}",  # ◉ yellow — action needed
        "working": f"{CYAN}\u25cf{RESET}",        # ● cyan — actively working
        "idle": f"{GREEN}\u25cf{RESET}",           # ● green — ready
        "compact": f"{BLUE}\u25cf{RESET}",           # ● blue — compacting
        "unknown": f"{DIM}?{RESET}",
    }

    def _status_label(entry: dict) -> str:
        """Build a rich status label from entry data."""
        status = entry["status"]
        tool = entry.get("tool", "")
        detail = entry.get("detail", "")
        message = entry.get("message", "")

        if status == "permission":
            return f"{YELLOW}Permission needed{RESET}"
        if status == "working" and tool:
            # Show human-readable tool label + detail
            tool_labels = {
                "Bash": "Running",
                "Read": "Reading",
                "Write": "Writing",
                "Edit": "Editing",
                "Grep": "Searching",
                "Glob": "Finding",
                "Task": "Agent",
                "WebSearch": "Searching web",
                "WebFetch": "Fetching",
                "LSP": "Analyzing",
            }
            label = tool_labels.get(tool, tool)
            detail_str = f" {DIM}{detail}{RESET}" if detail else ""
            return f"{CYAN}{label}{RESET}{detail_str}"
        if status == "working":
            return f"{CYAN}{message or 'Thinking...'}{RESET}"
        if status == "idle":
            return f"{GREEN}Idle{RESET}"
        if status == "compact":
            return f"{BLUE}Compacting...{RESET}"
        return f"{DIM}{message or 'Unknown'}{RESET}"

    def _render() -> str:
        """Read dashboard files and render the display."""
        entries = []
        stale_files = []

        for f in sorted(dashboard_dir.glob("*.json")):
            try:
                data = json.loads(f.read_text())
            except (json.JSONDecodeError, OSError):
                continue

            pid = data.get("pid", 0)
            alive = _is_pid_alive(pid) if pid else False
            age = int(_time.time()) - data.get("ts", 0)

            # Clean up entries older than 24h or with dead processes older than 5min
            if age > 86400 or (not alive and age > 300):
                stale_files.append(f)
                continue

            entries.append({
                "project": data.get("project", "?"),
                "pwd": data.get("pwd", "?"),
                "status": data.get("status", "unknown"),
                "tool": data.get("tool", ""),
                "detail": data.get("detail", ""),
                "message": data.get("message", ""),
                "last_tool": data.get("last_tool", ""),
                "last_detail": data.get("last_detail", ""),
                "last_prompt": data.get("last_prompt", ""),
                "iterm_session_id": data.get("iterm_session_id", ""),
                "ts": data.get("ts", 0),
                "pid": pid,
                "alive": alive,
            })

        # Clean up stale files
        for f in stale_files:
            try:
                f.unlink()
            except OSError:
                pass

        if not entries:
            return f"\n  {DIM}No active Claude Code instances.{RESET}\n"

        entries.sort(key=lambda e: e["project"].lower())

        # Calculate column widths
        max_project = max(len(e["project"]) for e in entries)
        max_project = max(max_project, 7)

        lines = []
        active = sum(1 for e in entries if e["alive"])
        working = sum(1 for e in entries if e["alive"] and e["status"] == "working")
        header_detail = f"{working} working, " if working else ""
        lines.append(f"  {BOLD}Claude Code Dashboard{RESET}  {DIM}{header_detail}{active} active{RESET}")
        lines.append(f"  {DIM}{'─' * 72}{RESET}")

        for idx, e in enumerate(entries):
            icon = STATUS_ICONS.get(e["status"], STATUS_ICONS["unknown"])
            label = _status_label(e)
            ago = _time_ago(e["ts"])
            alive_marker = "" if e["alive"] else f" {DIM}(exited){RESET}"

            project = e["project"].ljust(max_project)
            if idx < 9:
                hotkey = str(idx + 1)
            elif idx < 35:
                hotkey = chr(ord('a') + idx - 9)
            else:
                hotkey = ""
            hotkey_label = f"{DIM}[{hotkey}]{RESET} " if hotkey else "    "

            # Main line: hotkey, icon, project, status
            lines.append(f"  {hotkey_label}{icon} {BOLD}{project}{RESET}  {label}")

            # Detail line: last prompt + working directory
            prompt = e.get("last_prompt", "")
            if prompt:
                prompt_display = prompt[:70] + "..." if len(prompt) > 70 else prompt
                lines.append(f"       {DIM}\"{prompt_display}\"{RESET}")
            pwd_short = e["pwd"].replace(str(Path.home()), "~")
            detail_parts = [f"{DIM}{pwd_short}{RESET}"]
            detail_parts.append(f"{DIM}{ago}{RESET}{alive_marker}")
            sep = " \u00b7 "
            lines.append(f"       {sep.join(detail_parts)}")

        lines.append(f"  {DIM}{'─' * 72}{RESET}")
        lines.append(f"  {DIM}Press 1-9/a-z to jump to instance \u00b7 Ctrl+C to exit{RESET}")
        lines.append("")
        return "\n".join(lines)

    def _switch_to_iterm_session(iterm_session_id: str) -> bool:
        """Use AppleScript to find and activate an iTerm2 session by its unique ID."""
        import subprocess
        if not iterm_session_id:
            return False
        # Extract GUID from ITERM_SESSION_ID (format: w0t0p0:GUID)
        guid = iterm_session_id.split(":")[-1] if ":" in iterm_session_id else iterm_session_id
        script = f'''
tell application "iTerm2"
    activate
    repeat with w in windows
        tell w
            repeat with t in tabs
                tell t
                    repeat with s in sessions
                        tell s
                            if id is "{guid}" then
                                select
                                tell t to select
                                set index of w to 1
                                return
                            end if
                        end tell
                    end repeat
                end tell
            end repeat
        end tell
    end repeat
end tell
'''
        try:
            result = subprocess.run(
                ["osascript", "-e", script],
                capture_output=True, text=True, timeout=5,
            )
            return result.returncode == 0
        except (subprocess.TimeoutExpired, FileNotFoundError):
            return False

    if once:
        click.echo(_render())
        return

    # Live mode — interactive with keyboard input
    import select as _select
    import termios
    import tty

    old_settings = termios.tcgetattr(sys.stdin)
    try:
        tty.setcbreak(sys.stdin.fileno())

        while True:
            # Clear screen and render
            click.echo("\033[2J\033[H", nl=False)
            output = _render()
            click.echo(output)

            # Wait up to 2 seconds for keyboard input
            if _select.select([sys.stdin], [], [], 2)[0]:
                key = sys.stdin.read(1)
                if key == "\x03":  # Ctrl+C
                    break
                idx = -1
                if key.isdigit() and key != "0":
                    idx = int(key) - 1
                elif key.isalpha():
                    idx = ord(key.lower()) - ord('a') + 9

                if idx >= 0:
                    # Re-read entries to get current list
                    current_entries = []
                    for f in sorted(dashboard_dir.glob("*.json")):
                        try:
                            data = json.loads(f.read_text())
                        except (json.JSONDecodeError, OSError):
                            continue
                        pid = data.get("pid", 0)
                        alive = _is_pid_alive(pid) if pid else False
                        age = int(_time.time()) - data.get("ts", 0)
                        if age > 86400 or (not alive and age > 300):
                            continue
                        current_entries.append(data)
                    current_entries.sort(key=lambda e: e.get("project", "").lower())

                    if 0 <= idx < len(current_entries):
                        target = current_entries[idx]
                        click.echo(f"\n  Switching to {BOLD}{target.get('project', '?')}{RESET}...")
                        session_id = target.get("iterm_session_id", "")
                        if not _switch_to_iterm_session(session_id):
                            click.echo(f"  {YELLOW}Could not find matching iTerm2 session.{RESET}")
                            _time.sleep(1)

    except KeyboardInterrupt:
        pass
    finally:
        termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
        click.echo(f"\n{DIM}Dashboard stopped.{RESET}")


@main.command("add-rate-limiter")
@click.option("--force", is_flag=True, help="Overwrite existing files")
def add_rate_limiter_command(force: bool) -> None:
    """Install Redis-backed rate limiting for production endpoints.

    Installs a self-contained rate limiting module with a sliding window
    algorithm backed by Redis sorted sets.

    \b
    What gets installed:
      - stablestack_rate_limiter.py — @rate_limit decorator with Redis backend
      - .claude/commands/rate-limiter.md slash command

    \b
    Examples:
      stablestack add-rate-limiter          # Install rate limiter module
      stablestack add-rate-limiter --force  # Overwrite existing files
    """
    cwd = Path.cwd()
    created = []

    # 1. Write stablestack_rate_limiter.py
    module_path = cwd / "stablestack_rate_limiter.py"
    if module_path.exists() and not force:
        click.echo(f"{YELLOW}stablestack_rate_limiter.py already exists (use --force to overwrite){RESET}")
    else:
        module_path.write_text(_get_rate_limiter_module_content())
        click.echo(f"{GREEN}Created stablestack_rate_limiter.py{RESET}")
        created.append("stablestack_rate_limiter.py")

    # 2. Install slash command
    commands_dir = cwd / ".claude" / "commands"
    commands_dir.mkdir(parents=True, exist_ok=True)
    command_path = commands_dir / "rate-limiter.md"
    if command_path.exists() and not force:
        click.echo(f"{YELLOW}.claude/commands/rate-limiter.md already exists{RESET}")
    else:
        command_path.write_text(_get_rate_limiter_command())
        click.echo(f"{GREEN}Created .claude/commands/rate-limiter.md{RESET}")
        created.append(".claude/commands/rate-limiter.md")

    # 3. Show usage instructions
    click.echo()
    click.echo(f"{BOLD}Redis rate limiter installed!{RESET}")
    click.echo()
    click.echo("Usage:")
    click.echo(f"  {CYAN}from stablestack_rate_limiter import rate_limit{RESET}")
    click.echo()
    click.echo(f"  {CYAN}@rate_limit(requests=100, window=60){RESET}")
    click.echo(f"  {CYAN}async def my_endpoint(request):{RESET}")
    click.echo(f"  {CYAN}    ...{RESET}")
    click.echo()
    click.echo(f"Configure via environment:")
    click.echo(f"  {DIM}REDIS_URL=redis://localhost:6379  (default){RESET}")
    click.echo()
    click.echo(f"Use {CYAN}/rate-limiter{RESET} in Claude Code to instrument existing endpoints.")


def _get_rate_limiter_command() -> str:
    """Get the /rate-limiter slash command content."""
    return '''---
description: "Add Redis-backed rate limiting to API endpoints"
version: 1.0.0
---

# /rate-limiter - Add Rate Limiting to Endpoints

<objective>
Find all endpoints flagged by RATE001 (missing rate limiting) and RATE002 (in-memory rate
limiting) and add the production-ready @rate_limit decorator from stablestack_rate_limiter.
</objective>

<steps>
1. **Verify rate limiter module is installed**
   - Check that `stablestack_rate_limiter.py` exists in the project root
   - If not, run `stablestack add-rate-limiter` to install it

2. **Find endpoints needing rate limiting**
   - Run `stablestack .` and look for RATE001 and RATE002 findings
   - RATE001: endpoints with no rate limiting at all
   - RATE002: endpoints using in-memory rate limiting (dicts/Maps)

3. **Add @rate_limit decorator**
   - For each flagged endpoint:
     a. Add `from stablestack_rate_limiter import rate_limit` to imports
     b. Add `@rate_limit(requests=N, window=W)` decorator above the handler
     c. Choose appropriate limits based on endpoint type:
        - Authentication (login/register): `requests=5, window=60`
        - Password reset: `requests=3, window=300`
        - Search/expensive: `requests=30, window=60`
        - Data creation: `requests=50, window=60`
        - General API: `requests=100, window=60`
   - If replacing an in-memory rate limiter, remove the old dict/Map-based code

4. **Verify**
   - Run `stablestack .` to confirm RATE001/RATE002 findings are resolved
   - Ensure `REDIS_URL` environment variable is documented in project README or .env.example
</steps>

<example>
Before (RATE002 — in-memory):
```python
request_counts = {}

@app.post("/api/login")
async def login(request: Request):
    ip = request.client.host
    if request_counts.get(ip, 0) > 5:
        raise HTTPException(429, "Too many requests")
    request_counts[ip] = request_counts.get(ip, 0) + 1
    ...
```

After (Redis-backed):
```python
from stablestack_rate_limiter import rate_limit

@app.post("/api/login")
@rate_limit(requests=5, window=60)
async def login(request: Request):
    ...
```

Before (RATE001 — no rate limiting):
```python
@app.post("/api/search")
async def search(request: Request):
    ...
```

After:
```python
from stablestack_rate_limiter import rate_limit

@app.post("/api/search")
@rate_limit(requests=30, window=60)
async def search(request: Request):
    ...
```
</example>

<rules>
- Always use the @rate_limit decorator from stablestack_rate_limiter, not in-memory dicts
- Choose rate limits appropriate to the endpoint type (auth endpoints get stricter limits)
- For key_func, default to IP-based; use user ID for authenticated endpoints
- Ensure REDIS_URL is in environment configuration
- The decorator is fail-open: if Redis is unavailable, requests are allowed through
</rules>
'''


def _get_rate_limiter_module_content() -> str:
    """Get the full stablestack_rate_limiter.py module content.

    Self-contained Redis-backed sliding window rate limiter with
    @rate_limit decorator for async HTTP handlers.
    """
    return '''"""
Redis-backed sliding window rate limiter.

Production-ready rate limiting using Redis sorted sets. Handles
multi-instance deployments, survives restarts, and fails open
if Redis is unavailable.

Usage:
    from stablestack_rate_limiter import rate_limit

    @app.post("/api/login")
    @rate_limit(requests=5, window=60)
    async def login(request):
        ...

    # Custom key extraction (e.g., by user ID):
    @rate_limit(requests=100, window=60, key_func=lambda r: r.state.user_id)
    async def api_endpoint(request):
        ...

Environment:
    REDIS_URL — Redis connection string (default: redis://localhost:6379)

Installed by: stablestack add-rate-limiter
"""

import os
import time
import logging
import functools
from typing import Callable, Optional, Any

logger = logging.getLogger("stablestack_rate_limiter")

# Redis connection (lazy-initialized)
_redis_client = None
_redis_unavailable = False

REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379")


def _get_redis():
    """Get or create Redis connection. Returns None if unavailable."""
    global _redis_client, _redis_unavailable

    if _redis_unavailable:
        return None

    if _redis_client is not None:
        return _redis_client

    try:
        import redis
        _redis_client = redis.from_url(REDIS_URL, decode_responses=True)
        _redis_client.ping()
        return _redis_client
    except Exception as e:
        logger.warning("Redis unavailable (%s) — rate limiting disabled (fail-open)", e)
        _redis_unavailable = True
        return None


def _default_key_func(request: Any) -> str:
    """Extract client IP from request for rate limit key."""
    # Support common frameworks
    # FastAPI / Starlette
    if hasattr(request, "client") and request.client:
        return request.client.host

    # Flask
    if hasattr(request, "remote_addr"):
        return request.remote_addr

    # X-Forwarded-For header (common behind proxies)
    if hasattr(request, "headers"):
        forwarded = None
        if hasattr(request.headers, "get"):
            forwarded = request.headers.get("x-forwarded-for")
        if forwarded:
            return forwarded.split(",")[0].strip()

    return "unknown"


def _check_rate_limit(key: str, max_requests: int, window_seconds: int) -> tuple[bool, int]:
    """Check rate limit using Redis sorted set sliding window.

    Returns (allowed, retry_after_seconds).
    """
    r = _get_redis()
    if r is None:
        # Fail open: allow request if Redis is unavailable
        return True, 0

    now = time.time()
    window_start = now - window_seconds
    rate_key = f"stablestack:rate:{key}"

    try:
        pipe = r.pipeline()
        # Remove expired entries
        pipe.zremrangebyscore(rate_key, 0, window_start)
        # Count current entries
        pipe.zcard(rate_key)
        # Add current request
        pipe.zadd(rate_key, {str(now): now})
        # Set TTL on the key
        pipe.expire(rate_key, window_seconds)
        results = pipe.execute()

        current_count = results[1]

        if current_count >= max_requests:
            # Get oldest entry to calculate retry-after
            oldest = r.zrange(rate_key, 0, 0, withscores=True)
            if oldest:
                retry_after = int(oldest[0][1] + window_seconds - now) + 1
                return False, max(retry_after, 1)
            return False, window_seconds

        return True, 0

    except Exception as e:
        logger.warning("Rate limit check failed (%s) — allowing request (fail-open)", e)
        return True, 0


def rate_limit(
    requests: int = 100,
    window: int = 60,
    key_func: Optional[Callable] = None,
):
    """Rate limit decorator for async HTTP handlers.

    Uses a Redis-backed sliding window algorithm. If Redis is unavailable,
    requests are allowed through (fail-open for availability).

    Args:
        requests: Maximum number of requests allowed in the window.
        window: Time window in seconds.
        key_func: Function that extracts a rate limit key from the request.
                  Defaults to client IP extraction.

    Usage:
        @app.post("/api/login")
        @rate_limit(requests=5, window=60)
        async def login(request: Request):
            ...
    """
    _key_func = key_func or _default_key_func

    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            # Find the request object in args/kwargs
            request = kwargs.get("request")
            if request is None and args:
                request = args[0]

            key = _key_func(request) if request else "unknown"
            allowed, retry_after = _check_rate_limit(key, requests, window)

            if not allowed:
                # Return 429 — support multiple frameworks
                try:
                    # FastAPI / Starlette
                    from starlette.responses import JSONResponse
                    return JSONResponse(
                        status_code=429,
                        content={"detail": "Too many requests"},
                        headers={"Retry-After": str(retry_after)},
                    )
                except ImportError:
                    pass

                try:
                    # Flask
                    from flask import jsonify, make_response
                    resp = make_response(jsonify({"error": "Too many requests"}), 429)
                    resp.headers["Retry-After"] = str(retry_after)
                    return resp
                except ImportError:
                    pass

                # Generic: raise an exception
                raise Exception(
                    f"Rate limit exceeded. Retry after {retry_after} seconds."
                )

            return await func(*args, **kwargs)

        return wrapper
    return decorator
'''


@main.command("add-token-manager")
@click.option("--force", is_flag=True, help="Overwrite existing files")
@click.option("--language", "-l", type=click.Choice(["python", "typescript"]), default=None,
              help="Target language (auto-detected if omitted)")
def add_token_manager_command(force: bool, language: str | None) -> None:
    """Install encrypted token storage with automatic rotation.

    Generates a multi-file module for OAuth token encryption, storage,
    and rotation with a provider-agnostic interface.

    \b
    What gets installed:
      - stablestack_token_manager/ — encryption, models, rotation, API
      - .claude/commands/token-manager.md slash command

    \b
    Examples:
      stablestack add-token-manager                  # Auto-detect language
      stablestack add-token-manager -l typescript     # Force TypeScript
      stablestack add-token-manager --force           # Overwrite existing
    """
    from stablestack.scaffolds import detect_language, write_scaffold_files
    from stablestack.scaffolds.token_manager import get_files, get_slash_command, DIR

    cwd = Path.cwd()
    lang = language or detect_language(cwd)
    if lang is None:
        click.echo(f"{YELLOW}Could not auto-detect language. Defaulting to Python.{RESET}")
        click.echo(f"{DIM}Use --language to specify: stablestack add-token-manager -l typescript{RESET}")
        lang = "python"

    click.echo(f"Generating {BOLD}{lang}{RESET} token manager...")

    files = get_files(lang)
    created = write_scaffold_files(cwd, files, force=force)

    for path in created:
        click.echo(f"  {GREEN}Created {path}{RESET}")

    skipped = set(files.keys()) - set(created)
    for path in sorted(skipped):
        click.echo(f"  {YELLOW}{path} already exists (use --force to overwrite){RESET}")

    # Install slash command
    commands_dir = cwd / ".claude" / "commands"
    commands_dir.mkdir(parents=True, exist_ok=True)
    command_path = commands_dir / "token-manager.md"
    if command_path.exists() and not force:
        click.echo(f"  {YELLOW}.claude/commands/token-manager.md already exists{RESET}")
    else:
        command_path.write_text(get_slash_command())
        click.echo(f"  {GREEN}Created .claude/commands/token-manager.md{RESET}")

    click.echo()
    click.echo(f"{BOLD}Token manager installed!{RESET}")
    click.echo()
    if lang == "python":
        click.echo("Usage:")
        click.echo(f"  {CYAN}from stablestack_token_manager import TokenEncryptor, TokenRotationJob{RESET}")
        click.echo()
        click.echo(f"  {CYAN}encryptor = TokenEncryptor()  # reads ENCRYPTION_KEY env var{RESET}")
        click.echo(f"  {CYAN}encrypted = encryptor.encrypt(\"my_oauth_token\"){RESET}")
        click.echo()
        click.echo(f"Required: {DIM}pip install cryptography sqlalchemy fastapi requests{RESET}")
    else:
        click.echo("Usage:")
        click.echo(f"  {CYAN}import {{ TokenEncryptor, TokenRotationJob }} from \"./stablestack_token_manager\";{RESET}")
        click.echo()
        click.echo(f"  {CYAN}const encryptor = new TokenEncryptor(); // reads ENCRYPTION_KEY env var{RESET}")
        click.echo(f"  {CYAN}const encrypted = encryptor.encrypt(\"my_oauth_token\");{RESET}")
        click.echo()
        click.echo(f"Required: {DIM}npm install express{RESET}")

    click.echo()
    click.echo(f"Set {CYAN}ENCRYPTION_KEY{RESET} in your environment before using.")
    click.echo(f"Use {CYAN}/token-manager{RESET} in Claude Code to migrate hardcoded tokens.")


@main.command("add-admin-dashboard")
@click.option("--force", is_flag=True, help="Overwrite existing files")
@click.option("--language", "-l", type=click.Choice(["python", "typescript"]), default=None,
              help="Target language (auto-detected if omitted)")
def add_admin_dashboard_command(force: bool, language: str | None) -> None:
    """Install infrastructure health monitoring API.

    Generates an admin dashboard with health checks, database stats,
    query management, user admin, and audit logs.

    \b
    What gets installed:
      - stablestack_admin_dashboard/ — monitor, models, API routes
      - .claude/commands/admin-dashboard.md slash command

    \b
    Examples:
      stablestack add-admin-dashboard                  # Auto-detect language
      stablestack add-admin-dashboard -l typescript     # Force TypeScript
      stablestack add-admin-dashboard --force           # Overwrite existing
    """
    from stablestack.scaffolds import detect_language, write_scaffold_files
    from stablestack.scaffolds.admin_dashboard import get_files, get_slash_command, DIR

    cwd = Path.cwd()
    lang = language or detect_language(cwd)
    if lang is None:
        click.echo(f"{YELLOW}Could not auto-detect language. Defaulting to Python.{RESET}")
        click.echo(f"{DIM}Use --language to specify: stablestack add-admin-dashboard -l typescript{RESET}")
        lang = "python"

    click.echo(f"Generating {BOLD}{lang}{RESET} admin dashboard...")

    files = get_files(lang)
    created = write_scaffold_files(cwd, files, force=force)

    for path in created:
        click.echo(f"  {GREEN}Created {path}{RESET}")

    skipped = set(files.keys()) - set(created)
    for path in sorted(skipped):
        click.echo(f"  {YELLOW}{path} already exists (use --force to overwrite){RESET}")

    # Install slash command
    commands_dir = cwd / ".claude" / "commands"
    commands_dir.mkdir(parents=True, exist_ok=True)
    command_path = commands_dir / "admin-dashboard.md"
    if command_path.exists() and not force:
        click.echo(f"  {YELLOW}.claude/commands/admin-dashboard.md already exists{RESET}")
    else:
        command_path.write_text(get_slash_command())
        click.echo(f"  {GREEN}Created .claude/commands/admin-dashboard.md{RESET}")

    click.echo()
    click.echo(f"{BOLD}Admin dashboard installed!{RESET}")
    click.echo()
    if lang == "python":
        click.echo("Mount the router in your FastAPI app:")
        click.echo(f"  {CYAN}from stablestack_admin_dashboard import router{RESET}")
        click.echo(f"  {CYAN}app.include_router(router, prefix=\"/api\"){RESET}")
        click.echo()
        click.echo(f"Required: {DIM}pip install fastapi sqlalchemy{RESET}")
    else:
        click.echo("Mount the router in your Express app:")
        click.echo(f"  {CYAN}import {{ adminRouter }} from \"./stablestack_admin_dashboard\";{RESET}")
        click.echo(f"  {CYAN}app.use(\"/api\", adminRouter);{RESET}")
        click.echo()
        click.echo(f"Required: {DIM}npm install express{RESET}")

    click.echo()
    click.echo(f"Use {CYAN}/admin-dashboard{RESET} in Claude Code to wire up health checks.")


if __name__ == "__main__":
    main()
