"""sift — AI-Powered Alert Triage Summarizer.

Entry point for the Typer CLI. Wires together:
  normalizers → dedup → ioc_extract → cluster → prioritize → summarize → output
"""

from __future__ import annotations

import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated, Optional

import typer
from rich.console import Console

from . import __version__
from .banner import show_banner
from .config import load_config
from .models import PipelineManifest, TriageReport

app = typer.Typer(
    name="sift",
    help="AI-powered alert triage summarizer for SOC teams.",
    add_completion=False,
    no_args_is_help=True,
)
console = Console(stderr=True)


# ---------------------------------------------------------------------------
# Format enum
# ---------------------------------------------------------------------------

class OutputFormat(str):
    RICH = "rich"
    CONSOLE = "console"
    JSON = "json"
    CSV = "csv"
    STIX = "stix"


# ---------------------------------------------------------------------------
# Helper: resolve normalizer
# ---------------------------------------------------------------------------

def _normalize(raw: str) -> tuple[list, str]:
    """Auto-detect format and return (alerts, format_name)."""
    from .normalizers.csv_normalizer import CSVNormalizer
    from .normalizers.generic import GenericNormalizer
    from .normalizers.splunk import SplunkNormalizer

    for normalizer in [SplunkNormalizer(), GenericNormalizer(), CSVNormalizer()]:
        if normalizer.can_handle(raw):
            alerts = normalizer.normalize(raw)
            if alerts:
                return alerts, normalizer.name
    # Last resort: generic without can_handle check
    alerts = GenericNormalizer().normalize(raw)
    return alerts, "generic"


# ---------------------------------------------------------------------------
# Helper: build summarizer
# ---------------------------------------------------------------------------

def _build_summarizer(provider: str, config):
    from .summarizers.template import TemplateSummarizer

    # Attach injection whitelist patterns so build_cluster_prompt() can use them.
    # This bridges PromptInjectionConfig → SummarizeConfig without changing the schema.
    summarize_cfg = config.summarize
    summarize_cfg._injection_whitelist = config.injection.whitelist_patterns

    if provider == "template":
        return TemplateSummarizer()

    if provider == "mock":
        from .summarizers.mock import MockSummarizer
        return MockSummarizer()

    if provider == "anthropic":
        try:
            from .summarizers.anthropic import AnthropicSummarizer
            return AnthropicSummarizer(config.summarize)
        except ImportError as e:
            console.print(f"[yellow]Warning:[/yellow] {e}")
            console.print("[dim]Falling back to template summarizer.[/dim]")
            return TemplateSummarizer()

    if provider == "openai":
        try:
            from .summarizers.openai import OpenAISummarizer
            return OpenAISummarizer(config.summarize)
        except ImportError as e:
            console.print(f"[yellow]Warning:[/yellow] {e}")
            console.print("[dim]Falling back to template summarizer.[/dim]")
            return TemplateSummarizer()

    if provider == "ollama":
        from .summarizers.ollama import OllamaSummarizer
        return OllamaSummarizer(config.summarize)

    console.print(f"[yellow]Unknown provider '{provider}', using template.[/yellow]")
    return TemplateSummarizer()


# ---------------------------------------------------------------------------
# Helper: enrichment consent
# ---------------------------------------------------------------------------

def _check_enrich_consent(yes: bool, cfg) -> bool:
    """Returns True if consent is given for external API calls."""
    if yes or cfg.enrich.consent_given:
        return True
    console.print(
        "\n[yellow]--enrich[/yellow] will call external APIs (barb, vex).\n"
        "This sends IOC data to VirusTotal (via vex) and runs local barb analysis.\n"
    )
    return typer.confirm("Proceed with enrichment?", default=False)


# ---------------------------------------------------------------------------
# triage command
# ---------------------------------------------------------------------------

