# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""MergeGuide CLI entry point."""

from __future__ import annotations

import fnmatch
import logging
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any

import click
from rich.console import Console
from rich.table import Table

from mergeguide.cli_errors import format_error
from mergeguide.engine.evaluator import PolicyEngine
from mergeguide.engine.reporter import ReportFormat
from mergeguide.policies.defaults import get_default_rules
from mergeguide.policies.loader import PolicyLoader

# Global console - will be replaced per-command based on --quiet flag
_console: Console | None = None


def get_console(ctx: click.Context | None = None) -> Console:
    """Get the console instance, respecting quiet mode."""
    global _console
    if ctx and ctx.obj:
        return ctx.obj.get("console", Console())
    if _console is None:
        _console = Console()
    return _console


# Backwards compatibility
console = Console()

from datetime import UTC

from mergeguide.constants.languages import get_source_extensions

# Source file extensions to check (canonical registry)
SOURCE_EXTENSIONS = get_source_extensions()


def _matches_skip_pattern(file_path: str, patterns: list[str]) -> bool:
    """Check if a file path matches any skip pattern.

    Supports glob patterns like:
    - "**/*.generated.*" - any file with .generated. in the name
    - "**/node_modules/**" - anything in node_modules
    - "dist/**" - anything in dist directory
    - "*.min.js" - minified JS files

    Args:
        file_path: Path to the file (relative or absolute).
        patterns: List of glob patterns to match against.

    Returns:
        True if the file matches any pattern, False otherwise.
    """
    file_name = Path(file_path).name

    for pattern in patterns:
        # Direct fnmatch on full path
        if fnmatch.fnmatch(file_path, pattern):
            return True

        # Also check against just the filename
        if fnmatch.fnmatch(file_name, pattern):
            return True

        # Handle ** patterns specially
        if "**" in pattern:
            # Pattern like "**/*.generated.*" -> extract the file pattern part
            if pattern.startswith("**/"):
                # Try matching the suffix pattern against the path or filename
                suffix_pattern = pattern[3:]  # Remove "**/"
                if fnmatch.fnmatch(file_path, suffix_pattern):
                    return True
                if fnmatch.fnmatch(file_name, suffix_pattern):
                    return True
                # For nested patterns like "**/node_modules/**", check if segment is in path
                if "/**" in suffix_pattern:
                    segment = suffix_pattern.replace("/**", "")
                    if f"/{segment}/" in f"/{file_path}/" or file_path.startswith(f"{segment}/"):
                        return True
            elif pattern.endswith("/**"):
                # Pattern like "dist/**" -> check if path starts with directory
                prefix = pattern[:-3]  # Remove "/**"
                if file_path.startswith(f"{prefix}/") or file_path == prefix:
                    return True
            else:
                # General ** pattern - try segment matching
                base_pattern = pattern.replace("**/", "").replace("/**", "")
                if fnmatch.fnmatch(file_path, base_pattern):
                    return True
                if fnmatch.fnmatch(file_name, base_pattern):
                    return True
                # Check if any pattern segment is in the path
                if base_pattern and base_pattern in file_path:
                    return True

    return False


@click.group()
@click.option("--quiet", "-q", is_flag=True, help="Minimal output (only violations/errors)")
@click.option("--verbose", "-v", is_flag=True, help="Debug output (cache status, timing, etc.)")
@click.version_option()
@click.pass_context
def main(ctx: click.Context, quiet: bool, verbose: bool) -> None:
    """MergeGuide - Developer-transparent governance layer."""
    ctx.ensure_object(dict)
    ctx.obj["quiet"] = quiet
    ctx.obj["verbose"] = verbose
    ctx.obj["console"] = Console(quiet=quiet)

    # Set up logging for verbose mode
    if verbose:
        logging.basicConfig(
            level=logging.DEBUG,
            format="[%(levelname)s] %(name)s: %(message)s",
        )


