"""CLI entry point using Click — defines all commands and options."""

from __future__ import annotations

import logging
import os
import sys
import json
from pathlib import Path
from typing import Optional

import click

from . import __version__

logger = logging.getLogger("oncecheck")


def _setup_logging(verbose: bool = False) -> None:
    """Configure logging for the CLI."""
    level = logging.DEBUG if verbose else logging.WARNING
    logging.basicConfig(
        level=level,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%H:%M:%S",
    )


def _advanced_engine_preflight(project_path: Path, analysis_mode: str) -> dict:
    """Return advanced engine readiness details for user messaging."""
    from .engine.advanced_analyzers import available_engines, detected_languages

    mode = (analysis_mode or "heuristic").strip().lower()
    langs = detected_languages(project_path)
    engines = available_engines()
    requires_advanced = mode in ("advanced", "hybrid")
    has_engine = bool(engines.get("codeql") or engines.get("semgrep"))
    return {
        "requires_advanced": requires_advanced,
        "languages": langs,
        "engines": engines,
        "has_engine": has_engine,
    }


def _print_engine_install_help(console) -> None:
    console.print("  [dim]Install guidance:[/dim]")
    console.print("  [dim]- CodeQL CLI: https://codeql.github.com/docs/codeql-cli/getting-started-with-the-codeql-cli/[/dim]")
    console.print("  [dim]- Semgrep CLI: https://semgrep.dev/docs/getting-started/[/dim]")
    console.print("  [dim]- Verify with: oncecheck engines .[/dim]")


@click.group(invoke_without_command=True)
@click.version_option(version=__version__, prog_name="Oncecheck")
@click.pass_context
def cli(ctx):
    """Oncecheck — scan iOS, Android, and Web projects for compliance risks."""
    if ctx.invoked_subcommand is None:
        _interactive_flow()