@app.command()
def triage(
    files: Annotated[list[Path], typer.Argument(
        help=(
            "Alert files or directories to triage (JSON, Splunk JSON, CSV). "
            "Pass multiple paths to correlate alerts across sources. "
            "Use '-' for stdin. Directories are scanned for .json and .csv files."
        ),
        show_default=False,
    )],
    format: Annotated[str, typer.Option(
        "--format", "-f",
        help="Output format: rich | console | json | csv | stix",
    )] = "rich",
    filter: Annotated[Optional[str], typer.Option(
        "--filter",
        help="Filter clusters using boolean DSL (e.g., 'priority >= HIGH')",
    )] = None,
    summarize: Annotated[bool, typer.Option(
        "--summarize", "-s",
        help="Generate AI/template triage summary.",
    )] = False,
    provider: Annotated[Optional[str], typer.Option(
        help="LLM provider: template | mock | anthropic | openai | ollama",
    )] = None,
    output: Annotated[Optional[Path], typer.Option(
        "--output", "-o",
        help="Save output to file.",
    )] = None,
    quiet: Annotated[bool, typer.Option(
        "--quiet", "-q",
        help="Suppress banner.",
    )] = False,
    no_dedup: Annotated[bool, typer.Option(
        "--no-dedup",
        help="Skip alert deduplication.",
    )] = False,
    config_path: Annotated[Optional[Path], typer.Option(
        "--config",
        help="Path to config.yaml",
        show_default=False,
    )] = None,
    enrich: Annotated[bool, typer.Option(
        "--enrich",
        help="Enrich IOCs via barb (URLs) and vex (all IOCs). Requires consent or --yes.",
    )] = False,
    enrich_mode: Annotated[Optional[str], typer.Option(
        "--enrich-mode",
        help="Enrichment scope: all | barb | vex | local  [default: all]. 'local' = heuristic-only, no external API calls.",
    )] = None,
    yes: Annotated[bool, typer.Option(
        "--yes", "-y",
        help="Skip consent prompt for external API calls (--enrich).",
    )] = False,
    validate_only: Annotated[bool, typer.Option(
        "--validate-only",
        help="Validation-only mode: parse and validate, skip output rendering.",
    )] = False,
    cache: Annotated[bool, typer.Option(
        "--cache",
        help="Cache triage results by input fingerprint (opt-in, TTL 1h).",
    )] = False,
    redact_fields: Annotated[Optional[str], typer.Option(
        "--redact-fields",
        help="Comma-separated alert fields to redact before processing (e.g. 'user,host,source_ip').",
    )] = None,
    chunk_size: Annotated[int, typer.Option(
        "--chunk-size",
        help="Process alerts in chunks of N (0 = no chunking, default). Reduces memory for large files.",
    )] = 0,
) -> None:
    """Triage alerts from one or more FILES or directories.

    Multiple sources are merged before clustering, enabling cross-source correlation.
    """

    # --- Load config ---
    cfg = load_config(config_path)

    # --- Banner ---
    show_banner(
        quiet=quiet or cfg.output.quiet,
        update_check_enabled=cfg.update_check.enabled,
        check_interval_hours=cfg.update_check.check_interval_hours,
    )

    # --- Resolve input paths (expand directories, validate files) ---
    _SUPPORTED_SUFFIXES = {".json", ".csv", ".ndjson", ".log"}

    def _resolve_paths(raw_paths: list[Path]) -> list[Path]:
        resolved: list[Path] = []
        for p in raw_paths:
            if str(p) == "-":
                resolved.append(p)
            elif p.is_dir():
                found = sorted(
                    f for f in p.rglob("*") if f.is_file() and f.suffix.lower() in _SUPPORTED_SUFFIXES
                )
                if not found:
                    console.print(f"[yellow]Warning:[/yellow] No supported files found in directory: {p}")
                else:
                    resolved.extend(found)
            elif p.exists():
                resolved.append(p)
            else:
                console.print(f"[red]Error:[/red] File not found: {p}")
                raise typer.Exit(2)
        return resolved

    resolved_paths = _resolve_paths(files)
    if not resolved_paths:
        console.print("[red]Error:[/red] No input files to process.")
        raise typer.Exit(2)

    # --- Read and normalize all sources, merge alerts ---
    all_alerts: list = []
    all_raws: list[str] = []
    source_labels: list[str] = []
    detected_formats: list[str] = []

    for path in resolved_paths:
        try:
            if str(path) == "-":
                raw = sys.stdin.read()
                label = "<stdin>"
            else:
                raw = path.read_text(encoding="utf-8")
                label = str(path)
        except Exception as exc:
            console.print(f"[red]Error reading {path}:[/red] {exc}")
            raise typer.Exit(2)

        if not raw.strip():
            console.print(f"[yellow]Warning:[/yellow] Skipping empty file: {label}")
            continue

        try:
            source_alerts, fmt = _normalize(raw)
        except Exception as exc:
            console.print(f"[red]Error normalizing {label}:[/red] {exc}")
            raise typer.Exit(2)

        if not source_alerts:
            console.print(f"[yellow]Warning:[/yellow] No alerts parsed from: {label}")
            continue

        all_alerts.extend(source_alerts)
        all_raws.append(raw)
        source_labels.append(label)
        detected_formats.append(fmt)

    if not all_alerts:
        console.print("[yellow]Warning:[/yellow] No alerts could be parsed from any input source.")
        raise typer.Exit(0)

    raw = "\n".join(all_raws)  # combined raw for cache fingerprint
    input_file_str = ", ".join(source_labels) if len(source_labels) > 1 else (source_labels[0] if source_labels else "<unknown>")

    if len(source_labels) > 1 and not (quiet or cfg.output.quiet):
        console.print(f"[dim]Sources: {len(source_labels)} files — merging {len(all_alerts)} alerts for cross-source correlation.[/dim]")

    # --- Cache lookup (opt-in) ---
    _alert_cache = None
    _cache_key: str | None = None
    if cache:
        import hashlib
        from .cache import AlertCache, CacheConfig
        _cache_key = hashlib.sha256(raw.encode()).hexdigest()
        _alert_cache = AlertCache(CacheConfig(enabled=True))
        _cached = _alert_cache.get(_cache_key)
        if _cached is not None:
            if not quiet:
                console.print(f"[dim]Cache hit ({_cache_key[:12]}…) — skipping pipeline.[/dim]")
            _render_output(_cached, format=format, output_path=output, cfg=cfg, quiet=quiet)
            raise typer.Exit(0)
        if not quiet:
            console.print(f"[dim]Cache miss ({_cache_key[:12]}…) — running pipeline.[/dim]")

    alerts = all_alerts

    # F-10: filter out phantom alerts (e.g. from bare {} input) with no meaningful content.
    alerts = [
        a for a in alerts
        if a.title not in ("Unknown Alert", "Unknown", "")
        or a.description
        or a.source_ip
        or a.dest_ip
        or a.user
        or a.host
        or a.iocs
        or a.raw
    ]
    if not alerts:
        console.print("[red]Error:[/red] No parseable alerts in input (all records were empty).")
        raise typer.Exit(2)

    alerts_ingested = len(alerts)

    # --- Dedup ---
    if no_dedup:
        alerts_after_dedup = alerts
        dedup_count = alerts_ingested
    else:
        from .pipeline.dedup import DeduplicatorConfig, deduplicate
        alerts_after_dedup, dedup_stats = deduplicate(
            alerts,
            DeduplicatorConfig(time_window_minutes=cfg.clustering.time_window_minutes),
        )
        dedup_count = dedup_stats.deduplicated_count
        if dedup_stats.removed_count > 0 and not quiet:
            console.print(
                f"[dim]Deduplication: {alerts_ingested} → {dedup_count} alerts "
                f"({dedup_stats.removed_count} duplicates removed)[/dim]"
            )

    # --- Field-level redaction (optional) ---
    if redact_fields:
        fields_to_redact = [f.strip() for f in redact_fields.split(",") if f.strip()]
        try:
            alerts_after_dedup = [a.redact(fields_to_redact) for a in alerts_after_dedup]
        except ValueError as exc:
            console.print(f"[red]Error:[/red] {exc}")
            raise typer.Exit(2)

    # --- IOC extraction ---
    from .pipeline.ioc_extractor import enrich_alerts_iocs
    alerts_after_dedup = enrich_alerts_iocs(alerts_after_dedup)

    # --- Cluster + Prioritize (with optional chunking) ---
    from .pipeline.chunker import chunk_alerts, merge_triage_reports
    from .pipeline.clusterer import cluster_alerts
    from .pipeline.prioritizer import prioritize_all

    # --- Validate chunk-size ---
    if chunk_size < 0:
        console.print("[red]Error:[/red] --chunk-size must be 0 (disabled) or a positive integer.")
        raise typer.Exit(2)

    # --- Warn if --enrich-mode given without --enrich ---
    if enrich_mode and not enrich:
        console.print(
            f"[yellow]Warning:[/yellow] --enrich-mode '{enrich_mode}' has no effect without --enrich. "
            "Add --enrich to activate enrichment."
        )

    effective_chunk_size = chunk_size if chunk_size > 0 else cfg.clustering.chunk_size
    if effective_chunk_size > 0:
        chunks = chunk_alerts(alerts_after_dedup, effective_chunk_size)
        _chunk_reports = []
        for _chunk in chunks:
            _cls = cluster_alerts(_chunk, cfg.clustering)
            _cls = prioritize_all(_cls, cfg.scoring)
            _chunk_reports.append(TriageReport(
                input_file=input_file_str,
                alerts_ingested=len(_chunk),
                alerts_after_dedup=len(_chunk),
                clusters=_cls,
                analyzed_at=datetime.now(tz=timezone.utc),
            ))
        clusters = merge_triage_reports(_chunk_reports).clusters
    else:
        clusters = cluster_alerts(alerts_after_dedup, cfg.clustering)
        clusters = prioritize_all(clusters, cfg.scoring)

    # --- Enrichment (optional) ---
    enrichment = None
    if enrich:
        from .enrichers.runner import EnrichmentMode, EnrichmentRunner
        mode_map = {"barb": EnrichmentMode.BARB, "vex": EnrichmentMode.VEX, "all": EnrichmentMode.ALL, "local": EnrichmentMode.LOCAL}
        eff_mode = mode_map.get((enrich_mode or "all").lower(), EnrichmentMode.ALL)
        # LOCAL mode skips consent prompt — no data leaves the system
        consent = (eff_mode is EnrichmentMode.LOCAL) or _check_enrich_consent(yes, cfg)
        if consent:
            runner = EnrichmentRunner(mode=eff_mode)
            # Collect all unique IOCs from clusters
            all_iocs = runner.collect_iocs_from_report(
                type("R", (), {"clusters": clusters})()
            )
            if all_iocs:
                if not (quiet or cfg.output.quiet):
                    console.print(f"  [dim]Enriching {len(all_iocs)} IOC(s) via {eff_mode.value}...[/dim]")
                try:
                    max_ioc_limit = getattr(cfg.enrich, 'max_iocs', 20)
                    enrichment = runner.enrich(all_iocs, max_iocs=max_ioc_limit)
                except Exception as e:
                    console.print(f"[yellow]Warning:[/yellow] Enrichment failed: {e}")
        else:
            console.print("[dim]Enrichment skipped.[/dim]")

    # --- Build report ---
    report = TriageReport(
        input_file=input_file_str,
        alerts_ingested=alerts_ingested,
        alerts_after_dedup=dedup_count if not no_dedup else alerts_ingested,
        clusters=clusters,
        enrichment=enrichment,
        manifest=PipelineManifest(
            sift_version=__version__,
            input_format=detected_formats[0] if detected_formats else "generic",
            enrich_mode=(enrich_mode or "all") if enrich and enrichment else None,
        ),
        analyzed_at=datetime.now(tz=timezone.utc),
    )

    # --- Summarize ---
    if summarize or cfg.summarize.provider != "template":
        effective_provider = provider or cfg.summarize.provider
        summarizer = _build_summarizer(effective_provider, cfg)
        try:
            report = report.model_copy(update={"summary": summarizer.summarize(report)})
        except Exception as exc:
            console.print(f"[yellow]Warning:[/yellow] Summarization failed: {exc}")

    # --- Validation-only mode ---
    if validate_only:
        if not quiet:
            console.print(f"[green]✓[/green] Validation passed: {len(report.clusters)} cluster(s)")
        raise typer.Exit(0)

    # --- Apply filter (if provided) ---
    if filter:
        try:
            from .filtering import FilterParser
            filter_obj = FilterParser.parse(filter)
            before = len(report.clusters)
            filtered = [c for c in report.clusters if filter_obj.matches(c)]
            after = len(filtered)
            if not (quiet or cfg.output.quiet):
                console.print(f"[dim]Filter '{filter}': {after}/{before} cluster(s) matched.[/dim]")
            report = report.model_copy(update={"clusters": filtered})
        except Exception as e:
            console.print(f"[yellow]Warning:[/yellow] Filter parsing failed: {e}")

    # --- Cache write (opt-in) ---
    if _alert_cache is not None and _cache_key is not None:
        try:
            _alert_cache.put(_cache_key, report.model_dump())
        except Exception:
            pass  # Cache write failure is non-blocking

    # --- Output ---
    _render_output(report, format=format, output_path=output, cfg=cfg, quiet=quiet)

    # --- Exit code ---
    raise typer.Exit(report.exit_code)