@main.command()
@click.argument("paths", nargs=-1, type=click.Path())
@click.option(
    "--format",
    "-f",
    "output_format",
    type=click.Choice(["text", "json", "sarif", "markdown"]),
    default="text",
    help="Output format",
)
@click.option(
    "--output",
    "-o",
    type=click.Path(),
    help="Write output to file",
)
@click.option(
    "--policy",
    "-p",
    multiple=True,
    type=click.Path(exists=True),
    help="Additional policy file(s) to use",
)
@click.option(
    "--no-defaults",
    is_flag=True,
    help="Don't use default policies",
)
@click.option(
    "--fail-on-warning",
    is_flag=True,
    help="Exit with error if warnings are found",
)
@click.option(
    "--staged",
    is_flag=True,
    help="Check only git-staged files (for pre-commit hooks)",
)
@click.option(
    "--hook-mode",
    is_flag=True,
    help="Optimized output for git hooks (concise, no spinner)",
)
@click.option(
    "--advisory",
    is_flag=True,
    help="Advisory mode: report findings but always exit 0 (never block commits)",
)
@click.option(
    "--no-cache",
    is_flag=True,
    help="Disable file cache for evaluation results",
)
@click.option(
    "--no-semgrep",
    is_flag=True,
    help="Disable Semgrep scanning (regex only)",
)
@click.option(
    "--semgrep-timeout",
    type=int,
    default=300,
    help="Semgrep scan timeout in seconds (default: 300)",
)
@click.option(
    "--local",
    is_flag=True,
    default=False,
    help="Run scan locally without submitting results to dashboard",
)
@click.option(
    "--show-low-confidence",
    is_flag=True,
    default=False,
    help="Show LOW confidence findings in text output (hidden by default)",
)
@click.option(
    "--tier",
    type=click.Choice(["free", "pro", "team", "business", "enterprise"], case_sensitive=False),
    default="free",
    help="Organization tier for absence-of-controls gating (default: free)",
)
@click.pass_context
def check(
    ctx: click.Context,
    paths: tuple[str, ...],
    output_format: str,
    output: str | None,
    policy: tuple[str, ...],
    no_defaults: bool,
    fail_on_warning: bool,
    staged: bool,
    hook_mode: bool,
    advisory: bool,
    no_cache: bool,
    no_semgrep: bool,
    semgrep_timeout: int,
    local: bool,
    show_low_confidence: bool,
    tier: str,
) -> None:
    """Check files against policies.

    PATHS: Files or directories to check. Reads from stdin if not provided.
    """
    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)
    verbose = ctx.obj.get("verbose", False)

    if verbose:
        con.print("[dim]Starting policy check...[/dim]")

    # Initialize engine
    rules = [] if no_defaults else get_default_rules()
    engine = PolicyEngine(rules=rules)

    if verbose:
        con.print(f"[dim]Loaded {len(rules)} default rules[/dim]")

    # Load additional policies
    loader = PolicyLoader()
    for policy_path in policy:
        try:
            policy_file = loader.load_file(policy_path)
            additional_rules = loader._policy_file_to_rules(policy_file)
            engine.add_rules(additional_rules)
            if verbose:
                con.print(f"[dim]Loaded {len(additional_rules)} rules from {policy_path}[/dim]")
        except Exception as e:
            con.print(format_error("POLICY_LOAD_ERROR"))
            con.print(f"[dim]Details: {e}[/dim]")
            sys.exit(1)

    # Handle --staged flag: get staged files from git
    if staged:
        try:
            from mergeguide.hooks.git_utils import get_staged_files, is_git_repo

            if not is_git_repo():
                con.print(format_error("NOT_GIT_REPO"))
                sys.exit(1)

            staged_files = get_staged_files()
            if not staged_files:
                if hook_mode:
                    print("MergeGuide: No staged files to check")
                elif not quiet:
                    con.print("[green]No staged files to check[/green]")
                sys.exit(0)

            # Filter to source files only (using canonical LANGUAGE_REGISTRY)
            # BUG-222/BUG-223/BUG-238: Use the canonical registry instead of a
            # hardcoded subset — the old list missed .cs, .kt, .swift, .tf,
            # Dockerfile, and many other supported languages, causing hook mode
            # to silently skip files and produce 0 detections.
            from mergeguide.constants.languages import FILENAME_REGISTRY, LANGUAGE_REGISTRY

            _source_exts = set(LANGUAGE_REGISTRY.keys())
            _source_filenames = set(FILENAME_REGISTRY.keys())
            files_to_check = [
                f for f in staged_files if f.suffix in _source_exts or f.name in _source_filenames
            ]

            if not files_to_check:
                if hook_mode:
                    print("MergeGuide: No source files staged")
                elif not quiet:
                    con.print("[green]No source files staged[/green]")
                sys.exit(0)

            # Apply skip patterns from hooks config
            from mergeguide.hooks.config import HooksConfig

            hooks_config = HooksConfig.load()
            exclude_paths = hooks_config.pre_commit.exclude_paths

            if exclude_paths:
                original_count = len(files_to_check)
                files_to_check = [
                    f for f in files_to_check if not _matches_skip_pattern(str(f), exclude_paths)
                ]
                skipped_count = original_count - len(files_to_check)
                if verbose and skipped_count > 0:
                    con.print(f"[dim]Skipped {skipped_count} files matching exclude patterns[/dim]")

            if not files_to_check:
                if hook_mode:
                    print("MergeGuide: All staged files excluded by patterns")
                elif not quiet:
                    con.print("[green]All staged files excluded by patterns[/green]")
                sys.exit(0)

            if verbose:
                con.print(f"[dim]Found {len(files_to_check)} staged source files[/dim]")

        except Exception as e:
            con.print(f"[red]Error getting staged files: {e}[/red]")
            sys.exit(1)
    else:
        # Collect files to check from paths
        files_to_check: list[Path] = []
        for path_str in paths:
            path = Path(path_str)
            if not path.exists():
                # Skip deleted files gracefully (e.g. from push hook diffs)
                if verbose:
                    con.print(f"[dim]Skipping non-existent path: {path}[/dim]")
                continue
            if path.is_file():
                files_to_check.append(path)
            elif path.is_dir():
                for ext in SOURCE_EXTENSIONS:
                    files_to_check.extend(path.rglob(ext))

    if not files_to_check and not paths and not staged:
        # Read from stdin
        if not quiet:
            con.print("[dim]Reading from stdin...[/dim]")
        content = sys.stdin.read()
        output_result = engine.evaluate_content(content)
        _output_results(output_result, output_format, output, hook_mode, con, quiet)
        _exit_with_code(output_result.passed, fail_on_warning, output_result.violations)
        return

    if not files_to_check:
        if hook_mode:
            print("MergeGuide: No files to check")
        elif not quiet:
            con.print("[yellow]No files to check[/yellow]")
        return

    if verbose:
        con.print(f"[dim]Checking {len(files_to_check)} file(s)...[/dim]")

    # ----------------------------------------------------------------
    # Dual-layer scanning via orchestrator  (DEV-516 / DEV-517)
    # ----------------------------------------------------------------
    from mergeguide.engine.orchestrator import DualLayerOrchestrator

    orchestrator = DualLayerOrchestrator(
        regex_engine=engine,
        semgrep_enabled=not no_semgrep,
        semgrep_timeout=semgrep_timeout,
    )

    # Show Semgrep availability info (DEV-520: graceful fallback, SYN-029: warning level)
    if not no_semgrep:
        if orchestrator.semgrep_available:
            if verbose:
                con.print("[dim]Semgrep detected -- dual-layer scanning enabled[/dim]")
        else:
            if not quiet and not hook_mode:
                con.print(
                    "[yellow]Warning: Semgrep not available -- running regex-only scan. "
                    "Install with: pip install mergeguide[semgrep][/yellow]"
                )

    if hook_mode or quiet:
        # Hook/quiet mode: no spinner
        orch_result = orchestrator.scan(files_to_check, verbose=verbose)
    else:
        # Interactive mode: use spinner
        with con.status("[bold blue]Scanning files (regex + semgrep)..."):
            orch_result = orchestrator.scan(files_to_check, verbose=verbose)

    all_violations = orch_result.violations
    all_files_checked = orch_result.files_checked

    # Show per-engine statistics in verbose mode  (REQ-6)
    # SYN-029: Show engines_used in output
    if not quiet and not hook_mode:
        engines = ", ".join(orch_result.engines_used)
        con.print(f"[dim]Engines used: {engines}[/dim]")

    if verbose:
        rs = orch_result.regex_stats
        con.print(
            f"[dim]Regex engine: {rs.rules_count} rules, "
            f"{rs.files_scanned} files checked, "
            f"{rs.violations_found} violations ({rs.duration_ms}ms)[/dim]"
        )
        if orch_result.semgrep_stats:
            ss = orch_result.semgrep_stats
            con.print(
                f"[dim]Semgrep engine: {ss.files_scanned} files checked, "
                f"{ss.violations_found} violations ({ss.duration_ms}ms)[/dim]"
            )
        if orch_result.dedup_count > 0:
            con.print(
                f"[dim]After dedup: {len(all_violations)} unique violations "
                f"({orch_result.dedup_count} duplicates removed)[/dim]"
            )

    # Create combined output
    from mergeguide.engine.reporter import EvaluationReport

    report = EvaluationReport.create(
        evaluation_id="cli-check",
        violations=all_violations,
        files_checked=all_files_checked,
        duration_ms=(
            orch_result.regex_stats.duration_ms
            + (orch_result.semgrep_stats.duration_ms if orch_result.semgrep_stats else 0)
        ),
    )

    # ----------------------------------------------------------------
    # Absence-of-controls scan  (DEV-1106 / WS2-M2D)
    # ----------------------------------------------------------------
    # Runs after line-level violation scan. Findings are structurally
    # separate and do NOT affect the main ``passed`` boolean (IAA CRITICAL-4).
    absence_result = None
    try:
        from mergeguide.scanning.absence_scanner import AbsenceScanner

        # Determine scan directory from CLI paths (first directory arg, or cwd)
        _absence_dir = None
        for path_str in paths:
            p = Path(path_str)
            if p.is_dir():
                _absence_dir = p
                break
            elif p.is_file():
                _absence_dir = p.parent
                break
        if _absence_dir is None:
            _absence_dir = Path.cwd()

        _absence_scanner = AbsenceScanner()
        absence_result = _absence_scanner.scan_directory(_absence_dir, org_tier=tier)

        if verbose and not quiet:
            n_findings = len(absence_result.findings)
            n_frameworks = len(absence_result.frameworks_detected)
            con.print(
                f"[dim]Absence scanner: {n_frameworks} framework(s) detected, "
                f"{n_findings} missing-control finding(s)[/dim]"
            )
    except Exception as exc:
        if verbose:
            con.print(f"[dim]Absence scanner skipped: {exc}[/dim]")

    # BUG-272: Determine advisory mode from CLI flag, env var, or hooks config.
    # Priority: --advisory flag > MERGEGUIDE_ADVISORY env > hooks.yaml config
    if not advisory:
        advisory = os.environ.get("MERGEGUIDE_ADVISORY", "").lower() in ("1", "true", "yes")
    if not advisory and hook_mode:
        try:
            from mergeguide.hooks.config import HooksConfig

            hooks_config = HooksConfig.load()
            advisory = hooks_config.pre_commit.is_advisory
        except Exception:
            pass

    # Output results
    # Display-layer filtering: hide LOW confidence and code-quality findings
    # from text/hook output (all findings remain in JSON/SARIF/Markdown output)
    if hook_mode:
        display_violations = _filter_display_violations(
            report.violations, show_low_confidence=show_low_confidence
        )
        _output_hook_results(
            report,
            all_files_checked,
            advisory=advisory,
            display_violations=display_violations,
        )
    else:
        # Load compliance mapping registry for framework citations (Phase 4A)
        _compliance_registry = None
        try:
            from mergeguide.compliance import load_control_mappings

            _compliance_registry = load_control_mappings()
        except Exception:
            pass  # Graceful degradation — no framework citations

        _output_results_from_report(
            report,
            output_format,
            output,
            con,
            quiet,
            show_low_confidence=show_low_confidence,
            absence_result=absence_result,
            compliance_registry=_compliance_registry,
            tier=tier,
        )

    # Log findings
    try:
        from mergeguide.hooks.findings_log import log_session_findings

        log_session_findings(
            violations=all_violations,
            files_checked=all_files_checked,
            hook_type="pre-commit" if staged else "manual",
            enforcement_mode="advisory" if advisory else "blocking",
        )
    except Exception:
        pass  # Don't let logging failures break the hook

    # Upload findings to dashboard (async, fire-and-forget)
    if hook_mode and all_violations:
        try:
            from mergeguide.hooks.evidence_uploader import upload_hook_findings_async

            upload_hook_findings_async(
                violations=all_violations,
                files_checked=all_files_checked,
                hook_type="pre-commit" if staged else "pre-push",
                commit_sha=_get_current_commit_sha(),
                repo=_get_repo_remote_url(),
                author=_get_git_config("user.email"),
            )
        except Exception:
            pass  # Don't let upload failures break the hook

    # Auto-submit results if authenticated and not --local
    if not local:
        creds = _load_credentials()
        if creds:
            _submit_results(
                creds,
                report,
                con,
                files_checked=all_files_checked,
                commit_sha=_get_current_commit_sha(),
                repo=_get_repo_remote_url(),
                author=_get_git_config("user.email"),
            )
        elif not hook_mode:  # Don't prompt during git hook runs
            con.print("[dim]Run `mergeguide login` to sync results to your dashboard.[/dim]")

    # Exit with appropriate code
    has_errors = any(v.severity == "error" for v in all_violations)
    has_warnings = any(v.severity == "warning" for v in all_violations)

    if advisory:
        sys.exit(0)
    elif has_errors:
        sys.exit(1)
    elif has_warnings and fail_on_warning:
        sys.exit(1)


@main.command()
@click.option(
    "--global",
    "global_config",
    is_flag=True,
    help="Initialize globally in ~/.mergeguide",
)
@click.pass_context
def init(ctx: click.Context, global_config: bool) -> None:
    """Initialize MergeGuide in current repository."""
    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)

    if global_config:
        config_dir = Path.home() / ".mergeguide"
    else:
        config_dir = Path(".mergeguide")

    config_dir.mkdir(parents=True, exist_ok=True)
    policies_dir = config_dir / "policies"
    policies_dir.mkdir(exist_ok=True)

    # Create config file (BUG-369: init must create config)
    config_file = config_dir / "config.yaml"
    if not config_file.exists():
        config_file.write_text("""# MergeGuide configuration
# https://docs.mergeguide.ai/cli/configuration

version: "1.0"
fail_mode: closed
enforcement_mode: graduated
policies:
  use_defaults: true
  custom_dir: .mergeguide/policies
""")

    # Create sample policy file
    sample_policy = policies_dir / "custom.yaml"
    if not sample_policy.exists():
        sample_policy.write_text("""# Custom MergeGuide Policies
version: "1.0"

metadata:
  name: "Custom Policies"
  description: "Project-specific policy rules"

policies:
  - id: "example-rule"
    name: "Example Rule"
    description: "An example custom rule"
    severity: warning
    languages: ["*"]
    enabled: false  # Set to true to enable
    rules:
      - pattern: "example_pattern"
        message: "This is an example violation"
        remediation: "Replace with the correct pattern"
""")

    if not quiet:
        con.print(f"[green]Initialized MergeGuide in {config_dir}[/green]")
        con.print(f"  - Created config file: {config_file}")
        con.print(f"  - Created policies directory: {policies_dir}")
        con.print(f"  - Created sample policy: {sample_policy}")
        con.print("\nRun [bold]mergeguide check <files>[/bold] to scan your code")