def _interactive_flow() -> None:
    """Full interactive welcome screen + menu-driven scan flow."""
    from .auth.api import check_scan_quota, record_scan
    from .auth.login import ensure_authenticated, login_flow
    from .auth.token_store import load_token
    from .engine.runner import run_scan
    from .scanners.detector import Platform, DetectionResult, detect_platforms
    from .engine.reporter import export_json, export_text, export_sarif
    from .ui.rich_console import (
        console,
        print_welcome,
        print_step_status,
        prompt_main_menu,
        prompt_platform,
        prompt_analysis_mode,
        prompt_require_compiler_engine,
        prompt_advanced_profile,
        prompt_project_path,
        prompt_post_scan,
        prompt_export_format,
        prompt_export_location,
        prompt_tools_menu,
        prompt_rules_menu,
        prompt_rules_platform,
        prompt_rules_severity,
        prompt_rules_tier,
        prompt_benchmark_menu,
        prompt_benchmark_suite,
        prompt_suppressions_menu,
        prompt_input,
        prompt_shell,
        prompt_confidence_threshold,
        print_header,
        print_scan_result,
        print_footer,
    )
    from .ui.interactive import show_interactive
    from .rules.sync import get_policy_sync_status, maybe_auto_sync

    print_welcome()
    def _pause() -> None:
        import readchar
        console.print("  [dim]Press any key to continue...[/dim]")
        readchar.readkey()

    while True:
        choice = prompt_main_menu()

        if choice == "scan":
            total_steps = 6
            print_step_status(1, total_steps, "Policy sync", "running")
            maybe_auto_sync()
            status = get_policy_sync_status()
            if not status["healthy"]:
                print_step_status(1, total_steps, "Policy sync", "warn", f"missing {status['missing']}, stale {status['stale']}")
                console.print(
                    f"  [#eab308]Policy sources not fully fresh[/#eab308] "
                    f"(missing: {status['missing']}, stale: {status['stale']}). "
                    "Run `oncecheck rules sync` from CLI for a full refresh.\n"
                )
            else:
                print_step_status(1, total_steps, "Policy sync", "done", "fresh")
            # --- Auth gate ---
            try:
                print_step_status(2, total_steps, "Authentication", "running")
                token_data = ensure_authenticated()
                print_step_status(2, total_steps, "Authentication", "done")
            except SystemExit:
                raise
            except (OSError, ConnectionError, TimeoutError) as exc:
                print_step_status(2, total_steps, "Authentication", "failed", str(exc))
                console.print(f"\n  [bold #ef4444]Auth error:[/bold #ef4444] {exc}\n")
                _pause()
                continue

            # --- Quota gate (server-side) ---
            try:
                print_step_status(3, total_steps, "Plan and quota", "running")
                quota = check_scan_quota(token_data["access_token"])
                print_step_status(3, total_steps, "Plan and quota", "done", f"{quota['used']}/{quota['limit']} today")
            except PermissionError:
                print_step_status(3, total_steps, "Plan and quota", "failed", "session expired")
                console.print("\n  [bold #ef4444]Session expired.[/bold #ef4444] Select Login to re-authenticate.\n")
                _pause()
                continue
            except ConnectionError as exc:
                print_step_status(3, total_steps, "Plan and quota", "failed", str(exc))
                console.print(f"\n  [bold #ef4444]Server error:[/bold #ef4444] {exc}\n")
                _pause()
                continue

            if not quota["allowed"]:
                console.print(f"\n  [bold #eab308]Scan limit reached[/bold #eab308] ({quota['used']}/{quota['limit']} scans today)")
                console.print("  Upgrade to [bold]Team[/bold] for unlimited scans: [#eeef20]https://www.oncecheck.com/dashboard/upgrade[/#eeef20]\n")
                _pause()
                continue

            user_plan = quota.get("plan", "starter")

            # --- Scan flow ---
            project_path_str = prompt_project_path()
            project_path = Path(project_path_str).resolve()
            analysis_mode = prompt_analysis_mode()
            require_compiler_engine = False
            advanced_profile = "balanced"
            min_confidence = prompt_confidence_threshold()
            if analysis_mode in ("advanced", "hybrid"):
                require_compiler_engine = prompt_require_compiler_engine()
                advanced_profile = prompt_advanced_profile()

            platform_choice = prompt_platform()
            console.clear()

            print_step_status(4, total_steps, "Platform detection", "running")
            if platform_choice == "auto":
                detections = detect_platforms(project_path)
                if not detections:
                    print_step_status(4, total_steps, "Platform detection", "failed", "no platform detected")
                    console.print("  [bold #ef4444]Could not detect project type.[/bold #ef4444]")
                    console.print("  [dim]Try selecting the platform manually.[/dim]\n")
                    import readchar
                    readchar.readkey()
                    continue
                print_step_status(4, total_steps, "Platform detection", "done", f"{len(detections)} target(s)")
            else:
                detections = [
                    DetectionResult(
                        platform=Platform(platform_choice),
                        project_root=project_path,
                        project_type="Manual",
                        confidence=1.0,
                    )
                ]
                print_step_status(4, total_steps, "Platform detection", "done", "manual")

            preflight = _advanced_engine_preflight(project_path, analysis_mode)
            if preflight["requires_advanced"] and not preflight["has_engine"]:
                detail = "no advanced engines installed"
                if require_compiler_engine:
                    print_step_status(5, total_steps, "Run scanners", "failed", detail)
                    console.print("  [bold #ef4444]Compiler-grade mode required, but CodeQL/Semgrep are unavailable.[/bold #ef4444]")
                    _print_engine_install_help(console)
                    _pause()
                    continue
                console.print("  [#eab308]Advanced engines unavailable; continuing with heuristic findings only.[/#eab308]")
                _print_engine_install_help(console)

            results = []
            print_step_status(5, total_steps, "Run scanners", "running")
            for detection in detections:
                console.print(f"  [dim]Scanning {detection.platform.value.upper()} ({detection.project_type})...[/dim]")
                try:
                    result = run_scan(
                        detection,
                        user_plan=user_plan,
                        analysis_mode=analysis_mode,
                        require_compiler_engine=require_compiler_engine,
                        advanced_profile=advanced_profile,
                    )
                except RuntimeError as exc:
                    print_step_status(5, total_steps, "Run scanners", "failed", "engine error")
                    console.print(f"  [bold #ef4444]Advanced engine failure:[/bold #ef4444] {exc}")
                    _print_engine_install_help(console)
                    _pause()
                    results = []
                    break
                results.append(result)
            if min_confidence > 0:
                for result in results:
                    result.findings = [f for f in result.findings if float(f.confidence or 0.0) >= min_confidence]
            if not results:
                continue
            print_step_status(5, total_steps, "Run scanners", "done", f"{len(results)} scan(s)")

            # Record the completed scan (increments counter)
            try:
                record_scan(token_data["access_token"])
            except (PermissionError, ConnectionError):
                pass  # Don't block results if recording fails

            print_step_status(6, total_steps, "Render report", "running")
            console.clear()
            print_header()
            for result in results:
                print_scan_result(result)
            print_footer(results)
            print_step_status(6, total_steps, "Render report", "done")

            # Post-scan menu
            if any(r.findings for r in results):
                _pause()
                action = prompt_post_scan(is_team=(user_plan == "team"))
                if action == "browse":
                    show_interactive(results)
                elif action == "export":
                    fmt = prompt_export_format()
                    save_dir = prompt_export_location()
                    ext = "txt" if fmt == "text" else fmt
                    filename = f"oncecheck-results.{ext}"
                    output_path = str(Path(save_dir) / filename)
                    try:
                        if fmt == "json":
                            export_json(results, output_path)
                        elif fmt == "text":
                            Path(output_path).write_text(export_text(results))
                        elif fmt == "sarif":
                            export_sarif(results, output_path)
                        console.print(f"\n  [#22c55e]Exported to {output_path}[/#22c55e]\n")
                    except (ValueError, FileNotFoundError, OSError) as exc:
                        console.print(f"\n  [bold #ef4444]Export failed:[/bold #ef4444] {exc}\n")
                    _pause()
                elif action == "exit":
                    console.print("\n  [dim]Goodbye![/dim]\n")
                    break
            else:
                _pause()

        elif choice == "tools":
            while True:
                tool = prompt_tools_menu()
                if tool == "back":
                    break
                if tool == "engines":
                    try:
                        path = prompt_project_path()
                        engines(path)
                    except Exception as exc:  # pragma: no cover
                        console.print(f"\n  [bold #ef4444]Engines check failed:[/bold #ef4444] {exc}\n")
                    _pause()
                    continue
                if tool == "completions":
                    try:
                        shell = prompt_shell()
                        completions(shell=shell)
                    except Exception as exc:  # pragma: no cover
                        console.print(f"\n  [bold #ef4444]Completions failed:[/bold #ef4444] {exc}\n")
                    _pause()
                    continue
                if tool == "init":
                    try:
                        path = prompt_project_path()
                        init_config(path)
                    except Exception as exc:  # pragma: no cover
                        console.print(f"\n  [bold #ef4444]Init failed:[/bold #ef4444] {exc}\n")
                    _pause()
                    continue
                if tool == "suppressions":
                    while True:
                        action = prompt_suppressions_menu()
                        if action == "back":
                            break
                        try:
                            if action == "add":
                                path = prompt_project_path()
                                rid = prompt_input("Rule ID", "Rule ID to suppress (e.g. WEB-OWASP-003).", "")
                                reason = prompt_input("Reason", "Justification for suppression (required).", "")
                                if not rid or not reason:
                                    console.print("  [#eab308]Rule ID and reason are required.[/#eab308]")
                                else:
                                    suppress_add(rule_id=rid, path=path, reason=reason)
                            elif action == "list":
                                path = prompt_project_path()
                                suppress_list(path=path)
                        except Exception as exc:  # pragma: no cover
                            console.print(f"\n  [bold #ef4444]Suppressions action failed:[/bold #ef4444] {exc}\n")
                        _pause()
                    continue
                if tool == "rules":
                    while True:
                        action = prompt_rules_menu()
                        if action == "back":
                            break
                        try:
                            if action == "sync":
                                force_raw = prompt_input("Force Sync", "Type 'force' to force refresh, or press Enter for normal sync.", "")
                                rules_sync(force=(force_raw.lower() == "force"), timeout=20)
                            elif action == "status":
                                days = prompt_input("Stale Threshold", "Days before a policy source is stale (default 14).", "14")
                                rules_status(stale_after_days=int(days))
                            elif action == "impact":
                                days = prompt_input("Lookback Days", "Show policy updates within this many days (default 7).", "7")
                                rules_impact(within_days=int(days))
                            elif action == "list":
                                pf = prompt_rules_platform()
                                sv = prompt_rules_severity()
                                tier = prompt_rules_tier()
                                rules_list(platform=pf, severity=sv, tier=tier)
                            elif action == "show":
                                rid = prompt_input("Rule ID", "Enter rule id like IOS-SEC-001, AND-PERM-001, WEB-OWASP-003.", "")
                                if not rid:
                                    console.print("  [#eab308]Rule ID is required.[/#eab308]")
                                else:
                                    try:
                                        rules_show(rule_id=rid)
                                    except SystemExit:
                                        pass
                            elif action == "doctor":
                                days = prompt_input("Stale Threshold", "Days before a policy source is stale (default 14).", "14")
                                try:
                                    rules_doctor(stale_after_days=int(days))
                                except SystemExit:
                                    pass
                        except Exception as exc:  # pragma: no cover
                            console.print(f"\n  [bold #ef4444]Rules action failed:[/bold #ef4444] {exc}\n")
                        _pause()
                    continue
                if tool == "benchmark":
                    while True:
                        action = prompt_benchmark_menu()
                        if action == "back":
                            break
                        try:
                            if action == "template":
                                suite = prompt_benchmark_suite()
                                output = prompt_input("Output Path", "Where to write template JSON.", "benchmark-truth-template.json")
                                benchmark_template(suite=suite, output=output)
                            elif action == "score":
                                pred = prompt_input("Predictions JSON", "Path to oncecheck JSON export.", "")
                                truth = prompt_input("Truth JSON", "Path to benchmark truth labels JSON.", "")
                                if not pred or not truth:
                                    console.print("  [#eab308]Predictions and truth paths are required.[/#eab308]")
                                else:
                                    benchmark_score(predictions=pred, truth=truth)
                            elif action == "gate":
                                pred = prompt_input("Predictions JSON", "Path to oncecheck JSON export.", "")
                                truth = prompt_input("Truth JSON", "Path to benchmark truth labels JSON.", "")
                                min_precision = prompt_input("Min Precision", "Minimum precision threshold (default 0.80).", "0.80")
                                min_recall = prompt_input("Min Recall", "Minimum recall threshold (default 0.80).", "0.80")
                                min_f1 = prompt_input("Min F1", "Minimum F1 threshold (default 0.80).", "0.80")
                                min_tp = prompt_input("Min TP", "Minimum true positives required (default 1).", "1")
                                if not pred or not truth:
                                    console.print("  [#eab308]Predictions and truth paths are required.[/#eab308]")
                                else:
                                    try:
                                        benchmark_gate(
                                            predictions=pred,
                                            truth=truth,
                                            min_precision=float(min_precision),
                                            min_recall=float(min_recall),
                                            min_f1=float(min_f1),
                                            min_tp=int(min_tp),
                                        )
                                    except SystemExit:
                                        pass
                            elif action == "gate-config":
                                cfg = prompt_input(
                                    "Gate Config JSON",
                                    "Path to gate config JSON (default benchmarks/ci/gate_config.json).",
                                    "benchmarks/ci/gate_config.json",
                                )
                                try:
                                    benchmark_gate_config(config_path=cfg)
                                except SystemExit:
                                    pass
                        except Exception as exc:  # pragma: no cover
                            console.print(f"\n  [bold #ef4444]Benchmark action failed:[/bold #ef4444] {exc}\n")
                        _pause()

        elif choice == "login":
            login_flow()
            console.print()

        elif choice == "logout":
            logout()
            console.print("\n  [#22c55e]Logged out.[/#22c55e]\n")
            _pause()

        elif choice == "status":
            console.clear()
            token = load_token()
            if token:
                plan = token.get("plan", "Unknown")
                email = token.get("email", "Unknown")
                console.print(f"\n  [#22c55e]Authenticated[/#22c55e] \u2014 {email}")
                console.print(f"  Plan: [bold]{plan}[/bold]\n")
            else:
                console.print("\n  [#eab308]Not authenticated.[/#eab308] Select Login to sign in.\n")
            _pause()

        elif choice == "exit":
            console.clear()
            console.print("\n  [dim]Goodbye![/dim]\n")
            break