# ---------------------------------------------------------------------------
# Output rendering
# ---------------------------------------------------------------------------

def _render_output(report: TriageReport, *, format: str, output_path: Optional[Path], cfg, quiet: bool) -> None:
    import json

    from .output.export import export_csv, export_json
    from .output.formatter import format_report_console, format_report_rich

    fmt = format.lower()

    if fmt == "rich":
        format_report_rich(report)
        if output_path:
            data = export_json(report)
            output_path.write_text(data, encoding="utf-8")
            if not quiet:
                console.print(f"[dim]Report saved → {output_path}[/dim]")

    elif fmt == "console":
        format_report_console(report)
        if output_path:
            import contextlib
            import io
            buf = io.StringIO()
            with contextlib.redirect_stdout(buf):
                format_report_console(report)
            output_path.write_text(buf.getvalue(), encoding="utf-8")

    elif fmt == "json":
        data = export_json(report, output_path)
        if not output_path:
            print(data)

    elif fmt == "csv":
        data = export_csv(report, output_path)
        if not output_path:
            print(data)

    elif fmt == "stix":
        try:
            from .output.stix import STIXExporter
            exporter = STIXExporter(report)
            stix_bundle = exporter.to_stix_bundle()
            stix_json = json.dumps(stix_bundle, indent=2, default=str)
            if output_path:
                output_path.write_text(stix_json, encoding="utf-8")
                if not quiet:
                    console.print(f"[dim]STIX bundle saved → {output_path}[/dim]")
            else:
                print(stix_json)
        except ImportError:
            console.print("[red]Error:[/red] STIX export requires sift to be installed with stix support.")
            raise typer.Exit(2)
        except Exception as e:
            console.print(f"[red]Error:[/red] STIX export failed: {e}")
            raise typer.Exit(2)

    else:
        console.print(
            f"[red]Error:[/red] Unknown output format '{format}'. "
            "Valid formats: rich | console | json | csv | stix"
        )
        raise typer.Exit(2)