@main.command()
@click.pass_context
def policies(ctx: click.Context) -> None:
    """List available policies."""
    con = get_console(ctx)
    rules = get_default_rules()

    table = Table(title="MergeGuide Policies")
    table.add_column("ID", style="cyan")
    table.add_column("Name", style="green")
    table.add_column("Severity", style="yellow")
    table.add_column("Languages")

    # Deduplicate by policy ID (rules may have suffixes)
    seen = set()
    for rule in rules:
        base_id = rule.id.rsplit("-", 1)[0] if "-" in rule.id else rule.id
        if base_id not in seen:
            seen.add(base_id)
            severity_style = {
                "error": "red",
                "warning": "yellow",
                "info": "blue",
            }.get(rule.severity.value, "white")

            table.add_row(
                base_id,
                rule.name,
                f"[{severity_style}]{rule.severity.value}[/{severity_style}]",
                ", ".join(rule.languages) if rule.languages != ["*"] else "all",
            )

    con.print(table)


def _output_results(
    result,
    output_format: str,
    output_path: str | None,
    hook_mode: bool = False,
    con: Console | None = None,
    quiet: bool = False,
) -> None:
    """Output evaluation results."""
    if con is None:
        con = console

    if hook_mode:
        files = result.files_checked if hasattr(result, "files_checked") else None
        _output_hook_results(result.report, files)
        return

    from mergeguide.engine.reporter import Reporter

    reporter = Reporter()
    format_map = {
        "text": ReportFormat.TEXT,
        "json": ReportFormat.JSON,
        "sarif": ReportFormat.SARIF,
        "markdown": ReportFormat.MARKDOWN,
    }

    formatted = reporter.format(result.report, format_map[output_format])

    if output_path:
        Path(output_path).write_text(formatted)
        if not quiet:
            con.print(f"[green]Output written to {output_path}[/green]")
    else:
        if output_format == "text":
            _print_text_results(result, con, quiet)
        else:
            # Use print() instead of con.print() for structured formats
            # (JSON, SARIF, Markdown) to avoid Rich Console wrapping long
            # lines at terminal width, which corrupts JSON output.
            print(formatted)


def _get_current_commit_sha() -> str:
    """Get current commit SHA, or 'unknown' on failure."""
    try:
        import subprocess

        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],  # noqa: S607
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout.strip()
    except Exception:
        return "unknown"