@cli.command()
@click.argument("path", default=".", type=click.Path(exists=True))
@click.option(
    "--platform", "-p",
    type=click.Choice(["ios", "android", "web", "auto"], case_sensitive=False),
    default="auto",
    help="Force a specific platform instead of auto-detecting.",
)
@click.option(
    "--format", "-f", "output_format",
    type=click.Choice(["rich", "json", "text", "sarif"], case_sensitive=False),
    default="rich",
    help="Output format.",
)
@click.option("--output", "-o", type=click.Path(), default=None, help="Write results to file.")
@click.option("--fail-on", type=click.Choice(["FAIL", "WARN"], case_sensitive=False), default=None,
              help="Exit with code 2 if findings of this severity or higher exist.")
@click.option(
    "--min-confidence",
    type=float,
    default=0.0,
    show_default=True,
    help="Only include findings with confidence >= this value (0.0 - 1.0).",
)
@click.option("--interactive", "-i", is_flag=True, help="Launch interactive findings browser.")
@click.option(
    "--analysis-mode",
    type=click.Choice(["heuristic", "advanced", "hybrid"], case_sensitive=False),
    default="hybrid",
    show_default=True,
    help="Select analysis backend: built-in heuristics, advanced external engines, or both.",
)
@click.option(
    "--require-compiler-engine",
    is_flag=True,
    help="Fail if advanced/typed engines (CodeQL/Semgrep) are unavailable or fail in advanced mode.",
)
@click.option(
    "--advanced-profile",
    type=click.Choice(["balanced", "codeql-first", "codeql-only", "semgrep-only"], case_sensitive=False),
    default="balanced",
    show_default=True,
    help="Execution profile for advanced analysis engines.",
)
@click.option(
    "--policy-sync",
    type=click.Choice(["off", "auto", "force"], case_sensitive=False),
    default="auto",
    show_default=True,
    help="Sync official policy sources before scanning.",
)
@click.option(
    "--policy-max-age-days",
    type=int,
    default=14,
    show_default=True,
    help="Treat policy sources older than this as stale.",
)
@click.option(
    "--require-fresh-policies",
    is_flag=True,
    help="Fail the scan if policy sources are missing or stale.",
)
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging.")
def scan(
    path: str,
    platform: str,
    output_format: str,
    output: Optional[str],
    fail_on: Optional[str],
    min_confidence: float,
    interactive: bool,
    analysis_mode: str,
    require_compiler_engine: bool,
    advanced_profile: str,
    policy_sync: str,
    policy_max_age_days: int,
    require_fresh_policies: bool,
    verbose: bool,
):
    """Scan a project directory for compliance issues."""
    from rich.console import Console
    from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn

    from .auth.api import check_scan_quota, record_scan
    from .auth.login import ensure_authenticated
    from .engine.config import ScanConfig
    from .engine.reporter import export_json, export_sarif, export_text
    from .engine.runner import run_scan, ScanResult
    from .scanners.detector import Platform, detect_platforms
    from .ui.rich_console import print_header, print_scan_result, print_footer
    from .ui.rich_console import print_step_status
    from .ui.interactive import show_interactive
    from .rules.sync import get_policy_sync_status, maybe_auto_sync, sync_policies

    _setup_logging(verbose)
    if min_confidence < 0 or min_confidence > 1:
        raise click.BadParameter("--min-confidence must be between 0.0 and 1.0")

    console = Console()
    project_path = Path(path).resolve()
    total_steps = 7

    # --- Live policy sync ---
    print_step_status(1, total_steps, "Policy sync", "running")
    if policy_sync == "force":
        sync_result = sync_policies(force=True)
        step_state = "done" if sync_result["failed"] == 0 else "warn"
        console.print(
            f"[dim]Policy sync: {sync_result['updated']} updated, "
            f"{sync_result['unchanged']} unchanged, {sync_result['failed']} failed[/dim]"
        )
        print_step_status(1, total_steps, "Policy sync", step_state)
    elif policy_sync == "auto":
        sync_result = maybe_auto_sync(stale_after_days=policy_max_age_days)
        if sync_result is not None:
            step_state = "done" if sync_result["failed"] == 0 else "warn"
            console.print(
                f"[dim]Policy sync: {sync_result['updated']} updated, "
                f"{sync_result['unchanged']} unchanged, {sync_result['failed']} failed[/dim]"
            )
            print_step_status(1, total_steps, "Policy sync", step_state)
        else:
            print_step_status(1, total_steps, "Policy sync", "done", "already fresh")
    else:
        print_step_status(1, total_steps, "Policy sync", "done", "disabled")

    policy_status = get_policy_sync_status(stale_after_days=policy_max_age_days)
    if not policy_status["healthy"]:
        message = (
            f"Policy sources not fully fresh "
            f"(missing: {policy_status['missing']}, stale: {policy_status['stale']}). "
            "Run `oncecheck rules sync`."
        )
        if require_fresh_policies:
            print_step_status(1, total_steps, "Policy sync", "failed", "freshness required")
            console.print(f"[bold #ef4444]{message}[/bold #ef4444]")
            sys.exit(2)
        print_step_status(1, total_steps, "Policy sync", "warn", f"missing {policy_status['missing']}, stale {policy_status['stale']}")
        console.print(f"[#eab308]{message}[/#eab308]")

    # --- Load project config ---
    print_step_status(2, total_steps, "Load config", "running")
    config = ScanConfig(project_path)
    print_step_status(2, total_steps, "Load config", "done")
    logger.info("Config loaded: %d ignored rules, %d severity overrides",
                len(config.ignored_rules), len(config.severity_overrides))

    # --- Auth gate ---
    print_step_status(3, total_steps, "Authentication", "running")
    try:
        token_data = ensure_authenticated()
        print_step_status(3, total_steps, "Authentication", "done")
    except SystemExit:
        raise
    except (OSError, ConnectionError, TimeoutError) as exc:
        print_step_status(3, total_steps, "Authentication", "failed", str(exc))
        console.print(f"[bold #ef4444]Auth error:[/bold #ef4444] {exc}")
        sys.exit(1)

    # --- Quota gate (server-side) ---
    print_step_status(4, total_steps, "Plan and quota", "running")
    try:
        quota = check_scan_quota(token_data["access_token"])
        print_step_status(4, total_steps, "Plan and quota", "done", f"{quota['used']}/{quota['limit']} today")
    except PermissionError:
        print_step_status(4, total_steps, "Plan and quota", "failed", "session expired")
        console.print("[bold #ef4444]Session expired.[/bold #ef4444] Run `oncecheck login` to re-authenticate.")
        sys.exit(1)
    except ConnectionError as exc:
        print_step_status(4, total_steps, "Plan and quota", "failed", str(exc))
        console.print(f"[bold #ef4444]Server error:[/bold #ef4444] {exc}")
        sys.exit(1)

    if not quota["allowed"]:
        console.print(f"\n  [bold #eab308]Scan limit reached[/bold #eab308] ({quota['used']}/{quota['limit']} scans today)")
        console.print("  Upgrade to [bold]Team[/bold] for unlimited scans: [#eeef20]https://www.oncecheck.com/dashboard/upgrade[/#eeef20]\n")
        sys.exit(1)

    # --- Plan-gated features ---
    user_plan = quota.get("plan", "starter")
    if user_plan != "team":
        if output_format == "sarif":
            console.print("[bold #eab308]SARIF export requires the Team plan.[/bold #eab308]")
            console.print("Upgrade at: [#eeef20]https://www.oncecheck.com/dashboard/upgrade[/#eeef20]")
            sys.exit(1)
        if output and output_format != "rich":
            console.print("[bold #eab308]File export (--output) requires the Team plan.[/bold #eab308]")
            console.print("Upgrade at: [#eeef20]https://www.oncecheck.com/dashboard/upgrade[/#eeef20]")
            sys.exit(1)

    # --- Detect platforms ---
    print_step_status(5, total_steps, "Platform detection", "running")
    if platform == "auto":
        detections = detect_platforms(project_path)
        if not detections:
            print_step_status(5, total_steps, "Platform detection", "failed", "no platform detected")
            console.print("[bold #ef4444]Could not detect project type.[/bold #ef4444]")
            console.print("Use --platform to specify manually.")
            sys.exit(1)
        print_step_status(5, total_steps, "Platform detection", "done", f"{len(detections)} target(s)")
    else:
        from .scanners.detector import DetectionResult
        detections = [
            DetectionResult(
                platform=Platform(platform.lower()),
                project_root=project_path,
                project_type="Manual",
                confidence=1.0,
            )
        ]
        print_step_status(5, total_steps, "Platform detection", "done", "manual")

    preflight = _advanced_engine_preflight(project_path, analysis_mode)
    if preflight["requires_advanced"] and not preflight["has_engine"]:
        detail = "no advanced engines installed"
        if require_compiler_engine:
            print_step_status(6, total_steps, "Run scanners", "failed", detail)
            console.print("[bold #ef4444]Compiler-grade mode required, but CodeQL/Semgrep are unavailable.[/bold #ef4444]")
            _print_engine_install_help(console)
            sys.exit(2)
        console.print("[#eab308]Advanced engines unavailable; continuing with heuristic findings only.[/#eab308]")
        _print_engine_install_help(console)

    # --- Run scans with progress ---
    print_step_status(6, total_steps, "Run scanners", "running")
    results: list[ScanResult] = []
    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        TimeElapsedColumn(),
        console=console,
        transient=True,
    ) as progress:
        for detection in detections:
            task_desc = f"Scanning {detection.platform.value.upper()} ({detection.project_type})"
            progress.add_task(task_desc, total=None)
            try:
                result = run_scan(
                    detection,
                    config=config,
                    user_plan=user_plan,
                analysis_mode=analysis_mode,
                require_compiler_engine=require_compiler_engine,
                advanced_profile=advanced_profile,
            )
            except RuntimeError as exc:
                print_step_status(6, total_steps, "Run scanners", "failed", "engine error")
                console.print(f"[bold #ef4444]Advanced engine failure:[/bold #ef4444] {exc}")
                _print_engine_install_help(console)
                sys.exit(2)
            results.append(result)
    print_step_status(6, total_steps, "Run scanners", "done", f"{len(results)} scan(s)")

    if min_confidence > 0:
        for result in results:
            result.findings = [f for f in result.findings if float(f.confidence or 0.0) >= min_confidence]

    # --- Record the completed scan ---
    try:
        record_scan(token_data["access_token"])
    except (PermissionError, ConnectionError):
        pass  # Don't block results if recording fails

    # --- Resolve fail_on: CLI flag > config file ---
    effective_fail_on = fail_on or config.fail_on

    # --- Output ---
    print_step_status(7, total_steps, "Render report", "running")
    if output_format == "rich":
        print_header()
        for result in results:
            print_scan_result(result)
        print_footer(results)
        # Show suppression summary
        total_suppressed = sum(r.suppressed_count for r in results)
        if total_suppressed:
            console.print(f"  [dim]{total_suppressed} finding(s) suppressed via .oncecheckignore[/dim]")
        # Show plan gating summary
        total_gated = sum(r.gated_count for r in results)
        if total_gated:
            console.print(f"  [dim]{total_gated} additional rule(s) available with Team plan[/dim]")
            console.print(f"  [dim]Upgrade at: https://www.oncecheck.com/dashboard/upgrade[/dim]\n")
    elif output_format == "json":
        text = export_json(results, output)
        if not output:
            console.print(text)
    elif output_format == "text":
        text = export_text(results)
        if output:
            Path(output).write_text(text)
        else:
            console.print(text)
    elif output_format == "sarif":
        text = export_sarif(results, output)
        if not output:
            console.print(text)

    if output:
        console.print(f"  [#22c55e]Results written to {output}[/#22c55e]")
    print_step_status(7, total_steps, "Render report", "done")

    # --- Interactive mode ---
    if interactive:
        show_interactive(results)

    # --- Exit code ---
    _exit_code(results, effective_fail_on)