# ---------------------------------------------------------------------------
# doctor command
# ---------------------------------------------------------------------------

@app.command()
def doctor() -> None:
    """Run diagnostics — check dependencies, config, and LLM connectivity."""
    from .doctor import print_doctor_report, run_checks

    results = run_checks()
    ok = print_doctor_report(results)
    raise typer.Exit(0 if ok else 1)


# ---------------------------------------------------------------------------
# metrics command
# ---------------------------------------------------------------------------

@app.command()
def metrics(
    file: Annotated[Path, typer.Argument(
        help="Alert file to analyze (JSON, Splunk JSON, CSV). Use '-' for stdin.",
        show_default=False,
    )],
    quiet: Annotated[bool, typer.Option(
        "--quiet", "-q",
        help="Suppress banner.",
    )] = False,
    no_dedup: Annotated[bool, typer.Option(
        "--no-dedup",
        help="Skip alert deduplication.",
    )] = False,
    config_path: Annotated[Optional[Path], typer.Option(
        "--config",
        help="Path to config.yaml",
        show_default=False,
    )] = None,
) -> None:
    """Show metrics for alerts: cluster count, IOC distribution, etc."""
    from .metrics import MetricsCollector

    # --- Load config ---
    cfg = load_config(config_path)

    # --- Banner ---
    show_banner(
        quiet=quiet or cfg.output.quiet,
        update_check_enabled=cfg.update_check.enabled,
        check_interval_hours=cfg.update_check.check_interval_hours,
    )

    # --- Read input ---
    try:
        if str(file) == "-":
            raw = sys.stdin.read()
            input_file_str = "<stdin>"
        else:
            if not file.exists():
                console.print(f"[red]Error:[/red] File not found: {file}")
                raise typer.Exit(2)
            raw = file.read_text(encoding="utf-8")
            input_file_str = str(file)
    except Exception as exc:
        console.print(f"[red]Error reading input:[/red] {exc}")
        raise typer.Exit(2)

    if not raw.strip():
        console.print("[red]Error:[/red] Input is empty.")
        raise typer.Exit(2)

    # --- Normalize ---
    try:
        alerts, detected_format = _normalize(raw)
    except Exception as exc:
        console.print(f"[red]Error during normalization:[/red] {exc}")
        raise typer.Exit(2)

    if not alerts:
        console.print("[yellow]Warning:[/yellow] No alerts could be parsed from input.")
        raise typer.Exit(0)

    alerts_ingested = len(alerts)

    # --- Dedup ---
    if no_dedup:
        alerts_after_dedup = alerts
        dedup_count = alerts_ingested
    else:
        from .pipeline.dedup import DeduplicatorConfig, deduplicate
        alerts_after_dedup, dedup_stats = deduplicate(
            alerts,
            DeduplicatorConfig(time_window_minutes=cfg.clustering.time_window_minutes),
        )
        dedup_count = dedup_stats.deduplicated_count

    # --- IOC extraction ---
    from .pipeline.ioc_extractor import enrich_alerts_iocs
    alerts_after_dedup = enrich_alerts_iocs(alerts_after_dedup)

    # --- Cluster ---
    from .pipeline.clusterer import cluster_alerts
    clusters = cluster_alerts(alerts_after_dedup, cfg.clustering)

    # --- Prioritize ---
    from .pipeline.prioritizer import prioritize_all
    clusters = prioritize_all(clusters, cfg.scoring)

    # --- Build minimal report ---
    report = TriageReport(
        input_file=input_file_str,
        alerts_ingested=alerts_ingested,
        alerts_after_dedup=dedup_count if not no_dedup else alerts_ingested,
        clusters=clusters,
        analyzed_at=datetime.now(tz=timezone.utc),
    )

    # --- Collect and display metrics ---
    metrics_obj = MetricsCollector.collect(report)
    metrics_table = MetricsCollector.format_table(metrics_obj)

    console.print()
    console.print(metrics_table)
    console.print()


