"""Click CLI — thin dispatcher, handles exit codes."""

import os
import sys
from pathlib import Path

import click
from rich.console import Console
from rich.panel import Panel
from rich.syntax import Syntax

from noisegate import __version__
from noisegate.checks import run_triage
from noisegate.config import (
    _POLICY_FILE,
    PROVIDERS,
    bootstrap,
    get_api_keys,
    get_default_program_name,
    get_provider_model,
    save_api_key,
    save_default_model,
    save_provider_model,
    set_default_program,
)
from noisegate.exceptions import NoDefaultProgramError, NoiseGateError
from noisegate.importer import import_program_from_file, import_program_from_url
from noisegate.llm import DEFAULT_MODEL, get_default_model
from noisegate.loader import load_reports
from noisegate.policy import load_policy
from noisegate.program import (
    _PROGRAMS_DIR,
    list_programs,
    load_program,
    remove_program,
    resolve_program,
    save_program,
)
from noisegate.renderer import render_human, render_json

console = Console()
err_console = Console(stderr=True)


def _inject_stored_api_keys() -> None:
    """Set env vars from stored config for any keys not already in the environment."""
    provider_env = {p["id"]: p["env_var"] for p in PROVIDERS if p["env_var"]}
    for provider, key in get_api_keys().items():
        env_var = provider_env.get(provider)
        if env_var and env_var not in os.environ and key:
            os.environ[env_var] = key


@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@click.version_option(__version__, "--version", "-V")
def cli() -> None:
    """NoiseGate — LLM-powered bug bounty report triage."""
    bootstrap()
    _inject_stored_api_keys()


# ---------------------------------------------------------------------------
# triage
# ---------------------------------------------------------------------------

@cli.command()
@click.argument("files", nargs=-1, required=True)
@click.option("--program", "-p", default=None, help="Program name to use (overrides default).")
@click.option("--json", "output_json", is_flag=True, default=False,
              help="Machine-readable JSON output.")
@click.option("--model", "-m", default=None,
              help=f"LLM model string (default: configured or {DEFAULT_MODEL}).")
def triage(
    files: tuple[str, ...], program: str | None, output_json: bool, model: str | None
) -> None:
    """Evaluate a bug report against a program.

    FILES is one or more files that together make up a single report
    (e.g. a writeup and a separate PoC file).
    """
    try:
        prog_name = program or get_default_program_name()
        if prog_name is None:
            raise NoDefaultProgramError()

        prog = load_program(resolve_program(prog_name))
        report = load_reports(files)
        policy = load_policy(_POLICY_FILE)

        with console.status("[dim]Triaging...[/dim]"):
            result = run_triage(policy.checks, report, prog, model=model or get_default_model())

        if output_json:
            click.echo(render_json(result))
        else:
            render_human(result, console=console)

        # Exit codes: 0=PASS, 1=WARN, 2=FAIL
        exit_code = {"PASS": 0, "WARN": 1, "FAIL": 2}.get(result.verdict.value, 3)
        sys.exit(exit_code)

    except (NoiseGateError, FileNotFoundError) as e:
        err_console.print(f"[red]Error:[/red] {e}")
        sys.exit(3)
    except KeyboardInterrupt:
        sys.exit(3)


# ---------------------------------------------------------------------------
# program
# ---------------------------------------------------------------------------

@cli.group()
def program() -> None:
    """Manage bug bounty program definitions."""


@program.command("add")
@click.argument("name")
@click.option("--url", "-u", default=None, help="Seed URL to crawl for the program.")
@click.option("--file", "-f", default=None, help="Path to an existing program YAML file.")
@click.option("--model", "-m", default=None,
              help=f"LLM model string (default: configured or {DEFAULT_MODEL}).")
@click.option("--verbose", "-v", is_flag=True, default=False,
              help="Print the extracted content of each fetched page (crawl only).")
def program_add(
    name: str, url: str | None, file: str | None, model: str | None, verbose: bool
) -> None:
    """Save a bug bounty program from a URL (crawled) or YAML file."""
    if not url and not file:
        raise click.UsageError("Provide either --url or --file.")
    if url and file:
        raise click.UsageError("--url and --file are mutually exclusive.")

    try:
        resolved_model = model or get_default_model()
        usage_line: list[str] = []

        def _on_complete(used_model: str, prompt_tokens: int, completion_tokens: int) -> None:
            usage_line.append(
                f"{used_model}  ·  {prompt_tokens:,} in / {completion_tokens:,} out"
            )

        if file:
            prog = import_program_from_file(
                Path(file), model=resolved_model, on_complete=_on_complete,
            )
        else:
            assert url is not None  # guaranteed by the mutual-exclusion checks above

            def _on_fetch_start(fetched_url: str) -> None:
                console.print(f"Crawling {fetched_url} ", end="")

            def _on_fetch_end(fetched_url: str, status: int, content: str) -> None:
                color = "green" if status < 400 else "red"
                console.print(f"[{status}]", style=color, markup=False)
                if verbose:
                    console.print(Panel(Syntax(content, "markdown"), border_style="dim"))

            def _on_warning(msg: str) -> None:
                console.print(f"[yellow]⚠[/yellow]  {msg}")

            prog = import_program_from_url(
                url, model=resolved_model,
                on_fetch_start=_on_fetch_start, on_fetch=_on_fetch_end,
                on_warning=_on_warning, on_complete=_on_complete,
            )

        out_path = _PROGRAMS_DIR / f"{name}.yaml"
        save_program(prog, out_path)
        set_default_program(name)

        console.print(f"\n[green]Program '[bold]{name}[/bold]' saved.[/green]")
        console.print(f"To view details: noisegate program show {name}")

        if usage_line:
            console.print()
            console.print(f"[dim]{usage_line[0]}[/dim]")

    except NoiseGateError as e:
        err_console.print(f"[red]Error:[/red] {e}")
        sys.exit(3)