def _exit_code(results, fail_on: Optional[str]) -> None:
    """Set process exit code based on findings."""
    total_fail = sum(r.fail_count for r in results)
    total_warn = sum(r.warn_count for r in results)

    if fail_on == "FAIL" and total_fail > 0:
        sys.exit(2)
    elif fail_on == "WARN" and (total_fail > 0 or total_warn > 0):
        sys.exit(2)
    elif total_fail > 0:
        sys.exit(2)
    elif total_warn > 0:
        sys.exit(1)
    else:
        sys.exit(0)


@cli.command()
def login():
    """Authenticate with Oncecheck."""
    from .auth.login import login_flow
    login_flow()


@cli.command()
def logout():
    """Clear stored authentication credentials."""
    from .auth.login import logout
    logout()


@cli.command()
def status():
    """Show authentication and subscription status."""
    from rich.console import Console

    from .auth.token_store import load_token

    console = Console()
    token = load_token()
    if token:
        plan = token.get("plan", "Unknown")
        email = token.get("email", "Unknown")
        console.print(f"  [#22c55e]Authenticated[/#22c55e] \u2014 {email}")
        console.print(f"  Plan: [bold]{plan}[/bold]")
    else:
        console.print("  [#eab308]Not authenticated.[/#eab308] Run `oncecheck login` to sign in.")