def _get_repo_remote_url() -> str:
    """Get repo remote origin URL, or 'unknown' on failure."""
    try:
        import subprocess

        result = subprocess.run(
            ["git", "remote", "get-url", "origin"],  # noqa: S607
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout.strip()
    except Exception:
        return "unknown"


def _get_git_config(key: str) -> str:
    """Get a git config value, or 'unknown' on failure."""
    try:
        import subprocess

        result = subprocess.run(  # noqa: S603
            ["git", "config", key],  # noqa: S607
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout.strip()
    except Exception:
        return "unknown"


def _filter_display_violations(
    violations: list,
    *,
    show_low_confidence: bool = False,
) -> list:
    """Filter violations for text/hook display output.

    By default, hides:
    - LOW confidence findings (ambiguous context regex matches)
    - code-quality category findings (console.log, TODO, debug -- not security)

    All findings remain in JSON/SARIF output regardless of this filter.

    Args:
        violations: Full list of Violation objects.
        show_low_confidence: If True, include LOW confidence findings.

    Returns:
        Filtered list of violations for display.
    """
    _CODE_QUALITY_CATEGORIES = {"code-quality"}

    filtered = []
    for v in violations:
        # Skip LOW confidence unless explicitly requested
        confidence = getattr(v, "confidence", "MEDIUM")
        if confidence == "LOW" and not show_low_confidence:
            continue

        # Skip code-quality category findings from security output
        category = getattr(v, "category", "")
        if category in _CODE_QUALITY_CATEGORIES:
            continue

        filtered.append(v)
    return filtered


def _output_results_from_report(
    report,
    output_format: str,
    output_path: str | None,
    con: Console | None = None,
    quiet: bool = False,
    show_low_confidence: bool = False,
    absence_result: Any = None,
    compliance_registry: Any = None,
    tier: str = "free",
) -> None:
    """Output results from a report.

    Args:
        absence_result: Optional AbsenceScanResult from the absence-of-controls
            scanner. When present, absence findings are appended to JSON output
            and printed as a separate section in text output. Absence findings
            do NOT affect the main ``passed`` boolean (IAA CRITICAL-4).
        compliance_registry: Optional MappingRegistry for framework control
            citations. When present, JSON and SARIF outputs include
            ``framework_controls`` on each violation (Phase 4A).
        tier: Organization tier for framework control filtering.
    """
    if con is None:
        con = console

    from mergeguide.engine.reporter import Reporter

    reporter = Reporter()
    format_map = {
        "text": ReportFormat.TEXT,
        "json": ReportFormat.JSON,
        "sarif": ReportFormat.SARIF,
        "markdown": ReportFormat.MARKDOWN,
    }

    if output_path:
        # File output always gets full (unfiltered) data
        formatted = reporter.format(report, format_map[output_format])
        if output_format == "json":
            if compliance_registry is not None:
                formatted = _inject_framework_controls_json(formatted, compliance_registry, tier)
            if absence_result is not None:
                formatted = _inject_absence_into_json(formatted, absence_result)
        elif output_format == "sarif":
            if compliance_registry is not None:
                formatted = _inject_framework_controls_sarif(formatted, compliance_registry, tier)
            if absence_result is not None:
                formatted = _inject_absence_into_sarif(formatted, absence_result)
        Path(output_path).write_text(formatted)
        if not quiet:
            con.print(f"[green]Output written to {output_path}[/green]")
    else:
        if output_format == "text":
            # Text display: apply confidence/category filtering
            display_violations = _filter_display_violations(
                report.violations, show_low_confidence=show_low_confidence
            )
            _print_text_report(report, con, quiet, display_violations=display_violations)
            # Append absence-of-controls section below violations
            if absence_result is not None:
                _print_absence_text(absence_result, con, quiet)
        elif output_format == "json":
            # JSON: output ALL findings (no display filtering) + framework citations + absence
            formatted = reporter.format(report, format_map[output_format])
            if compliance_registry is not None:
                formatted = _inject_framework_controls_json(formatted, compliance_registry, tier)
            if absence_result is not None:
                formatted = _inject_absence_into_json(formatted, absence_result)
            # Use print() instead of con.print() for structured formats
            # to avoid Rich Console wrapping long lines (corrupts JSON).
            print(formatted)
        elif output_format == "sarif":
            # SARIF: output ALL findings + framework citations + absence
            formatted = reporter.format(report, format_map[output_format])
            if compliance_registry is not None:
                formatted = _inject_framework_controls_sarif(formatted, compliance_registry, tier)
            if absence_result is not None:
                formatted = _inject_absence_into_sarif(formatted, absence_result)
            print(formatted)
        else:
            # Markdown: output ALL findings (no display filtering)
            formatted = reporter.format(report, format_map[output_format])
            # Use print() instead of con.print() for structured formats
            # to avoid Rich Console wrapping long lines (corrupts JSON).
            print(formatted)


def _inject_absence_into_json(json_str: str, absence_result: Any) -> str:
    """Merge absence-of-controls data into the JSON report output.

    Adds ``absence_findings``, ``absence_frameworks_detected``, and
    ``absence_passed`` keys to the top-level JSON object.
    """
    import json as _json

    try:
        data = _json.loads(json_str)
        data["absence_findings"] = [f.to_dict() for f in absence_result.findings]
        data["absence_frameworks_detected"] = absence_result.frameworks_detected
        data["absence_passed"] = absence_result.absence_passed
        return _json.dumps(data, indent=2)
    except Exception:
        # If JSON parsing fails for any reason, return the original string
        return json_str


def _inject_framework_controls_json(json_str: str, registry: Any, tier: str) -> str:
    """Enrich each violation in the JSON report with framework_controls.

    Uses the compliance mapping registry to add ``framework_controls`` to
    each violation dict, filtered by the caller's billing tier (Phase 4A).
    """
    import json as _json

    try:
        from mergeguide.compliance import enrich_violations

        data = _json.loads(json_str)
        if "violations" in data and isinstance(data["violations"], list):
            data["violations"] = enrich_violations(data["violations"], registry, tier)
        return _json.dumps(data, indent=2)
    except Exception:
        return json_str


def _inject_framework_controls_sarif(json_str: str, registry: Any, tier: str) -> str:
    """Enrich each SARIF result with frameworkControls in properties.

    Adds ``properties.frameworkControls`` to each result in the SARIF output,
    containing the framework/control citations for the result's ruleId (Phase 4A).
    """
    import json as _json

    try:
        data = _json.loads(json_str)
        for run in data.get("runs", []):
            for result in run.get("results", []):
                rule_id = result.get("ruleId", "")
                controls = registry.get_controls(rule_id, tier)
                if controls:
                    props = result.setdefault("properties", {})
                    props["frameworkControls"] = [
                        {
                            "frameworkId": c.framework_id,
                            "controlId": c.control_id,
                            "controlTitle": c.control_title,
                            "minTier": c.min_tier,
                        }
                        for c in controls
                    ]
        return _json.dumps(data, indent=2)
    except Exception:
        return json_str


def _inject_absence_into_sarif(json_str: str, absence_result: Any) -> str:
    """Merge absence-of-controls data into the SARIF report output.

    Adds absence findings as additional results in the SARIF run with a
    dedicated ``mergeguide-absence`` tool component, and sets
    ``properties.absencePassed`` on the run (Phase 4A).
    """
    import json as _json

    try:
        data = _json.loads(json_str)
        for run in data.get("runs", []):
            run_props = run.setdefault("properties", {})
            run_props["absencePassed"] = absence_result.absence_passed
            run_props["absenceFrameworksDetected"] = absence_result.frameworks_detected

            if absence_result.findings:
                # Add absence findings as separate SARIF results with a tag
                for finding in absence_result.findings:
                    absence_sarif_result = {
                        "ruleId": f"absence/{finding.control_id}",
                        "level": (
                            "error"
                            if finding.severity == "error"
                            else "warning"
                            if finding.severity == "warning"
                            else "note"
                        ),
                        "message": {"text": finding.message},
                        "properties": {
                            "kind": "absence-of-control",
                            "framework": finding.framework,
                            "controlId": finding.control_id,
                            "controlName": finding.control_name,
                        },
                    }
                    if finding.remediation:
                        absence_sarif_result["properties"]["remediation"] = finding.remediation
                    run.get("results", []).append(absence_sarif_result)

        return _json.dumps(data, indent=2)
    except Exception:
        return json_str


def _print_absence_text(absence_result: Any, con: Console, quiet: bool = False) -> None:
    """Print absence-of-controls findings as a Rich text section."""
    if absence_result is None or quiet:
        return

    findings = absence_result.findings
    frameworks = absence_result.frameworks_detected

    if not frameworks:
        return

    con.print("\n[bold]Absence-of-Controls Findings[/bold]")
    con.print(f"  Frameworks detected: {', '.join(frameworks)}")

    if not findings:
        con.print("  [green]All expected security controls are present.[/green]")
        return

    con.print(f"  {len(findings)} missing control(s) found:\n")

    for finding in findings:
        severity_style = {"error": "red", "warning": "yellow", "info": "blue"}.get(
            finding.severity, "white"
        )
        con.print(
            f"  [{severity_style}]{finding.severity.upper()}[/{severity_style}] "
            f"[{finding.framework}] {finding.control_id}: {finding.control_name}"
        )
        con.print(f"    {finding.message}")
        if finding.remediation:
            con.print(f"    [cyan]Remediation: {finding.remediation}[/cyan]")

    con.print(
        "\n  [dim]Absence findings are advisory and do not affect the pass/fail result.[/dim]"
    )


def _print_text_results(result, con: Console | None = None, quiet: bool = False) -> None:
    """Print results in human-readable format."""
    if con is None:
        con = console

    if result.passed:
        if not quiet:
            con.print(f"\n[green]PASSED[/green] - {len(result.files_checked)} file(s) checked")
    else:
        con.print(
            f"\n[red]FAILED[/red] - {len(result.violations)} violation(s) in "
            f"{len(result.files_checked)} file(s)"
        )

    for violation in result.violations:
        severity_style = {"error": "red", "warning": "yellow", "info": "blue"}.get(
            violation.severity, "white"
        )
        con.print(
            f"\n[{severity_style}]{violation.severity.upper()}[/{severity_style}] "
            f"{violation.file_path}:{violation.line_no}:{violation.column_start}"
        )
        con.print(f"  {violation.rule_name}: {violation.message}")
        con.print(f"  [dim]> {violation.line_content.strip()}[/dim]")
        if violation.remediation:
            con.print(f"  [cyan]Remediation: {violation.remediation}[/cyan]")


def _print_text_report(
    report,
    con: Console | None = None,
    quiet: bool = False,
    display_violations: list | None = None,
) -> None:
    """Print report in human-readable format.

    Args:
        report: EvaluationReport with summary and violations.
        con: Rich Console instance.
        quiet: Suppress non-essential output.
        display_violations: If provided, use this filtered list for display
            instead of report.violations. The summary still reflects all findings.
    """
    if con is None:
        con = console

    violations_to_show = display_violations if display_violations is not None else report.violations
    hidden_count = len(report.violations) - len(violations_to_show)

    if report.summary.passed:
        if not quiet:
            con.print(f"\n[green]PASSED[/green] - {report.summary.total_files} file(s) checked")
    else:
        con.print(
            f"\n[red]FAILED[/red] - {report.summary.total_violations} violation(s) in "
            f"{report.summary.files_with_violations} file(s)"
        )

    for violation in violations_to_show:
        severity_style = {"error": "red", "warning": "yellow", "info": "blue"}.get(
            violation.severity, "white"
        )
        confidence = getattr(violation, "confidence", "MEDIUM")
        confidence_tag = f" [{confidence}]" if confidence != "MEDIUM" else ""
        con.print(
            f"\n[{severity_style}]{violation.severity.upper()}[/{severity_style}]{confidence_tag} "
            f"{violation.file_path}:{violation.line_no}:{violation.column_start}"
        )
        con.print(f"  {violation.rule_name}: {violation.message}")
        con.print(f"  [dim]> {violation.line_content.strip()}[/dim]")
        if violation.remediation:
            con.print(f"  [cyan]Remediation: {violation.remediation}[/cyan]")

    if hidden_count > 0 and not quiet:
        con.print(
            f"\n[dim]{hidden_count} low-confidence/code-quality finding(s) hidden. "
            f"Use --show-low-confidence to show all.[/dim]"
        )


def _exit_with_code(passed: bool, fail_on_warning: bool, violations: list) -> None:
    """Exit with appropriate code based on results."""
    has_errors = any(v.severity == "error" for v in violations)
    has_warnings = any(v.severity == "warning" for v in violations)

    if has_errors:
        sys.exit(1)
    elif has_warnings and fail_on_warning:
        sys.exit(1)
    else:
        sys.exit(0)


def _group_violations_by_file(violations: list) -> dict[str, list]:
    """Group violations by file path.

    Args:
        violations: List of Violation objects.

    Returns:
        Dictionary mapping file paths to lists of violations.
    """
    by_file: dict[str, list] = {}
    for v in violations:
        by_file.setdefault(v.file_path, []).append(v)
    return by_file


def _output_hook_results(
    report,
    files_checked: list[str] | None = None,
    advisory: bool = False,
    display_violations: list | None = None,
) -> None:
    """Output results in improved hook-friendly format.

    Provides clear, actionable output grouped by file with:
    - File-by-file status with inline indicators
    - Grouped violation details
    - Partial commit suggestion for passing files
    - Clear action options

    Args:
        report: EvaluationReport with violations and summary.
        files_checked: Optional list of file paths checked (for partial commit suggestion).
        advisory: If True, show [ADVISORY] banner and allow commit.
        display_violations: If provided, use this filtered list for display.
    """
    violations_to_show = display_violations if display_violations is not None else report.violations
    total_files = report.summary.total_files
    errors = sum(1 for v in violations_to_show if v.severity == "error")
    warnings = sum(1 for v in violations_to_show if v.severity == "warning")

    # Header
    if advisory:
        print("\n[ADVISORY] MergeGuide Pre-Commit Check\n")
    else:
        print("\nMergeGuide Pre-Commit Check\n")

    if report.summary.passed:
        print(f"Checking {total_files} staged file(s)... all passed\n")
        return

    print(f"Checking {total_files} staged file(s)...\n")

    # Group violations by file (use filtered violations for display)
    violations_by_file = _group_violations_by_file(violations_to_show)

    # Get all checked files (from report or parameter)
    all_files = files_checked if files_checked else report.files_checked

    # Determine passing and failing files
    failing_files = set(violations_by_file.keys())
    passing_files = [f for f in all_files if f not in failing_files]

    # File status list with inline indicators
    for file_path in all_files:
        file_violations = violations_by_file.get(file_path, [])
        if not file_violations:
            print(f"  {file_path}")
        else:
            file_errors = sum(1 for v in file_violations if v.severity == "error")
            file_warnings = sum(1 for v in file_violations if v.severity == "warning")
            # Build status indicator
            indicators = []
            if file_errors:
                indicators.append(f"{file_errors} error{'s' if file_errors > 1 else ''}")
            if file_warnings:
                indicators.append(f"{file_warnings} warning{'s' if file_warnings > 1 else ''}")
            status = ", ".join(indicators)
            # Pad with dots for alignment
            padding = max(1, 50 - len(file_path))
            dots = "." * padding
            print(f"  {file_path} {dots} {status}")

    print()

    # Violation details grouped by file
    for _file_path, file_violations in violations_by_file.items():
        for v in file_violations:
            severity_label = "error" if v.severity == "error" else "warn"
            print(f"{v.file_path}:{v.line_no}:{v.column_start}")
            print(f"  [{severity_label}] {v.message} ({v.rule_id})")
            if v.line_content:
                print(f"  > {v.line_content.strip()}")
            if v.remediation:
                print(f"  Fix: {v.remediation}")
            print()

    # Summary line
    summary_parts = []
    if errors:
        summary_parts.append(f"{errors} error{'s' if errors > 1 else ''}")
    if warnings:
        summary_parts.append(f"{warnings} warning{'s' if warnings > 1 else ''}")
    summary = ", ".join(summary_parts)

    if advisory:
        print(f"Findings detected (advisory mode — commit allowed): {summary}\n")
    else:
        print(f"Commit blocked: {summary}\n")

        # Action options
        print("Options:")
        print("  Fix and retry ............. git add -u && git commit")

        # Partial commit suggestion if there are passing files
        if passing_files:
            # Truncate list if too long
            if len(passing_files) <= 3:
                passing_list = " ".join(passing_files)
            else:
                passing_list = (
                    " ".join(passing_files[:2]) + f" ... (+{len(passing_files) - 2} more)"
                )
            print(f"  Commit passing files ...... git commit {passing_list}")

        print("  Bypass (emergency) ........ git commit --no-verify")
        print()


# ============================================================================
# Hooks command group
# ============================================================================


@main.group()
def hooks() -> None:
    """Manage Git hooks for local policy enforcement."""
    pass


@hooks.command("install")
@click.option("--force", is_flag=True, help="Overwrite existing hooks")
@click.option(
    "--hook-type",
    type=click.Choice(["all", "pre-commit", "pre-push", "commit-msg", "post-commit"]),
    default="all",
    help="Type of hook to install",
)
@click.pass_context
def hooks_install(ctx: click.Context, force: bool, hook_type: str) -> None:
    """Install MergeGuide git hooks."""
    from mergeguide.hooks.installer import (
        HookExistsError,
        HookInstaller,
        NotInRepoError,
    )

    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)

    try:
        installer = HookInstaller()
        installed = installer.install(hook_type, force=force)

        if installed:
            if not quiet:
                con.print(f"[green]Installed hooks:[/green] {', '.join(installed)}")
                con.print("\nHooks will run automatically on commit/push.")
                con.print("Bypass with --no-verify flag if needed.")
        else:
            if not quiet:
                con.print("[yellow]No hooks installed[/yellow]")

    except NotInRepoError:
        con.print(format_error("NOT_GIT_REPO"))
        sys.exit(1)
    except HookExistsError as e:
        con.print(f"[red]Error: {e}[/red]")
        con.print("Use --force to overwrite existing hooks.")
        sys.exit(1)
    except Exception as e:
        con.print(f"[red]Error installing hooks: {e}[/red]")
        sys.exit(1)


@hooks.command("uninstall")
@click.option(
    "--hook-type",
    type=click.Choice(["all", "pre-commit", "pre-push", "commit-msg", "post-commit"]),
    default="all",
    help="Type of hook to uninstall",
)
@click.pass_context
def hooks_uninstall(ctx: click.Context, hook_type: str) -> None:
    """Remove MergeGuide git hooks."""
    from mergeguide.hooks.installer import HookInstaller, NotInRepoError

    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)

    try:
        installer = HookInstaller()
        uninstalled = installer.uninstall(hook_type)

        if uninstalled:
            if not quiet:
                con.print(f"[green]Uninstalled hooks:[/green] {', '.join(uninstalled)}")
        else:
            if not quiet:
                con.print("[yellow]No MergeGuide hooks found to uninstall[/yellow]")

    except NotInRepoError:
        con.print(format_error("NOT_GIT_REPO"))
        sys.exit(1)
    except Exception as e:
        con.print(f"[red]Error uninstalling hooks: {e}[/red]")
        sys.exit(1)