# ---------------------------------------------------------------------------
# config command
# ---------------------------------------------------------------------------

@app.command(name="config")
def config_cmd(
    show: Annotated[bool, typer.Option("--show", help="Print current configuration as YAML.")] = False,
    config_path: Annotated[Optional[Path], typer.Option("--config", show_default=False, help="Path to config.yaml.")] = None,
    # --- Credentials (stored in ~/.sift/.env, never in config.yaml) ---
    api_key: Annotated[Optional[str], typer.Option(
        "--api-key",
        help="Set LLM API key (stored in ~/.sift/.env as SIFT_LLM_KEY, never in config.yaml).",
        show_default=False,
    )] = None,
    unset_api_key: Annotated[bool, typer.Option(
        "--unset-api-key",
        help="Remove the LLM API key from ~/.sift/.env.",
    )] = False,
    # --- Summarization ---
    provider: Annotated[Optional[str], typer.Option(
        "--provider",
        help="Default LLM provider: template | anthropic | openai | ollama.",
        show_default=False,
    )] = None,
    model: Annotated[Optional[str], typer.Option(
        "--model",
        help="Default LLM model name (e.g. claude-opus-4-6, gpt-4o). None = auto-select.",
        show_default=False,
    )] = None,
    # --- Output defaults ---
    quiet: Annotated[Optional[bool], typer.Option(
        "--quiet/--no-quiet",
        help="Set quiet mode as default (suppresses banner and status lines).",
    )] = None,
    default_format: Annotated[Optional[str], typer.Option(
        "--default-format",
        help="Default output format: rich | console | json | csv | stix.",
        show_default=False,
    )] = None,
    # --- Pipeline defaults ---
    chunk_size: Annotated[Optional[int], typer.Option(
        "--chunk-size",
        help="Default chunk size for large alert batches (0 = no chunking).",
        show_default=False,
    )] = None,
    cache: Annotated[Optional[bool], typer.Option(
        "--cache/--no-cache",
        help="Enable or disable result caching by default (opt-in, TTL 1h).",
    )] = None,
    enrich_consent: Annotated[Optional[bool], typer.Option(
        "--enrich-consent/--no-enrich-consent",
        help="Pre-approve enrichment consent (skips interactive prompt for --enrich).",
    )] = None,
) -> None:
    """Show or set sift configuration.

    Settings are persisted to ~/.sift/config.yaml.
    The LLM API key is stored separately in ~/.sift/.env (mode 600).

    \b
    Examples:
      sift config --show
      sift config --api-key sk-ant-...
      sift config --provider anthropic --model claude-opus-4-6
      sift config --quiet --default-format json
      sift config --chunk-size 100 --cache
      sift config --unset-api-key
    """
    from sift.config import clear_credentials, save_credentials

    import yaml
    from rich.syntax import Syntax

    _VALID_PROVIDERS = {"template", "mock", "anthropic", "openai", "ollama"}
    _VALID_FORMATS = {"rich", "console", "json", "csv", "stix"}

    cfg = load_config(config_path)
    cfg_changed = False
    rich_console = Console()

    # --- Validate inputs early ---
    if provider is not None and provider not in _VALID_PROVIDERS:
        console.print(f"[red]Error:[/red] Invalid provider {provider!r}. Valid: {', '.join(sorted(_VALID_PROVIDERS))}")
        raise typer.Exit(2)
    if default_format is not None and default_format not in _VALID_FORMATS:
        console.print(f"[red]Error:[/red] Invalid format {default_format!r}. Valid: {', '.join(sorted(_VALID_FORMATS))}")
        raise typer.Exit(2)
    if chunk_size is not None and chunk_size < 0:
        console.print("[red]Error:[/red] --chunk-size must be >= 0.")
        raise typer.Exit(2)

    # --- Credentials (never written to config.yaml) ---
    if api_key is not None:
        env_path = save_credentials(api_key)
        console.print(f"[green]✓[/green] API key saved to {env_path}")

    if unset_api_key:
        removed = clear_credentials()
        if removed:
            console.print("[green]✓[/green] API key removed from ~/.sift/.env")
        else:
            console.print("[yellow]No API key found in ~/.sift/.env[/yellow]")

    # --- Config fields ---
    if provider is not None:
        cfg.summarize.provider = provider
        cfg_changed = True
    if model is not None:
        cfg.summarize.model = model
        cfg_changed = True
    if quiet is not None:
        cfg.output.quiet = quiet
        cfg_changed = True
    if default_format is not None:
        cfg.output.default_format = default_format
        cfg_changed = True
    if chunk_size is not None:
        cfg.clustering.chunk_size = chunk_size
        cfg_changed = True
    if cache is not None:
        cfg.cache_enabled = cache
        cfg_changed = True
    if enrich_consent is not None:
        cfg.enrich.consent_given = enrich_consent
        cfg_changed = True

    if cfg_changed:
        from sift.config import save_config as _save_config
        saved_path = _save_config(cfg, config_path)
        console.print(f"[green]✓[/green] Config saved to {saved_path}")

    # --- Show ---
    if show:
        data = cfg.model_dump()
        yaml_str = yaml.dump(data, default_flow_style=False, sort_keys=False)
        rich_console.print(Syntax(yaml_str, "yaml", theme="monokai", line_numbers=False))
        return

    # If nothing was done, show usage hint
    if not cfg_changed and not api_key and not unset_api_key:
        typer.echo(
            "Usage: sift config --show\n"
            "       sift config --api-key <key>\n"
            "       sift config --provider <template|anthropic|openai|ollama>\n"
            "       sift config --quiet --default-format json\n"
            "Run 'sift config --help' for all options."
        )


# ---------------------------------------------------------------------------
# version command
# ---------------------------------------------------------------------------

@app.command()
def version() -> None:
    """Print sift version and exit."""
    typer.echo(f"sift v{__version__}")


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

def main() -> None:
    app()


if __name__ == "__main__":
    main()