@cli.command("engines")
@click.argument("path", default=".", type=click.Path(exists=True))
def engines(path: str):
    """Show advanced analyzer availability and detected source languages."""
    from rich.console import Console
    from rich.table import Table

    from .engine.advanced_analyzers import available_engines, detected_languages

    console = Console()
    root = Path(path).resolve()
    engine_state = available_engines()
    langs = detected_languages(root)

    table = Table(title=f"Advanced Engines: {root}")
    table.add_column("Engine", style="bold")
    table.add_column("Available")
    table.add_row("CodeQL", "yes" if engine_state.get("codeql") else "no")
    table.add_row("Semgrep", "yes" if engine_state.get("semgrep") else "no")
    console.print(table)
    console.print(f"  [dim]Detected languages:[/dim] {', '.join(langs) if langs else 'none'}")
    if not any(engine_state.values()):
        _print_engine_install_help(console)


@cli.group("rules")
def rules_group():
    """Manage rule catalogs and official policy source sync."""


@rules_group.command("sync")
@click.option("--force", is_flag=True, help="Force re-download all policy sources.")
@click.option("--timeout", type=int, default=20, show_default=True, help="HTTP timeout in seconds.")
def rules_sync(force: bool, timeout: int):
    """Fetch official policy pages used for rule maintenance."""
    from rich.console import Console

    from .rules.sync import sync_policies

    console = Console()
    result = sync_policies(force=force, timeout_seconds=timeout)
    console.print(
        f"  [#22c55e]Policy sync complete[/#22c55e] "
        f"(updated: {result['updated']}, unchanged: {result['unchanged']}, failed: {result['failed']})"
    )
    for item in result["results"]:
        if item["status"] == "failed":
            console.print(f"  [#ef4444]✗[/#ef4444] {item['name']}: {item['error']}")
        elif item["status"] == "updated":
            console.print(f"  [#22c55e]✓[/#22c55e] {item['name']} (updated)")
        else:
            console.print(f"  [dim]• {item['name']} (unchanged)[/dim]")