@hooks.command("status")
@click.pass_context
def hooks_status(ctx: click.Context) -> None:
    """Show status of installed hooks."""
    from mergeguide.hooks.installer import HookInstaller, NotInRepoError

    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)

    try:
        installer = HookInstaller()
        status = installer.status()

        table = Table(title="MergeGuide Hook Status")
        table.add_column("Hook", style="cyan")
        table.add_column("Status")

        for hook, hook_status in status.items():
            if hook_status == "installed":
                status_display = "[green]Installed[/green]"
            elif hook_status == "other":
                status_display = "[yellow]Other hook exists[/yellow]"
            else:
                status_display = "[dim]Not installed[/dim]"

            table.add_row(hook, status_display)

        con.print(table)

        # Show helpful commands
        if not quiet:
            not_installed = [h for h, s in status.items() if s == "not_installed"]
            if not_installed:
                con.print("\nTo install: [bold]mergeguide hooks install[/bold]")

    except NotInRepoError:
        con.print(format_error("NOT_GIT_REPO"))
        sys.exit(1)
    except Exception as e:
        con.print(f"[red]Error checking hook status: {e}[/red]")
        sys.exit(1)


CONVENTIONAL_COMMIT_TYPES = (
    "feat",
    "fix",
    "docs",
    "style",
    "refactor",
    "perf",
    "test",
    "build",
    "ci",
    "chore",
    "revert",
)


@hooks.command("check-commit-msg")
@click.argument("commit_msg_file", type=click.Path(exists=True))
@click.pass_context
def hooks_check_commit_msg(ctx: click.Context, commit_msg_file: str) -> None:
    """Validate a commit message file against Conventional Commits format.

    Called by the commit-msg git hook. Reads the commit message from the
    given file and validates it against the configured pattern (default:
    Conventional Commits).

    Exit 0 if valid, exit 1 if invalid.
    """
    import re

    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)

    types_str = "|".join(CONVENTIONAL_COMMIT_TYPES)

    # Read commit message
    try:
        msg_path = Path(commit_msg_file)
        message = msg_path.read_text().strip()
    except Exception as e:
        if not quiet:
            con.print(f"[red]Error reading commit message file: {e}[/red]")
        sys.exit(1)

    # Skip validation for merge commits, fixup, squash, etc.
    if message.startswith(("Merge ", "Revert ", "fixup! ", "squash! ", "amend! ")):
        sys.exit(0)

    # Strip comments (lines starting with #)
    lines = [line for line in message.split("\n") if not line.startswith("#")]
    first_line = lines[0] if lines else ""

    if not first_line:
        if not quiet:
            print("Missing type prefix. Expected format: <type>: <description>")
            print(f"Valid types: {types_str}")
        sys.exit(1)

    # Structural validation against Conventional Commits spec
    # Check for valid type prefix
    type_match = re.match(r"^([a-zA-Z]+)", first_line)
    if not type_match:
        if not quiet:
            print("Missing type prefix. Expected format: <type>: <description>")
            print(f"Valid types: {types_str}")
        sys.exit(1)

    parsed_type = type_match.group(1)

    # Check if the type is valid (case-sensitive -- must be lowercase)
    if parsed_type not in CONVENTIONAL_COMMIT_TYPES:
        if not quiet:
            print(f"Invalid type '{parsed_type}'. Valid types: {types_str}")
        sys.exit(1)

    # Match the full conventional commit pattern:
    # type[!][(scope)]: description
    full_pattern = rf"^({types_str})(!)?(\([^)]+\))?: .+"
    if re.match(full_pattern, first_line):
        sys.exit(0)

    # Determine the specific error
    after_type = first_line[len(parsed_type) :]
    # Skip optional ! and (scope)
    rest = after_type
    if rest.startswith("!"):
        rest = rest[1:]
    scope_match = re.match(r"^\([^)]+\)", rest)
    if scope_match:
        rest = rest[scope_match.end() :]

    if not rest.startswith(": "):
        if rest.startswith(":") and len(rest) > 1 and rest[1] != " ":
            if not quiet:
                print(
                    f"Missing space after ':'. Expected "
                    f"'{parsed_type}: description', got '{first_line}'"
                )
            sys.exit(1)
        if not quiet:
            print(
                f"Missing ': ' separator after type. Expected "
                f"'{parsed_type}: description', got '{first_line}'"
            )
        sys.exit(1)

    # Colon-space is present but description is empty
    desc = rest[2:]
    if not desc.strip():
        if not quiet:
            print(f"Missing description after type. Format: {parsed_type}: <description>")
        sys.exit(1)

    # Fallback
    sys.exit(0)


@hooks.command("bypasses")
@click.option(
    "--since",
    "-s",
    type=str,
    help="Show bypasses since date (YYYY-MM-DD)",
)
@click.option(
    "--limit",
    "-n",
    type=int,
    default=20,
    help="Maximum entries to show (default: 20)",
)
@click.pass_context
def hooks_bypasses(ctx: click.Context, since: str | None, limit: int) -> None:
    """Show audit log of hook bypasses.

    Displays a log of commits where the pre-commit hook was bypassed
    using --no-verify. Useful for compliance auditing.
    """
    from datetime import datetime

    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)

    bypass_log = Path(".mergeguide/bypass.log")

    if not bypass_log.exists():
        if not quiet:
            con.print("[green]No hook bypasses recorded[/green]")
        return

    # Parse the log file
    entries = []
    try:
        for line in bypass_log.read_text().strip().split("\n"):
            if not line:
                continue
            parts = line.split("|")
            if len(parts) >= 5:
                timestamp, event_type, author, commit_sha, file_info = parts[:5]
                # Parse timestamp for filtering
                try:
                    entry_date = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
                except ValueError:
                    entry_date = None

                entries.append(
                    {
                        "timestamp": timestamp,
                        "date": entry_date,
                        "type": event_type,
                        "author": author,
                        "commit": commit_sha,
                        "files": file_info,
                    }
                )
    except Exception as e:
        con.print(f"[red]Error reading bypass log: {e}[/red]")
        sys.exit(1)

    if not entries:
        if not quiet:
            con.print("[green]No hook bypasses recorded[/green]")
        return

    # Filter by date if --since provided
    if since:
        try:
            since_date = datetime.fromisoformat(since)
            # Make timezone-aware if needed
            if since_date.tzinfo is None:
                from datetime import UTC

                since_date = since_date.replace(tzinfo=UTC)
            entries = [e for e in entries if e["date"] and e["date"] >= since_date]
        except ValueError:
            con.print(f"[red]Invalid date format: {since}. Use YYYY-MM-DD[/red]")
            sys.exit(1)

    if not entries:
        if not quiet:
            con.print(f"[green]No bypasses found since {since}[/green]")
        return

    # Limit entries (most recent first)
    entries = entries[-limit:][::-1]

    # Display as table
    table = Table(title="Hook Bypass Audit Log")
    table.add_column("Timestamp", style="dim")
    table.add_column("Author", style="cyan")
    table.add_column("Commit", style="yellow")
    table.add_column("Files")

    for entry in entries:
        table.add_row(
            entry["timestamp"],
            entry["author"],
            entry["commit"],
            entry["files"],
        )

    con.print(table)
    con.print(f"\n[dim]Showing {len(entries)} of {len(entries)} bypass events[/dim]")