@program.command("list")
def program_list() -> None:
    """List all saved programs."""
    programs = list_programs()
    if not programs:
        console.print("[dim]No programs saved yet.[/dim]")
        console.print("Run: noisegate program add <name> --url URL")
        return

    default = get_default_program_name()
    for p in programs:
        marker = " [green](default)[/green]" if p == default else ""
        console.print(f"  • {p}{marker}")


@program.command("show")
@click.argument("name")
def program_show(name: str) -> None:
    """Print a saved program."""
    try:
        path = resolve_program(name)
        prog = load_program(path)
        console.print(Panel(
            Syntax(path.read_text(), "yaml"),
            title=f"[bold]{prog.name}[/bold]",
            subtitle=f"[dim]{path}[/dim]",
        ))
    except NoiseGateError as e:
        err_console.print(f"[red]Error:[/red] {e}")
        sys.exit(3)


@program.command("use")
@click.argument("name")
def program_use(name: str) -> None:
    """Set the default program for triage."""
    try:
        set_default_program(name)
        console.print(f"[green]Default program set to '[bold]{name}[/bold]'.[/green]")
    except NoiseGateError as e:
        err_console.print(f"[red]Error:[/red] {e}")
        sys.exit(3)


@program.command("remove")
@click.argument("name")
@click.confirmation_option(prompt="Are you sure you want to remove this program?")
def program_remove(name: str) -> None:
    """Delete a saved program."""
    try:
        remove_program(name)
        console.print(f"[green]Program '[bold]{name}[/bold]' removed.[/green]")
    except NoiseGateError as e:
        err_console.print(f"[red]Error:[/red] {e}")
        sys.exit(3)


# ---------------------------------------------------------------------------
# llm
# ---------------------------------------------------------------------------

@cli.command()
def llm() -> None:
    """Configure an LLM provider, API key, and model."""
    stored_keys = get_api_keys()
    current_model = get_default_model()

    # --- Provider selection ---
    console.print()
    console.print("Choose a provider:\n")
    for i, p in enumerate(PROVIDERS, 1):
        has_key = p["env_var"] and (stored_keys.get(p["id"]) or os.environ.get(p["env_var"], ""))
        configured = has_key or get_provider_model(p["id"])
        key_marker = " [green]✓[/green]" if configured else ""
        is_default = current_model and current_model.startswith(f"{p['id']}:")
        default_marker = " [dim](default)[/dim]" if is_default else ""
        console.print(f"  {i}  {p['name']}{key_marker}{default_marker}")
    console.print()

    valid = {str(i) for i in range(1, len(PROVIDERS) + 1)}
    while True:
        raw = click.prompt("Provider", default="1")
        if raw in valid:
            break
        console.print(f"[yellow]Please enter a number between 1 and {len(PROVIDERS)}.[/yellow]")

    provider = PROVIDERS[int(raw) - 1]

    # --- API key ---
    if provider["env_var"]:
        existing = os.environ.get(provider["env_var"], "") or stored_keys.get(provider["id"], "")
        if existing:
            masked = f"{existing[:10]}{'*' * 6}"
            key_prompt = f"API key [{masked}]"
            console.print("[dim]Press Enter to keep existing key.[/dim]")
        else:
            key_prompt = "API key"
        key = click.prompt(key_prompt, default=existing or "", hide_input=True, show_default=False)
        if key:
            save_api_key(provider["id"], key)
            os.environ[provider["env_var"]] = key

    # --- Model ---
    configured_model = get_provider_model(provider["id"]) or provider["default_model"]
    model = click.prompt("Model", default=configured_model)
    save_provider_model(provider["id"], model)

    # --- Set as default provider? ---
    set_as_default = click.confirm(
        f"Set {provider['name']} as the default provider?",
        default=True,
    )
    if set_as_default:
        save_default_model(model)

    console.print("[green]Done.[/green]")