@rules_group.command("status")
@click.option(
    "--stale-after-days",
    type=int,
    default=14,
    show_default=True,
    help="Treat policy sources older than this as stale.",
)
def rules_status(stale_after_days: int):
    """Show freshness status of synced official policy sources."""
    from datetime import datetime, timezone

    from rich.console import Console

    from .rules.sync import get_policy_sync_status

    console = Console()
    status = get_policy_sync_status(stale_after_days=stale_after_days)
    color = "#22c55e" if status["healthy"] else "#eab308"
    console.print(
        f"  [{color}]Policy sync status[/{color}] "
        f"(missing: {status['missing']}, stale: {status['stale']}, total: {status['total']})"
    )
    for item in status["sources"]:
        synced = item["synced_at"]
        if synced:
            synced_text = datetime.fromtimestamp(synced, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
        else:
            synced_text = "never"
        marker = "stale" if item["stale"] else ("missing" if item["missing"] else "fresh")
        console.print(f"  - [{item['platform']}] {item['name']}: {marker} (last sync: {synced_text})")


def _platforms_for_filter(platform: str) -> list[str]:
    if platform == "all":
        return ["ios", "android", "web", "common"]
    return [platform]


def _platform_from_rule_id(rule_id: str) -> Optional[str]:
    if rule_id.startswith("IOS-"):
        return "ios"
    if rule_id.startswith("AND-"):
        return "android"
    if rule_id.startswith("WEB-"):
        return "web"
    if rule_id.startswith("CROSS-") or rule_id.startswith("SUPPLY-"):
        return "common"
    return None


@rules_group.command("list")
@click.option(
    "--platform",
    type=click.Choice(["all", "ios", "android", "web", "common"], case_sensitive=False),
    default="all",
    show_default=True,
    help="Filter by platform.",
)
@click.option(
    "--severity",
    type=click.Choice(["ALL", "FAIL", "WARN", "INFO"], case_sensitive=False),
    default="ALL",
    show_default=True,
    help="Filter by severity.",
)
@click.option(
    "--tier",
    type=click.Choice(["all", "starter", "team"], case_sensitive=False),
    default="all",
    show_default=True,
    help="Filter by plan tier.",
)
def rules_list(platform: str, severity: str, tier: str):
    """List available rules with filters."""
    from rich.console import Console
    from rich.table import Table

    from .rules.loader import load_rules

    console = Console()
    table = Table(title="Oncecheck Rule Catalog")
    table.add_column("ID", style="bold")
    table.add_column("Platform")
    table.add_column("Severity")
    table.add_column("Tier")
    table.add_column("Summary")

    severity = severity.upper()
    total = 0
    for p in _platforms_for_filter(platform.lower()):
        for rule in load_rules(p):
            if severity != "ALL" and rule.get("severity") != severity:
                continue
            rule_tier = str(rule.get("tier", ""))
            if tier != "all" and rule_tier != tier:
                continue
            total += 1
            table.add_row(
                str(rule.get("id", "")),
                p,
                str(rule.get("severity", "")),
                rule_tier or "-",
                str(rule.get("summary", "")),
            )

    if total == 0:
        console.print("  [#eab308]No rules matched filters.[/#eab308]")
        return

    console.print(table)
    console.print(f"  [dim]{total} rule(s) shown[/dim]")


@rules_group.command("show")
@click.argument("rule_id")
def rules_show(rule_id: str):
    """Show full details for a specific rule ID."""
    from rich.console import Console
    from rich.panel import Panel

    from .rules.loader import get_rule_by_id

    console = Console()
    rid = rule_id.strip().upper()
    platform = _platform_from_rule_id(rid)
    if not platform:
        console.print(f"  [#ef4444]Unknown rule ID prefix:[/#ef4444] {rid}")
        sys.exit(1)

    rule = get_rule_by_id(platform, rid)
    if not rule:
        console.print(f"  [#ef4444]Rule not found:[/#ef4444] {rid}")
        sys.exit(1)

    detection = rule.get("detection", {})
    if detection:
        import json
        detection_text = json.dumps(detection, indent=2, sort_keys=True)
    else:
        detection_text = "{}"

    body = (
        f"[bold]Platform:[/bold] {platform}\n"
        f"[bold]Severity:[/bold] {rule.get('severity', '')}\n"
        f"[bold]Tier:[/bold] {rule.get('tier', '-')}\n\n"
        f"[bold]Summary:[/bold]\n{rule.get('summary', '')}\n\n"
        f"[bold]Fix:[/bold]\n{rule.get('fix', '')}\n\n"
        f"[bold]Reference:[/bold]\n{rule.get('reference', '')}\n\n"
        f"[bold]Detection:[/bold]\n{detection_text}"
    )
    console.print(Panel.fit(body, title=f"Rule {rid}", border_style="cyan"))


@rules_group.command("doctor")
@click.option(
    "--stale-after-days",
    type=int,
    default=14,
    show_default=True,
    help="Treat policy sources older than this as stale.",
)
def rules_doctor(stale_after_days: int):
    """Run rule and policy health checks (useful for CI)."""
    from rich.console import Console

    from .rules.loader import validate_rules
    from .rules.sync import get_policy_sync_status

    console = Console()
    failures = 0

    for platform in ("ios", "android", "web", "common"):
        errors = validate_rules(platform)
        if errors:
            failures += len(errors)
            console.print(f"  [#ef4444]{platform}: {len(errors)} validation error(s)[/#ef4444]")
            for err in errors:
                console.print(f"    - {err}")
        else:
            console.print(f"  [#22c55e]{platform}: OK[/#22c55e]")

    status = get_policy_sync_status(stale_after_days=stale_after_days)
    if status["healthy"]:
        console.print("  [#22c55e]policy sync: fresh[/#22c55e]")
    else:
        failures += status["missing"] + status["stale"]
        console.print(
            "  [#ef4444]policy sync: not healthy[/#ef4444] "
            f"(missing: {status['missing']}, stale: {status['stale']})"
        )
        console.print("    Run `oncecheck rules sync --force`")

    if failures > 0:
        sys.exit(2)


@rules_group.command("impact")
@click.option(
    "--within-days",
    type=int,
    default=7,
    show_default=True,
    help="Look back this many days for policy source updates.",
)
def rules_impact(within_days: int):
    """Show likely impacted rules from recent official policy source updates."""
    from rich.console import Console

    from .rules.loader import get_rule_ids
    from .rules.policy_map import impacted_rules_for_sources
    from .rules.sync import get_recent_policy_changes

    console = Console()
    changes = get_recent_policy_changes(within_days=within_days)
    if changes["count"] == 0:
        console.print("  [dim]No policy updates in the selected window.[/dim]")
        return

    for item in changes["sources"]:
        console.print(f"  [#22c55e]updated[/#22c55e] {item['platform']} :: {item['name']}")

    all_rule_ids = []
    for platform in ("ios", "android", "web", "common"):
        all_rule_ids.extend(get_rule_ids(platform))
    impacted = impacted_rules_for_sources(
        [src["id"] for src in changes["sources"]],
        all_rule_ids,
    )
    console.print(f"\n  [bold]Likely impacted rules:[/bold] {len(impacted)}")
    for rid in impacted[:40]:
        console.print(f"  - {rid}")
    if len(impacted) > 40:
        console.print(f"  [dim]... and {len(impacted) - 40} more[/dim]")


@cli.group("benchmark")
def benchmark_group():
    """Benchmark scanner quality against labeled datasets."""


@benchmark_group.command("score")
@click.option("--predictions", type=click.Path(exists=True), required=True, help="Path to oncecheck JSON export.")
@click.option("--truth", type=click.Path(exists=True), required=True, help="Path to truth labels JSON.")
def benchmark_score(predictions: str, truth: str):
    """Compute precision/recall/F1 from labeled benchmark truth."""
    from rich.console import Console

    from .benchmark.metrics import load_predictions, load_truth, score

    console = Console()
    pred = load_predictions(Path(predictions))
    gt = load_truth(Path(truth))
    result = score(pred, gt)
    console.print(
        f"  [bold]TP[/bold]: {int(result['tp'])}  "
        f"[bold]FP[/bold]: {int(result['fp'])}  "
        f"[bold]FN[/bold]: {int(result['fn'])}"
    )
    console.print(
        f"  [bold]Precision[/bold]: {result['precision']:.4f}  "
        f"[bold]Recall[/bold]: {result['recall']:.4f}  "
        f"[bold]F1[/bold]: {result['f1']:.4f}"
    )


@benchmark_group.command("template")
@click.option(
    "--suite",
    type=click.Choice(["owasp-benchmark", "juliet", "custom"], case_sensitive=False),
    default="custom",
    show_default=True,
    help="Target suite template.",
)
@click.option("--output", type=click.Path(), required=True, help="Output JSON template path.")
def benchmark_template(suite: str, output: str):
    """Generate a truth-label template JSON for benchmark scoring."""
    from rich.console import Console

    from .benchmark.metrics import truth_template

    console = Console()
    payload = truth_template(suite)
    Path(output).write_text(json.dumps(payload, indent=2))
    console.print(f"  [#22c55e]Created truth template:[/#22c55e] {output}")


@cli.group("suppress")
def suppress_group():
    """Manage suppressions with explicit reasons."""


@suppress_group.command("add")
@click.argument("rule_id")
@click.option("--path", default=".", type=click.Path(exists=True), help="Project root containing .oncecheckignore")
@click.option("--reason", required=True, help="Justification for suppressing this rule.")
def suppress_add(rule_id: str, path: str, reason: str):
    """Add a suppression entry with an auditable reason."""
    from rich.console import Console

    from .engine.suppressions import add_suppression

    console = Console()
    root = Path(path).resolve()
    add_suppression(root, rule_id, reason)
    console.print(f"  [#22c55e]Suppressed[/#22c55e] {rule_id.strip().upper()}")


@suppress_group.command("list")
@click.option("--path", default=".", type=click.Path(exists=True), help="Project root containing .oncecheckignore")
def suppress_list(path: str):
    """List suppressed rules and recorded reasons."""
    from datetime import datetime, timezone
    from rich.console import Console

    from .engine.suppressions import get_suppression_reasons, list_suppressions

    console = Console()
    root = Path(path).resolve()
    rules = list_suppressions(root)
    reasons = get_suppression_reasons(root)
    if not rules:
        console.print("  [dim]No suppressions configured.[/dim]")
        return
    for rid in rules:
        info = reasons.get(rid, {})
        reason = info.get("reason", "no reason recorded")
        ts = int(info.get("updated_at", 0) or 0)
        when = datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%d %H:%M UTC") if ts else "unknown time"
        console.print(f"  - [bold]{rid}[/bold] :: {reason} [dim]({when})[/dim]")


@benchmark_group.command("gate")
@click.option("--predictions", type=click.Path(exists=True), required=True, help="Path to oncecheck JSON export.")
@click.option("--truth", type=click.Path(exists=True), required=True, help="Path to truth labels JSON.")
@click.option("--min-precision", type=float, default=0.80, show_default=True, help="Minimum required precision.")
@click.option("--min-recall", type=float, default=0.80, show_default=True, help="Minimum required recall.")
@click.option("--min-f1", type=float, default=0.80, show_default=True, help="Minimum required F1.")
@click.option("--min-tp", type=int, default=1, show_default=True, help="Minimum true positives required.")
def benchmark_gate(
    predictions: str,
    truth: str,
    min_precision: float,
    min_recall: float,
    min_f1: float,
    min_tp: int,
):
    """Fail (exit 2) when benchmark quality thresholds are not met."""
    from rich.console import Console

    from .benchmark.metrics import evaluate_thresholds, load_predictions, load_truth, score

    console = Console()
    pred = load_predictions(Path(predictions))
    gt = load_truth(Path(truth))
    result = score(pred, gt)
    gate = evaluate_thresholds(
        result,
        min_precision=min_precision,
        min_recall=min_recall,
        min_f1=min_f1,
        min_tp=min_tp,
    )

    console.print(
        f"  [bold]TP[/bold]: {int(result['tp'])}  "
        f"[bold]FP[/bold]: {int(result['fp'])}  "
        f"[bold]FN[/bold]: {int(result['fn'])}"
    )
    console.print(
        f"  [bold]Precision[/bold]: {result['precision']:.4f}  "
        f"[bold]Recall[/bold]: {result['recall']:.4f}  "
        f"[bold]F1[/bold]: {result['f1']:.4f}"
    )

    if gate["ok"]:
        console.print("  [#22c55e]Benchmark gate passed[/#22c55e]")
        return

    failed = ", ".join(str(x) for x in gate["failed"])
    console.print(f"  [#ef4444]Benchmark gate failed[/#ef4444] ({failed})")
    console.print(
        f"  [dim]thresholds => precision>={min_precision:.2f}, recall>={min_recall:.2f}, "
        f"f1>={min_f1:.2f}, tp>={min_tp}[/dim]"
    )
    sys.exit(2)


@benchmark_group.command("gate-config")
@click.option("--config", "config_path", type=click.Path(exists=True), required=True, help="Path to benchmark gate config JSON.")
def benchmark_gate_config(config_path: str):
    """Run one or more benchmark gates from a config file and fail on any violation."""
    from rich.console import Console

    from .benchmark.metrics import (
        evaluate_thresholds,
        filter_keys_by_prefixes,
        load_predictions,
        load_truth,
        score,
    )

    console = Console()
    root = Path(config_path).resolve().parent
    payload = json.loads(Path(config_path).read_text())
    suites = payload.get("suites", [])
    if not isinstance(suites, list) or not suites:
        raise click.BadParameter("config must include non-empty 'suites' list")

    failures = 0
    for suite in suites:
        name = str(suite.get("name", "suite"))
        pred_path = root / str(suite.get("predictions", ""))
        truth_path = root / str(suite.get("truth", ""))
        prefixes = suite.get("rule_prefixes", []) or []
        min_precision = float(suite.get("min_precision", 0.8))
        min_recall = float(suite.get("min_recall", 0.8))
        min_f1 = float(suite.get("min_f1", 0.8))
        min_tp = int(suite.get("min_tp", 1))

        pred = load_predictions(pred_path)
        gt = load_truth(truth_path)
        pred_f = filter_keys_by_prefixes(pred, prefixes)
        gt_f = filter_keys_by_prefixes(gt, prefixes)
        result = score(pred_f, gt_f)
        gate = evaluate_thresholds(
            result,
            min_precision=min_precision,
            min_recall=min_recall,
            min_f1=min_f1,
            min_tp=min_tp,
        )

        console.print(f"\n  [bold]{name}[/bold]")
        if prefixes:
            console.print(f"  [dim]prefixes: {', '.join(prefixes)}[/dim]")
        console.print(
            f"  TP={int(result['tp'])} FP={int(result['fp'])} FN={int(result['fn'])} "
            f"P={result['precision']:.4f} R={result['recall']:.4f} F1={result['f1']:.4f}"
        )
        if gate["ok"]:
            console.print("  [#22c55e]pass[/#22c55e]")
        else:
            failures += 1
            console.print(f"  [#ef4444]fail[/#ef4444] ({', '.join(gate['failed'])})")

    if failures > 0:
        sys.exit(2)


@cli.command("completions")
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
def completions(shell: str):
    """Generate shell completion script.

    \b
    Install completions:
      oncecheck completions bash >> ~/.bashrc
      oncecheck completions zsh >> ~/.zshrc
      oncecheck completions fish > ~/.config/fish/completions/oncecheck.fish
    """
    import subprocess

    env_var = "_ONCECHECK_COMPLETE"
    shell_map = {"bash": "bash_source", "zsh": "zsh_source", "fish": "fish_source"}

    result = subprocess.run(
        ["oncecheck"],
        env={**os.environ, env_var: shell_map[shell]},
        capture_output=True,
        text=True,
    )
    click.echo(result.stdout)


@cli.command("init")
@click.argument("path", default=".", type=click.Path(exists=True))
def init_config(path: str):
    """Generate a .oncecheckrc config and .oncecheckignore in the project."""
    from rich.console import Console

    console = Console()
    project_path = Path(path).resolve()
    rc_path = project_path / ".oncecheckrc"

    if rc_path.exists():
        console.print(f"  [#eab308]Config already exists:[/#eab308] {rc_path}")
    else:
        rc_path.write_text(
            "# Oncecheck configuration\n"
            "# https://oncecheck.com\n"
            "\n"
            "# Rules to suppress (same as .oncecheckignore)\n"
            "disabled_rules: []\n"
            "  # - IOS-SEC-001\n"
            "  # - WEB-OWASP-003\n"
            "\n"
            "# Override severity for specific rules\n"
            "severity_overrides: {}\n"
            "  # IOS-SEC-001: WARN\n"
            "  # WEB-A11Y-001: INFO\n"
            "\n"
            "# Exit code threshold (FAIL or WARN)\n"
            "# fail_on: FAIL\n"
        )
        console.print(f"  [#22c55e]Created config:[/#22c55e] {rc_path}")

    ignore_path = project_path / ".oncecheckignore"
    if ignore_path.exists():
        console.print(f"  [#eab308]Ignore file already exists:[/#eab308] {ignore_path}")
    else:
        ignore_path.write_text(
            "# Oncecheck — suppressed rules (one ID per line)\n"
            "# Lines starting with # are comments\n"
            "#\n"
            "# Example:\n"
            "# IOS-SEC-001\n"
            "# WEB-OWASP-003\n"
        )
        console.print(f"  [#22c55e]Created ignore file:[/#22c55e] {ignore_path}")