# ============================================================================
# Completions command
# ============================================================================


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

    Usage:

    \b
      # Bash (add to ~/.bashrc)
      mergeguide completions bash >> ~/.bashrc

    \b
      # Zsh (add to ~/.zshrc)
      mergeguide completions zsh >> ~/.zshrc

    \b
      # Fish
      mergeguide completions fish > ~/.config/fish/completions/mergeguide.fish
    """
    env = os.environ.copy()
    # Click's shell completion uses _<PROG>_COMPLETE environment variable
    env[f"_{shell.upper()}_COMPLETE"] = f"{shell}_source"

    # Get full path to mergeguide executable
    mergeguide_path = shutil.which("mergeguide")
    if mergeguide_path is None:
        mergeguide_path = sys.argv[0]  # Fallback to current invocation

    result = subprocess.run(  # noqa: S603 - Safe: calling our own executable
        [mergeguide_path],
        env=env,
        capture_output=True,
        text=True,
    )
    click.echo(result.stdout)


# ============================================================================
# Doctor command
# ============================================================================


@main.command()
@click.pass_context
def doctor(ctx: click.Context) -> None:
    """Diagnose MergeGuide configuration and connectivity."""
    con = get_console(ctx)
    checks: list[tuple[str, str, bool | None]] = []

    # Check 1: CLI version
    try:
        from importlib.metadata import version

        cli_version = version("mergeguide")
    except Exception:
        cli_version = "unknown"
    checks.append(("CLI version", cli_version, True))

    # Check 2: Python version
    checks.append(("Python version", platform.python_version(), True))

    # Check 3: Configuration file
    config_path = _find_config_file()
    config_found = config_path is not None
    config_display = str(config_path) if config_path else "Not found"
    checks.append(("Configuration", config_display, config_found))

    # Check 4: Git repository
    git_dir = Path(".git")
    checks.append(("Git repository", "Yes" if git_dir.exists() else "No", git_dir.exists()))

    # Check 5: Git hooks (if in repo)
    installed_hooks: list[str] = []
    if git_dir.exists():
        try:
            from mergeguide.hooks.installer import HookInstaller

            installer = HookInstaller()
            status = installer.status()
            installed_hooks = [h for h, s in status.items() if s == "installed"]
            hooks_ok = len(installed_hooks) > 0
            hooks_display = ", ".join(installed_hooks) if installed_hooks else "Not installed"
            checks.append(("Git hooks", hooks_display, hooks_ok))
        except Exception as e:
            checks.append(("Git hooks", f"Error: {e}", False))

    # Check 6: Cache status
    try:
        from mergeguide.cache import get_all_stats

        stats = get_all_stats()
        total = sum(s.get("size", 0) for s in stats.values())
        checks.append(("Cache", f"{total} entries", True))
    except Exception:
        checks.append(("Cache", "Not available", None))

    # Check 7: Default policies
    try:
        rules = get_default_rules()
        checks.append(("Default policies", f"{len(rules)} rules", True))
    except Exception as e:
        checks.append(("Default policies", f"Error: {e}", False))

    # Output
    con.print("\n[bold]MergeGuide Doctor[/bold]\n")
    table = Table(show_header=False, box=None)
    table.add_column("Status", width=3)
    table.add_column("Check", style="cyan")
    table.add_column("Value")

    for name, value, ok in checks:
        if ok is True:
            icon = "[green]\u2713[/green]"
        elif ok is False:
            icon = "[yellow]\u26a0[/yellow]"
        else:
            icon = "[dim]\u2014[/dim]"
        table.add_row(icon, name, str(value))

    con.print(table)

    # Recommendations
    recommendations = []
    if not config_found:
        recommendations.append("Run `mergeguide init` to create configuration")
    if git_dir.exists() and not installed_hooks:
        recommendations.append("Run `mergeguide hooks install` to enable pre-commit checks")

    if recommendations:
        con.print("\n[bold]Recommendations:[/bold]")
        for rec in recommendations:
            con.print(f"  \u2022 {rec}")

    con.print()


# ============================================================================
# Config command group
# ============================================================================


# Valid configuration keys and their types
CONFIG_KEYS = {
    "severity": {"type": "choice", "choices": ["error", "warning", "info"]},
    "fail_on_warning": {"type": "bool"},
    "exclude_patterns": {"type": "list"},
    "enabled_policies": {"type": "list"},
    "disabled_policies": {"type": "list"},
}


def _find_config_file() -> Path | None:
    """Find the MergeGuide config file."""
    candidates = [
        Path(".mergeguide.yaml"),
        Path(".mergeguide/config.yaml"),
        Path.home() / ".mergeguide" / "config.yaml",
    ]
    for path in candidates:
        if path.exists():
            return path
    return None


def _load_config() -> tuple[dict[str, Any], Path | None]:
    """Load configuration from file.

    Returns:
        Tuple of (config dict, config path). Config dict may be empty.
    """
    config_path = _find_config_file()
    if config_path is None:
        return {}, None

    try:
        import yaml

        content = config_path.read_text()
        config = yaml.safe_load(content) or {}
        return config, config_path
    except Exception:
        return {}, config_path


def _save_config(config: dict[str, Any], config_path: Path) -> None:
    """Save configuration to file."""
    import yaml

    config_path.parent.mkdir(parents=True, exist_ok=True)
    config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))


@main.group()
def config() -> None:
    """Manage MergeGuide configuration."""
    pass


@config.command("list")
@click.pass_context
def config_list(ctx: click.Context) -> None:
    """List current configuration."""
    con = get_console(ctx)

    config_data, config_path = _load_config()

    if config_path is None:
        con.print(format_error("CONFIG_NOT_FOUND"))
        return

    con.print(f"\n[bold]Configuration:[/bold] {config_path}\n")

    if not config_data:
        con.print("[dim]No configuration values set (using defaults)[/dim]")
        return

    table = Table(show_header=True, box=None)
    table.add_column("Key", style="cyan")
    table.add_column("Value")

    for key, value in config_data.items():
        if isinstance(value, list):
            value_str = ", ".join(str(v) for v in value)
        else:
            value_str = str(value)
        table.add_row(key, value_str)

    con.print(table)


@config.command("get")
@click.argument("key")
@click.pass_context
def config_get(ctx: click.Context, key: str) -> None:
    """Get a configuration value."""
    con = get_console(ctx)

    config_data, config_path = _load_config()

    if config_path is None:
        con.print(format_error("CONFIG_NOT_FOUND"))
        sys.exit(1)

    if key not in config_data:
        con.print(f"[yellow]Key '{key}' not set (using default)[/yellow]")
        return

    value = config_data[key]
    if isinstance(value, list):
        click.echo(", ".join(str(v) for v in value))
    else:
        click.echo(value)


@config.command("set")
@click.argument("key")
@click.argument("value")
@click.pass_context
def config_set(ctx: click.Context, key: str, value: str) -> None:
    """Set a configuration value."""
    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)

    config_data, config_path = _load_config()

    if config_path is None:
        # Create default config path
        config_path = Path(".mergeguide/config.yaml")

    # Validate key if it's a known key
    if key in CONFIG_KEYS:
        key_info = CONFIG_KEYS[key]
        if key_info["type"] == "choice":
            if value not in key_info["choices"]:
                con.print(
                    f"[red]Invalid value '{value}' for key '{key}'[/red]\n"
                    f"Valid choices: {', '.join(key_info['choices'])}"
                )
                sys.exit(1)
        elif key_info["type"] == "bool":
            value = value.lower() in ("true", "1", "yes", "on")
        elif key_info["type"] == "list":
            value = [v.strip() for v in value.split(",")]

    config_data[key] = value

    try:
        _save_config(config_data, config_path)
        if not quiet:
            con.print(f"[green]Set {key} = {value}[/green]")
    except Exception as e:
        con.print(format_error("CONFIG_WRITE_ERROR"))
        con.print(f"[dim]Details: {e}[/dim]")
        sys.exit(1)


@config.command("unset")
@click.argument("key")
@click.pass_context
def config_unset(ctx: click.Context, key: str) -> None:
    """Remove a configuration value."""
    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)

    config_data, config_path = _load_config()

    if config_path is None:
        con.print(format_error("CONFIG_NOT_FOUND"))
        sys.exit(1)

    if key not in config_data:
        con.print(f"[yellow]Key '{key}' not set[/yellow]")
        return

    del config_data[key]

    try:
        _save_config(config_data, config_path)
        if not quiet:
            con.print(f"[green]Removed {key}[/green]")
    except Exception as e:
        con.print(format_error("CONFIG_WRITE_ERROR"))
        con.print(f"[dim]Details: {e}[/dim]")
        sys.exit(1)


@config.command("path")
@click.pass_context
def config_path(ctx: click.Context) -> None:
    """Show configuration file path."""
    config_file = _find_config_file()

    if config_file is None:
        click.echo("No configuration file found")
        sys.exit(1)

    click.echo(config_file)


# ============================================================================
# SBOM command group
# ============================================================================


@main.group()
def sbom() -> None:
    """Generate Software Bill of Materials (SBOM)."""
    pass


@sbom.command("generate")
@click.argument("paths", nargs=-1, type=click.Path(exists=True))
@click.option(
    "--format",
    "-f",
    "output_format",
    type=click.Choice(["cyclonedx-json", "cyclonedx-xml", "spdx-json", "spdx-tag-value"]),
    default="cyclonedx-json",
    help="Output format (default: cyclonedx-json)",
)
@click.option(
    "--output",
    "-o",
    type=click.Path(),
    help="Write output to file (default: stdout)",
)
@click.option(
    "--name",
    "-n",
    type=str,
    default="",
    help="Project/component name",
)
@click.option(
    "--version",
    type=str,
    default="",
    help="Project/component version",
)
@click.option(
    "--description",
    "-d",
    type=str,
    default="",
    help="Project description",
)
@click.option(
    "--include-dev",
    is_flag=True,
    help="Include dev dependencies",
)
@click.pass_context
def sbom_generate(
    ctx: click.Context,
    paths: tuple[str, ...],
    output_format: str,
    output: str | None,
    name: str,
    version: str,
    description: str,
    include_dev: bool,
) -> None:
    """Generate SBOM from manifest files.

    PATHS: Files or directories to scan. If directory, scans for manifest files.
    Defaults to current directory if not specified.

    \b
    Supported manifest files:
      - npm: package.json, package-lock.json
      - pip: requirements.txt, Pipfile, pyproject.toml
      - go: go.mod, go.sum
      - cargo: Cargo.toml, Cargo.lock
      - maven: pom.xml
      - gem: Gemfile, Gemfile.lock
      - composer: composer.json, composer.lock
      - nuget: packages.config, *.csproj

    \b
    Examples:
      mergeguide sbom generate .
      mergeguide sbom generate --format spdx-json -o sbom.json
      mergeguide sbom generate --name "MyApp" --version "1.0.0" package.json
    """
    from mergeguide.sbom import SBOMFormat, generate_sbom, generate_sbom_from_directory

    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)
    verbose = ctx.obj.get("verbose", False)

    # Map string format to enum
    format_map = {
        "cyclonedx-json": SBOMFormat.CYCLONEDX_JSON,
        "cyclonedx-xml": SBOMFormat.CYCLONEDX_XML,
        "spdx-json": SBOMFormat.SPDX_JSON,
        "spdx-tag-value": SBOMFormat.SPDX_TAG_VALUE,
    }
    sbom_format = format_map[output_format]

    # Default to current directory
    if not paths:
        paths = (".",)

    try:
        # Collect files from all paths
        files: dict[str, str] = {}

        for path_str in paths:
            path = Path(path_str)

            if path.is_dir():
                # Scan directory for manifest files
                if verbose:
                    con.print(f"[dim]Scanning directory: {path}[/dim]")

                # Use generate_sbom_from_directory for single directory
                if len(paths) == 1:
                    result = generate_sbom_from_directory(
                        directory=path,
                        format=sbom_format,
                        name=name or path.name,
                        version=version,
                        description=description,
                        include_dev=include_dev,
                    )

                    if output:
                        Path(output).write_text(result)
                        if not quiet:
                            con.print(f"[green]SBOM written to {output}[/green]")
                    else:
                        click.echo(result)
                    return

                # Multiple directories: collect files
                manifest_files = [
                    "package.json",
                    "package-lock.json",
                    "requirements.txt",
                    "Pipfile",
                    "pyproject.toml",
                    "go.mod",
                    "go.sum",
                    "Cargo.toml",
                    "Cargo.lock",
                    "pom.xml",
                    "Gemfile",
                    "Gemfile.lock",
                    "composer.json",
                    "composer.lock",
                    "packages.config",
                ]

                for manifest_name in manifest_files:
                    manifest_path = path / manifest_name
                    if manifest_path.exists():
                        try:
                            files[str(manifest_path)] = manifest_path.read_text()
                            if verbose:
                                con.print(f"[dim]  Found: {manifest_path}[/dim]")
                        except Exception as e:
                            if verbose:
                                con.print(f"[dim]  Error reading {manifest_path}: {e}[/dim]")

                # Also scan for .csproj files
                for csproj in path.glob("*.csproj"):
                    try:
                        files[str(csproj)] = csproj.read_text()
                        if verbose:
                            con.print(f"[dim]  Found: {csproj}[/dim]")
                    except Exception:
                        pass

            elif path.is_file():
                # Single file
                try:
                    files[str(path)] = path.read_text()
                    if verbose:
                        con.print(f"[dim]Reading: {path}[/dim]")
                except Exception as e:
                    con.print(f"[red]Error reading {path}: {e}[/red]")
                    sys.exit(1)

        if not files:
            con.print("[yellow]No manifest files found[/yellow]")
            sys.exit(1)

        # Generate SBOM
        result = generate_sbom(
            files=files,
            format=sbom_format,
            name=name,
            version=version,
            description=description,
            include_dev=include_dev,
        )

        if output:
            Path(output).write_text(result)
            if not quiet:
                con.print(f"[green]SBOM written to {output}[/green]")
        else:
            click.echo(result)

    except Exception as e:
        con.print(f"[red]Error generating SBOM: {e}[/red]")
        sys.exit(1)


# ============================================================================
# Scan command group (DEV-873)
# ============================================================================


@main.group()
def scan() -> None:
    """Run specialized security scanners (vulnerability, IaC)."""
    pass


@scan.command("vuln")
@click.argument("paths", nargs=-1, type=click.Path())
@click.option(
    "--path",
    "-p",
    "scan_path",
    type=click.Path(exists=True),
    default=None,
    help="Directory to scan (default: current directory)",
)
@click.option(
    "--format",
    "-f",
    "output_format",
    type=click.Choice(["text", "json", "sarif"]),
    default="text",
    help="Output format (default: text)",
)
@click.option(
    "--fail-on",
    type=click.Choice(["none", "high", "critical"]),
    default="none",
    help="Exit 1 when findings at this severity or above (default: none)",
)
@click.pass_context
def scan_vuln(
    ctx: click.Context,
    paths: tuple[str, ...],
    scan_path: str | None,
    output_format: str,
    fail_on: str,
) -> None:
    """Scan manifest files for known vulnerabilities (OSV).

    PATHS: Specific manifest files to scan. If omitted, scans --path directory.

    \b
    Examples:
      mergeguide scan vuln
      mergeguide scan vuln --path ./my-project
      mergeguide scan vuln package.json requirements.txt
      mergeguide scan vuln --format json --fail-on critical
    """
    from mergeguide.scanning.vulnerability_scanner import VulnerabilityScanner

    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)

    scanner = VulnerabilityScanner()

    # Build the file map: {path: content}
    files: dict[str, str] = {}

    if paths:
        # Explicit file paths provided
        for p in paths:
            file_path = Path(p)
            if file_path.is_file():
                try:
                    files[str(file_path)] = file_path.read_text()
                except Exception as e:
                    con.print(f"[red]Error reading {file_path}: {e}[/red]")
            elif file_path.is_dir():
                # Treat as directory scan
                result = scanner.scan_directory(file_path)
                _output_scan_result_vuln(result, output_format, con, quiet)
                _exit_scan(fail_on, result.critical_count, result.high_count)
                return
    else:
        # Scan directory (--path or cwd)
        target_dir = Path(scan_path) if scan_path else Path.cwd()
        result = scanner.scan_directory(target_dir)
        _output_scan_result_vuln(result, output_format, con, quiet)
        _exit_scan(fail_on, result.critical_count, result.high_count)
        return

    if not files:
        if not quiet:
            con.print("[yellow]No manifest files found to scan[/yellow]")
        return

    result = scanner.scan_files(files)
    _output_scan_result_vuln(result, output_format, con, quiet)
    _exit_scan(fail_on, result.critical_count, result.high_count)


@scan.command("iac")
@click.argument("paths", nargs=-1, type=click.Path())
@click.option(
    "--path",
    "-p",
    "scan_path",
    type=click.Path(exists=True),
    default=None,
    help="Directory to scan (default: current directory)",
)
@click.option(
    "--format",
    "-f",
    "output_format",
    type=click.Choice(["text", "json", "sarif"]),
    default="text",
    help="Output format (default: text)",
)
@click.option(
    "--fail-on",
    type=click.Choice(["none", "high", "critical"]),
    default="none",
    help="Exit 1 when findings at this severity or above (default: none)",
)
@click.pass_context
def scan_iac(
    ctx: click.Context,
    paths: tuple[str, ...],
    scan_path: str | None,
    output_format: str,
    fail_on: str,
) -> None:
    """Scan infrastructure-as-code files for security misconfigurations.

    PATHS: Specific IaC files to scan. If omitted, scans --path directory.

    \b
    Supported formats: Terraform, CloudFormation, Kubernetes, Helm

    \b
    Examples:
      mergeguide scan iac
      mergeguide scan iac --path ./infra
      mergeguide scan iac main.tf variables.tf
      mergeguide scan iac --format json --fail-on high
    """
    from mergeguide.scanning.iac_scanner import IaCScanner

    con = get_console(ctx)
    quiet = ctx.obj.get("quiet", False)

    scanner = IaCScanner()

    # Build the file map: {path: content}
    files: dict[str, str] = {}

    if paths:
        for p in paths:
            file_path = Path(p)
            if file_path.is_file():
                try:
                    files[str(file_path)] = file_path.read_text()
                except Exception as e:
                    con.print(f"[red]Error reading {file_path}: {e}[/red]")
            elif file_path.is_dir():
                result = scanner.scan_directory(file_path)
                _output_scan_result_iac(result, output_format, con, quiet)
                _exit_scan(fail_on, result.critical_count, result.high_count)
                return
    else:
        target_dir = Path(scan_path) if scan_path else Path.cwd()
        result = scanner.scan_directory(target_dir)
        _output_scan_result_iac(result, output_format, con, quiet)
        _exit_scan(fail_on, result.critical_count, result.high_count)
        return

    if not files:
        if not quiet:
            con.print("[yellow]No IaC files found to scan[/yellow]")
        return

    result = scanner.scan_files(files)
    _output_scan_result_iac(result, output_format, con, quiet)
    _exit_scan(fail_on, result.critical_count, result.high_count)


def _exit_scan(fail_on: str, critical_count: int, high_count: int) -> None:
    """Exit with appropriate code based on --fail-on threshold."""
    if fail_on == "critical" and critical_count > 0:
        sys.exit(1)
    elif fail_on == "high" and (critical_count > 0 or high_count > 0):
        sys.exit(1)
    # fail_on == "none" → always exit 0


def _output_scan_result_vuln(
    result: Any,
    output_format: str,
    con: Console,
    quiet: bool,
) -> None:
    """Output vulnerability scan results."""
    import json as json_mod

    if output_format == "json":
        print(json_mod.dumps(result.to_dict(), indent=2))
    elif output_format == "sarif":
        print(json_mod.dumps(_vuln_to_sarif(result), indent=2))
    else:
        # Text output
        if result.total_vulnerabilities == 0:
            if not quiet:
                con.print(
                    f"[green]No vulnerabilities found[/green] "
                    f"({result.total_dependencies} dependencies scanned "
                    f"across {len(result.manifests_scanned)} manifest(s))"
                )
            return

        con.print(
            f"\n[red]Found {result.total_vulnerabilities} vulnerability(ies)[/red] "
            f"in {result.total_dependencies} dependencies"
        )
        con.print(
            f"  Critical: {result.critical_count}  High: {result.high_count}  "
            f"Medium: {result.medium_count}  Low: {result.low_count}"
        )

        for finding in result.findings:
            severity = finding.severity_level.upper()
            sev_style = {"ERROR": "red", "WARNING": "yellow", "INFO": "blue"}.get(severity, "white")
            dep = finding.dependency
            vuln = finding.vulnerability
            con.print(
                f"\n[{sev_style}]{severity}[/{sev_style}] "
                f"{dep.name}@{dep.version} ({dep.ecosystem.value})"
            )
            con.print(f"  {vuln.id}: {vuln.summary}")
            if vuln.severity:
                con.print(f"  [dim]CVSS severity: {vuln.severity}[/dim]")


def _output_scan_result_iac(
    result: Any,
    output_format: str,
    con: Console,
    quiet: bool,
) -> None:
    """Output IaC scan results."""
    import json as json_mod

    if output_format == "json":
        print(json_mod.dumps(result.to_dict(), indent=2))
    elif output_format == "sarif":
        print(json_mod.dumps(_iac_to_sarif(result), indent=2))
    else:
        # Text output
        if result.total_issues == 0:
            if not quiet:
                con.print(
                    f"[green]No IaC security issues found[/green] "
                    f"({len(result.files_scanned)} file(s) scanned)"
                )
            return

        con.print(
            f"\n[red]Found {result.total_issues} IaC security issue(s)[/red] "
            f"in {len(result.files_scanned)} file(s)"
        )
        con.print(
            f"  Critical: {result.critical_count}  High: {result.high_count}  "
            f"Medium: {result.medium_count}  Low: {result.low_count}  "
            f"Info: {result.info_count}"
        )

        for finding in result.findings:
            severity = finding.severity.value.upper()
            sev_style = {
                "CRITICAL": "red",
                "HIGH": "red",
                "MEDIUM": "yellow",
                "LOW": "blue",
                "INFO": "dim",
            }.get(severity, "white")
            con.print(
                f"\n[{sev_style}]{severity}[/{sev_style}] {finding.file_path}:{finding.line_no}"
            )
            con.print(f"  [{finding.rule_id}] {finding.rule_name}")
            con.print(f"  {finding.description}")
            if finding.line_content:
                con.print(f"  [dim]> {finding.line_content.strip()}[/dim]")
            if finding.remediation:
                con.print(f"  [cyan]Fix: {finding.remediation}[/cyan]")


def _vuln_to_sarif(result: Any) -> dict[str, Any]:
    """Convert vulnerability scan result to SARIF format."""
    runs: list[dict[str, Any]] = []
    rules: list[dict[str, Any]] = []
    results_list: list[dict[str, Any]] = []
    seen_rules: set[str] = set()

    for finding in result.findings:
        vuln = finding.vulnerability
        rule_id = vuln.id
        if rule_id not in seen_rules:
            seen_rules.add(rule_id)
            rules.append(
                {
                    "id": rule_id,
                    "shortDescription": {"text": vuln.summary or rule_id},
                }
            )
        level_map = {"error": "error", "warning": "warning", "info": "note"}
        results_list.append(
            {
                "ruleId": rule_id,
                "level": level_map.get(finding.severity_level, "warning"),
                "message": {
                    "text": (
                        f"{finding.dependency.name}@{finding.dependency.version}: {vuln.summary}"
                    )
                },
                "locations": [
                    {
                        "physicalLocation": {
                            "artifactLocation": {
                                "uri": finding.dependency.source_file or "unknown"
                            },
                        }
                    }
                ],
            }
        )

    runs.append(
        {
            "tool": {"driver": {"name": "mergeguide-vuln", "rules": rules}},
            "results": results_list,
        }
    )

    return {
        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
        "version": "2.1.0",
        "runs": runs,
    }


def _iac_to_sarif(result: Any) -> dict[str, Any]:
    """Convert IaC scan result to SARIF format."""
    rules: list[dict[str, Any]] = []
    results_list: list[dict[str, Any]] = []
    seen_rules: set[str] = set()

    severity_to_level = {
        "critical": "error",
        "high": "error",
        "medium": "warning",
        "low": "note",
        "info": "note",
    }

    for finding in result.findings:
        if finding.rule_id not in seen_rules:
            seen_rules.add(finding.rule_id)
            rules.append(
                {
                    "id": finding.rule_id,
                    "shortDescription": {"text": finding.rule_name},
                }
            )
        results_list.append(
            {
                "ruleId": finding.rule_id,
                "level": severity_to_level.get(finding.severity.value, "warning"),
                "message": {"text": finding.description},
                "locations": [
                    {
                        "physicalLocation": {
                            "artifactLocation": {"uri": finding.file_path},
                            "region": {
                                "startLine": finding.line_no,
                                "startColumn": finding.column_start,
                            },
                        }
                    }
                ],
            }
        )

    return {
        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
        "version": "2.1.0",
        "runs": [
            {
                "tool": {"driver": {"name": "mergeguide-iac", "rules": rules}},
                "results": results_list,
            }
        ],
    }


@sbom.command("formats")
@click.pass_context
def sbom_formats(ctx: click.Context) -> None:
    """List supported SBOM output formats."""
    con = get_console(ctx)

    table = Table(title="Supported SBOM Formats")
    table.add_column("Format", style="cyan")
    table.add_column("Standard")
    table.add_column("Description")

    formats = [
        ("cyclonedx-json", "CycloneDX 1.5", "JSON format, widely supported"),
        ("cyclonedx-xml", "CycloneDX 1.5", "XML format, legacy compatible"),
        ("spdx-json", "SPDX 2.3", "JSON format, Linux Foundation standard"),
        ("spdx-tag-value", "SPDX 2.3", "Tag-value format, human readable"),
    ]

    for fmt, standard, desc in formats:
        table.add_row(fmt, standard, desc)

    con.print(table)


# ============================================================================
# Authentication helpers
# ============================================================================


def _load_credentials() -> dict | None:
    """Load credentials from ~/.mergeguide/credentials.yaml."""
    creds_path = Path.home() / ".mergeguide" / "credentials.yaml"
    if not creds_path.exists():
        return None
    try:
        import yaml

        with open(creds_path) as f:
            return yaml.safe_load(f)
    except Exception:
        return None


def _submit_results(
    creds: dict,
    report,
    con: Console,
    *,
    files_checked: list | None = None,
    commit_sha: str | None = None,
    repo: str | None = None,
    author: str | None = None,
) -> None:
    """Submit scan results to the dashboard API."""
    import httpx

    api_url = creds.get("api_url", "https://portal.mergeguide.ai")
    api_key = creds.get("api_key", "")

    try:
        result_payload = {
            "source": "cli",
            "repo": repo or "unknown",
            "commit_sha": commit_sha or "unknown",
            "author": author or "unknown",
            "files_checked": [str(f) for f in (files_checked or [])],
            "violations": [
                {
                    "rule_id": v.rule_id,
                    "rule_name": v.rule_name,
                    "severity": v.severity,
                    "file_path": v.file_path,
                    "line_no": v.line_no,
                    "message": v.message,
                    "code_snippet": getattr(v, "code_snippet", None),
                    "remediation": v.remediation,
                }
                for v in report.violations
            ],
            "policies_used": list({v.rule_id for v in report.violations if v.rule_id}),
            "duration_ms": getattr(report, "duration_ms", None),
        }
    except Exception:
        result_payload = {
            "source": "cli",
            "repo": repo or "unknown",
            "commit_sha": commit_sha or "unknown",
            "violations": [],
            "files_checked": [],
        }

    try:
        response = httpx.post(
            f"{api_url}/api/v1/evaluations",
            json=result_payload,
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=10.0,
        )
        if response.status_code in (200, 201):
            con.print("[green]Results synced to dashboard.[/green]")
        else:
            con.print(
                f"[yellow]Dashboard sync failed ({response.status_code}). "
                f"Results saved locally.[/yellow]"
            )
    except Exception:
        con.print("[yellow]Dashboard sync unavailable. Results saved locally.[/yellow]")


# ============================================================================
# Login command
# ============================================================================


@main.command()
@click.option("--api-key", help="API key for authentication (alternative to interactive login)")
@click.option(
    "--api-url",
    default="https://portal.mergeguide.ai",
    help="Dashboard URL",
)
@click.pass_context
def login(ctx: click.Context, api_key: str | None, api_url: str) -> None:
    """Authenticate with the MergeGuide dashboard."""
    con = get_console(ctx)

    # Get API key: from flag or interactive prompt
    if not api_key:
        api_key = click.prompt("Enter your API key", hide_input=True)

    if not api_key:
        con.print("[red]No API key provided.[/red]")
        sys.exit(1)

    # Validate the key against the dashboard
    org_slug = None
    try:
        import httpx

        response = httpx.get(
            f"{api_url}/api/v1/api-keys/validate",
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=10.0,
        )
        if response.status_code == 200:
            data = response.json()
            org_slug = data.get("org_slug") or data.get("orgSlug")
            con.print("[green]API key validated.[/green]")
        elif response.status_code == 401:
            con.print("[red]Invalid API key. Check your key and try again.[/red]")
            sys.exit(1)
        else:
            # Non-critical: store anyway, validate later
            con.print(
                "[yellow]Could not validate key (server returned "
                f"{response.status_code}). Storing credentials anyway.[/yellow]"
            )
    except Exception:
        # Network error: store anyway, validate later
        con.print(
            "[yellow]Could not reach dashboard for validation. Storing credentials anyway.[/yellow]"
        )

    # Store credentials
    from datetime import datetime

    creds_dir = Path.home() / ".mergeguide"
    creds_dir.mkdir(parents=True, exist_ok=True)
    creds_path = creds_dir / "credentials.yaml"

    import yaml

    creds = {
        "api_url": api_url,
        "api_key": api_key,
        "authenticated_at": datetime.now(UTC).isoformat(),
    }
    if org_slug:
        creds["org_slug"] = org_slug

    # Write credentials with restricted permissions atomically (no world-readable window)
    fd = os.open(str(creds_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        yaml.dump(creds, f, default_flow_style=False, sort_keys=False)

    con.print("\nAuthenticated. Scan results will sync to your dashboard.")
    con.print(f"[dim]Credentials stored in {creds_path}[/dim]")


if __name__ == "__main__":
    main()
