"""Interactive wizard — launches when `promptfuzz` is run with no arguments."""

from __future__ import annotations

import json
import os
import shlex
import sys
from typing import Any

import questionary
from rich.console import Console
from rich.panel import Panel
from rich.text import Text

from promptfuzz import __version__
from promptfuzz.attacks.loader import VALID_CATEGORIES

_console = Console()

_CATEGORY_DESCRIPTIONS: dict[str, str] = {
    "jailbreak": "Persona switches, DAN, roleplay bypasses",
    "injection": "Prompt override, delimiter attacks",
    "data_extraction": "System prompt leaking, credential extraction",
    "goal_hijacking": "Purpose redirection attacks",
    "edge_cases": "Unicode, long inputs, encoding attacks",
}

_OUTPUT_CHOICES = [
    questionary.Choice("Terminal only", value="terminal"),
    questionary.Choice("Terminal + TXT file  (report.txt)", value="txt"),
    questionary.Choice("Terminal + HTML report  (report.html)", value="html"),
    questionary.Choice("All formats", value="all"),
]

_SEVERITY_CHOICES = [
    questionary.Choice("low  (show everything)", value="low"),
    questionary.Choice("medium", value="medium"),
    questionary.Choice("high", value="high"),
    questionary.Choice("critical only", value="critical"),
]


# ---------------------------------------------------------------------------
# Curl parsing
# ---------------------------------------------------------------------------


def _parse_curl(curl_str: str) -> dict[str, Any]:
    """Parse a curl command string and extract URL, headers, and body fields.

    Args:
        curl_str: Raw curl command, possibly multi-line with backslash continuations.

    Returns:
        Dict with keys 'url' (str|None), 'headers' (dict), 'body_fields' (dict).
    """
    # Normalise line continuations
    curl_str = curl_str.replace("\\\n", " ").replace("\\\r\n", " ")

    try:
        tokens = shlex.split(curl_str)
    except ValueError:
        tokens = curl_str.split()

    result: dict[str, Any] = {"url": None, "headers": {}, "body_fields": {}}

    skip_flags = {"-X", "--request", "--compressed", "-s", "--silent", "-v", "--verbose"}
    data_flags = {"--data-raw", "--data", "-d", "--data-urlencode"}

    i = 0
    while i < len(tokens):
        tok = tokens[i]

        if tok == "curl":
            i += 1
            continue

        if tok in skip_flags:
            i += 2  # skip flag + its value
            continue

        if tok in {"-H", "--header"}:
            i += 1
            if i < len(tokens):
                header_str = tokens[i]
                if ":" in header_str:
                    key, _, val = header_str.partition(":")
                    result["headers"][key.strip()] = val.strip()
            i += 1
            continue

        if tok.startswith("-H"):
            header_str = tok[2:]
            if ":" in header_str:
                key, _, val = header_str.partition(":")
                result["headers"][key.strip()] = val.strip()
            i += 1
            continue

        if tok in data_flags:
            i += 1
            if i < len(tokens):
                raw = tokens[i]
                try:
                    parsed = json.loads(raw)
                    if isinstance(parsed, dict):
                        result["body_fields"] = parsed
                except (json.JSONDecodeError, ValueError):
                    pass
            i += 1
            continue

        if tok.startswith("--data") or tok.startswith("-d"):
            # inline value after = sign
            _, _, val = tok.partition("=")
            if val:
                try:
                    parsed = json.loads(val)
                    if isinstance(parsed, dict):
                        result["body_fields"] = parsed
                except (json.JSONDecodeError, ValueError):
                    pass
            i += 1
            continue

        # Anything that looks like a URL
        if tok.startswith("http://") or tok.startswith("https://"):
            result["url"] = tok
            i += 1
            continue

        # Skip unknown flags
        i += 1

    return result


# ---------------------------------------------------------------------------
# HTTP probing
# ---------------------------------------------------------------------------


def _probe_url_with_headers(
    url: str,
    headers: dict[str, str],
    input_field: str,
    output_field: str,
    extra_fields: dict[str, str],
) -> tuple[bool, str, dict[str, Any]]:
    """Send a test POST to the URL and return (success, message, response_body).

    Args:
        url: Target URL.
        headers: HTTP headers to include.
        input_field: JSON key for the prompt.
        output_field: JSON key for the reply.
        extra_fields: Extra fixed fields to include in the payload.

    Returns:
        Tuple of (ok, diagnostic_message, response_json_or_empty_dict).
    """
    import httpx

    payload = {input_field: "hello", **extra_fields}
    try:
        r = httpx.post(url, json=payload, headers=headers, timeout=10.0)
        if r.status_code >= 400:
            try:
                body = r.json()
            except Exception:
                body = r.text[:300]
            return False, (
                f"HTTP {r.status_code} — API rejected the request.\n"
                f"  Response: {body}\n"
                f"  Hint: Check if the API requires extra fields "
                f"(e.g. conversation_id, session_id)."
            ), {}
        try:
            data = r.json()
        except Exception:
            return True, "Connected (non-JSON response). output_field ignored.", {}

        if output_field and output_field not in data:
            available = ", ".join(str(k) for k in data.keys()) or "(empty)"
            return False, (
                f"Connected but response field '{output_field}' not found.\n"
                f"  Available keys: {available}\n"
                f"  Hint: Set the response field to one of the above."
            ), data
        return True, f"OK — received reply via '{output_field}' field.", data

    except httpx.ConnectError:
        return False, (
            f"Cannot connect to {url}.\n"
            f"  Hint: Is the server running? Check the URL."
        ), {}
    except httpx.TimeoutException:
        return False, "Connection timed out (10s). Is the server slow or unreachable?", {}


def _probe_url(
    url: str,
    input_field: str,
    output_field: str,
    extra_fields: dict[str, str],
) -> tuple[bool, str]:
    """Send a single test request to the URL and return (success, message).

    Args:
        url: Target URL.
        input_field: JSON key for the prompt.
        output_field: JSON key for the reply.
        extra_fields: Extra fixed fields to include in the payload.

    Returns:
        Tuple of (ok, diagnostic_message).
    """
    ok, msg, _ = _probe_url_with_headers(url, {}, input_field, output_field, extra_fields)
    return ok, msg


# ---------------------------------------------------------------------------
# Shared question helpers (unchanged)
# ---------------------------------------------------------------------------


def _ask_categories() -> list[str]:
    """Ask which attack categories to run via multi-select checkboxes.

    Returns:
        List of selected category names (at least one).
    """
    choices = [
        questionary.Choice(
            f"{cat:<20} — {_CATEGORY_DESCRIPTIONS.get(cat, '')}",
            value=cat,
            checked=cat in {"jailbreak", "injection", "data_extraction"},
        )
        for cat in sorted(VALID_CATEGORIES)
    ]

    while True:
        selected = questionary.checkbox(
            "Select attack categories: (space=toggle, enter=confirm)",
            choices=choices,
        ).ask()

        if selected is None:
            sys.exit(0)
        if selected:
            return selected
        _console.print("[yellow]Please select at least one category.[/yellow]")


def _ask_output() -> str:
    """Ask for the desired output format.

    Returns:
        One of 'terminal', 'txt', 'html', 'all'.
    """
    result = questionary.select(
        "Output format:",
        choices=_OUTPUT_CHOICES,
    ).ask()
    if result is None:
        sys.exit(0)
    return result


def _ask_severity() -> str:
    """Ask for the minimum severity to include in the report.

    Returns:
        One of 'low', 'medium', 'high', 'critical'.
    """
    result = questionary.select(
        "Minimum severity to report:",
        choices=_SEVERITY_CHOICES,
    ).ask()
    if result is None:
        sys.exit(0)
    return result


def _ask_ai_attacks() -> bool:
    """Ask whether to use AI-generated attacks (only when API key is present).

    Returns:
        True if the user wants AI-generated attacks, False otherwise.
    """
    if not os.environ.get("OPENAI_API_KEY"):
        return False

    result = questionary.confirm(
        "Use AI-generated attacks? Requires OPENAI_API_KEY",
        default=False,
    ).ask()
    if result is None:
        sys.exit(0)
    return result


def _ask_target() -> str:
    """Ask for target type and then the specific target string.

    Returns:
        A URL string or a 'module:function' import path.
    """
    target_type = questionary.select(
        "What is your target?",
        choices=[
            questionary.Choice("HTTP/HTTPS endpoint (URL)", value="url"),
            questionary.Choice("Python function (module:function)", value="function"),
        ],
    ).ask()

    if target_type is None:
        sys.exit(0)

    if target_type == "url":
        while True:
            url = questionary.text(
                "Enter target URL:",
                validate=lambda t: (
                    True
                    if t.startswith("http://") or t.startswith("https://")
                    else "Must start with http:// or https://"
                ),
            ).ask()
            if url is None:
                sys.exit(0)
            return url
    else:
        while True:
            path = questionary.text(
                "Enter import path (e.g. mymodule:my_function):",
                validate=lambda t: (
                    True
                    if ":" in t and len(t.split(":")) == 2  # noqa: PLR2004
                    else "Format must be module:function"
                ),
            ).ask()
            if path is None:
                sys.exit(0)
            return path


# ---------------------------------------------------------------------------
# Output-field select after probe
# ---------------------------------------------------------------------------


def _select_output_field_from_response(
    response_data: dict[str, Any],
) -> str:
    """Present response keys as a select list; fall back to manual entry.

    Args:
        response_data: Parsed JSON response from a probe request.

    Returns:
        The chosen output field name.
    """
    str_keys = [k for k, v in response_data.items() if isinstance(v, str)]
    all_keys = list(response_data.keys())

    candidates = str_keys if str_keys else all_keys

    if not candidates:
        fallback = questionary.text(
            "Response field name (JSON key the API returns the reply in):",
            default="response",
        ).ask()
        return (fallback or "response").strip()

    choices = [questionary.Choice(k, value=k) for k in candidates]
    choices.append(questionary.Choice("(type manually)", value="__manual__"))

    selected = questionary.select(
        "Connected! Which field contains the chatbot's reply?",
        choices=choices,
    ).ask()

    if selected is None:
        sys.exit(0)

    if selected == "__manual__":
        manual = questionary.text(
            "Response field name:",
            default="response",
        ).ask()
        return (manual or "response").strip()

    return selected


# ---------------------------------------------------------------------------
# Manual wizard (existing flow + improvements)
# ---------------------------------------------------------------------------


def _ask_url_fields(
    url: str,
    headers: dict[str, str] | None = None,
) -> tuple[str, dict[str, str]]:
    """Ask for the response field name and extra payload fields, then probe the URL.

    Probes the URL first and shows response keys as a select list.

    Args:
        url: The target URL (used for the connection test).
        headers: Optional HTTP headers to include in the probe.

    Returns:
        Tuple of (output_field, extra_fields dict).
    """
    if headers is None:
        headers = {}

    while True:
        extra_raw = questionary.text(
            "Extra payload fields? (e.g. conversation_id=abc123,api_key=xyz) "
            "Leave blank to skip:",
            default="",
        ).ask()
        if extra_raw is None:
            sys.exit(0)

        extra_fields: dict[str, str] = {}
        if extra_raw.strip():
            for pair in extra_raw.split(","):
                pair = pair.strip()
                if "=" in pair:
                    k, v = pair.split("=", 1)
                    extra_fields[k.strip()] = v.strip()

        # Probe first to discover response keys
        _console.print(f"[dim]Testing connection to {url}...[/dim]")
        ok, msg, response_data = _probe_url_with_headers(
            url, headers, "message", "", extra_fields
        )

        if ok and response_data:
            _console.print(f"[green]Connected![/green] {msg}")
            output_field = _select_output_field_from_response(response_data)
        elif ok:
            _console.print(f"[green]Connected![/green]")
            output_field = questionary.text(
                "Response field name (JSON key the API returns the reply in):",
                default="response",
            ).ask()
            output_field = (output_field or "response").strip()
        else:
            _console.print(f"[bold yellow]Connection test failed:[/bold yellow] {msg}")
            retry = questionary.confirm(
                "Fix the settings and try again?", default=True
            ).ask()
            if not retry:
                output_field = questionary.text(
                    "Response field name (JSON key the API returns the reply in):",
                    default="response",
                ).ask()
                output_field = (output_field or "response").strip()
                return output_field, extra_fields
            continue

        # Verify selected output_field actually exists
        if output_field and output_field not in response_data and response_data:
            available = ", ".join(str(k) for k in response_data.keys()) or "(empty)"
            _console.print(
                f"[bold yellow]Warning:[/bold yellow] field '{output_field}' not found. "
                f"Available: {available}"
            )

        return output_field, extra_fields


def _run_manual_wizard() -> None:
    """Run the step-by-step guided setup wizard."""
    target = _ask_target()

    output_field = "response"
    extra_fields: dict[str, str] = {}
    headers: dict[str, str] = {}

    if target.startswith("http://") or target.startswith("https://"):
        # Headers step
        headers_raw = questionary.text(
            "Auth/custom headers? (e.g. Authorization=Bearer xyz,X-Workspace-Id=abc) "
            "Leave blank to skip:",
            default="",
        ).ask()
        if headers_raw is None:
            sys.exit(0)
        if headers_raw.strip():
            for pair in headers_raw.split(","):
                pair = pair.strip()
                if "=" in pair:
                    k, v = pair.split("=", 1)
                    headers[k.strip()] = v.strip()

        output_field, extra_fields = _ask_url_fields(target, headers)

    categories = _ask_categories()
    output_fmt = _ask_output()
    severity = _ask_severity()
    _ask_ai_attacks()  # result unused in current launch path

    _print_summary_and_confirm(
        target, categories, output_fmt, severity, output_field, extra_fields, headers
    )


def _print_summary_and_confirm(
    target: str,
    categories: list[str],
    output_fmt: str,
    severity: str,
    output_field: str,
    extra_fields: dict[str, str],
    headers: dict[str, str],
) -> None:
    """Print scan summary panel, ask for confirmation, then launch.

    Args:
        target: URL or module:function string.
        categories: Selected attack categories.
        output_fmt: Output format key.
        severity: Minimum severity string.
        output_field: JSON key for the model reply.
        extra_fields: Extra fixed payload fields.
        headers: HTTP headers to pass with every request.
    """
    from promptfuzz.attacks.loader import AttackLoader

    loader = AttackLoader()
    attacks = loader.load_categories(categories)
    attack_count = len(attacks)

    output_label = {
        "terminal": "Terminal only",
        "txt": "Terminal + report.txt",
        "html": "Terminal + report.html",
        "all": "Terminal + report.txt + report.html + report.json",
    }[output_fmt]

    summary = Text()
    summary.append("  Target   : ", style="dim")
    summary.append(f"{target}\n", style="cyan")
    summary.append("  Attacks  : ", style="dim")
    summary.append(f"{attack_count}  ({' + '.join(categories)})\n", style="green")
    summary.append("  Output   : ", style="dim")
    summary.append(f"{output_label}\n")
    summary.append("  Severity : ", style="dim")
    summary.append(f"{severity}+\n")
    if extra_fields:
        summary.append("  Extra    : ", style="dim")
        extra_str = ", ".join(f"{k}={v}" for k, v in extra_fields.items())
        summary.append(f"{extra_str}\n")
    if headers:
        summary.append("  Headers  : ", style="dim")
        header_str = ", ".join(headers.keys())
        summary.append(f"{header_str}\n")

    _console.print()
    _console.print(Panel(summary, title="Scan configuration", border_style="cyan"))
    _console.print()

    go = questionary.confirm(
        "Press ENTER to start scan (Ctrl+C to cancel)", default=True
    ).ask()
    if not go:
        _console.print("[dim]Scan cancelled.[/dim]")
        sys.exit(0)

    _console.print()
    _launch_scan(
        target, categories, output_fmt, severity,
        output_field, extra_fields, headers=headers,
    )


# ---------------------------------------------------------------------------
# Curl import wizard
# ---------------------------------------------------------------------------


def _run_curl_wizard() -> None:
    """Run the curl-import wizard: paste a curl, auto-extract everything."""
    _console.print(
        "[dim]Paste your curl command below. "
        "Press ENTER twice (or submit) when done:[/dim]"
    )

    curl_raw = questionary.text(
        "curl command:",
        multiline=False,
    ).ask()

    if curl_raw is None:
        sys.exit(0)

    parsed = _parse_curl(curl_raw.strip())

    url = parsed["url"]
    headers: dict[str, str] = parsed["headers"]
    body_fields: dict[str, Any] = parsed["body_fields"]

    # Show parsed result
    header_names = ", ".join(headers.keys()) or "(none)"
    field_names = ", ".join(str(k) for k in body_fields.keys()) or "(none)"

    _console.print()
    _console.print(
        Panel(
            f"  [dim]URL    :[/dim] [cyan]{url or '(not found)'}[/cyan]\n"
            f"  [dim]Headers:[/dim] {header_names}\n"
            f"  [dim]Fields :[/dim] {field_names}",
            title="Parsed curl",
            border_style="green" if url else "yellow",
        )
    )
    _console.print()

    if not url:
        _console.print("[bold yellow]Could not detect URL from curl command.[/bold yellow]")
        switch = questionary.confirm(
            "Switch to manual setup instead?", default=True
        ).ask()
        if switch or switch is None:
            _run_manual_wizard()
        return

    # Determine input field
    str_fields = [k for k, v in body_fields.items() if isinstance(v, str)]
    if len(str_fields) == 1:
        input_field = str_fields[0]
        _console.print(f"[dim]Auto-selected input field:[/dim] [green]{input_field}[/green]")
    elif str_fields:
        input_field_sel = questionary.select(
            "Which field contains the user's message?",
            choices=str_fields,
        ).ask()
        if input_field_sel is None:
            sys.exit(0)
        input_field = input_field_sel
    else:
        input_field_text = questionary.text(
            "Input field name (JSON key for the user's message):",
            default="message",
        ).ask()
        input_field = (input_field_text or "message").strip()

    # Build extra fields (everything except input_field)
    extra_fields: dict[str, str] = {
        k: str(v) for k, v in body_fields.items() if k != input_field
    }

    # Probe to discover output field
    _console.print(f"[dim]Probing {url}...[/dim]")
    ok, msg, response_data = _probe_url_with_headers(
        url, headers, input_field, "", extra_fields
    )

    if ok and response_data:
        _console.print(f"[green]Connected![/green]")
        output_field = _select_output_field_from_response(response_data)
    elif ok:
        _console.print(f"[green]Connected![/green] {msg}")
        output_field_text = questionary.text(
            "Response field name (JSON key the API returns the reply in):",
            default="response",
        ).ask()
        output_field = (output_field_text or "response").strip()
    else:
        _console.print(f"[bold yellow]Probe failed:[/bold yellow] {msg}")
        proceed = questionary.confirm(
            "Continue anyway (you'll need to type the output field manually)?",
            default=True,
        ).ask()
        if not proceed:
            sys.exit(0)
        output_field_text = questionary.text(
            "Response field name:",
            default="response",
        ).ask()
        output_field = (output_field_text or "response").strip()

    categories = _ask_categories()
    output_fmt = _ask_output()
    severity = _ask_severity()

    _print_summary_and_confirm(
        url, categories, output_fmt, severity, output_field, extra_fields, headers
    )


# ---------------------------------------------------------------------------
# Quick scan
# ---------------------------------------------------------------------------


def _run_quick_scan() -> None:
    """Ask for URL only and run all categories with defaults."""
    url = questionary.text(
        "Enter target URL:",
        validate=lambda t: (
            True
            if t.startswith("http://") or t.startswith("https://")
            else "Must start with http:// or https://"
        ),
    ).ask()
    if url is None:
        sys.exit(0)

    _console.print(f"[dim]Probing {url}...[/dim]")
    ok, msg, response_data = _probe_url_with_headers(url, {}, "message", "", {})

    output_field = "response"
    if ok and response_data:
        str_keys = [k for k, v in response_data.items() if isinstance(v, str)]
        if len(str_keys) == 1:
            output_field = str_keys[0]
            _console.print(
                f"[green]Connected![/green] Auto-selected output field: [bold]{output_field}[/bold]"
            )
        elif str_keys:
            output_field = _select_output_field_from_response(response_data)
        else:
            _console.print(f"[green]Connected![/green]")
    elif ok:
        _console.print(f"[green]Connected![/green] {msg}")
    else:
        _console.print(f"[bold yellow]Probe failed:[/bold yellow] {msg}")
        proceed = questionary.confirm(
            "Continue anyway with default settings?", default=True
        ).ask()
        if not proceed:
            sys.exit(0)

    categories = list(VALID_CATEGORIES)
    _console.print(
        f"[dim]Running all {len(categories)} categories with low severity threshold...[/dim]"
    )
    _console.print()
    _launch_scan(url, categories, "terminal", "low", output_field, {}, headers={})


# ---------------------------------------------------------------------------
# Help screen
# ---------------------------------------------------------------------------


def _show_help() -> None:
    """Display the in-app help guide then loop back to the landing screen."""
    help_text = Text()

    help_text.append("HOW TO GET A CURL FROM DEVTOOLS\n", style="bold yellow")
    help_text.append("  1. Open Chrome/Edge → F12 → Network tab\n")
    help_text.append("  2. Send a message to your chatbot in the browser\n")
    help_text.append(
        "  3. Right-click the request → Copy → Copy as cURL (bash)\n"
        "     Then choose \"Import from curl command\" in PromptFuzz\n\n"
    )

    help_text.append("ATTACK CATEGORIES\n", style="bold yellow")
    for cat, desc in _CATEGORY_DESCRIPTIONS.items():
        help_text.append(f"  {cat:<20}", style="cyan")
        help_text.append(f" {desc}\n")
    help_text.append("\n")

    help_text.append("CLI EXAMPLES\n", style="bold yellow")
    help_text.append("  promptfuzz scan --target http://localhost:8000/chat --verbose\n")
    help_text.append("  promptfuzz scan --target mymodule:my_fn --categories jailbreak\n")
    help_text.append("  promptfuzz list-attacks\n\n")

    help_text.append("PYTHON WRAPPER (for complex APIs)\n", style="bold yellow")
    help_text.append("  from promptfuzz import Fuzzer\n\n")
    help_text.append("  def my_bot(prompt: str) -> str:\n")
    help_text.append("      # call your API here\n")
    help_text.append("      return response_text\n\n")
    help_text.append("  result = Fuzzer(target=my_bot).run()\n")
    help_text.append("  result.report()\n")

    _console.print()
    _console.print(Panel(help_text, title="PromptFuzz Help", border_style="yellow"))
    _console.print()

    # Loop back to landing
    run_wizard()


# ---------------------------------------------------------------------------
# Landing screen
# ---------------------------------------------------------------------------


def run_wizard() -> None:
    """Show the landing screen and route to the chosen setup path.

    Presents four options: manual setup, curl import, quick scan, and help.
    """
    _console.print()
    _console.print(
        Panel(
            f"[bold cyan]PromptFuzz[/bold cyan] v{__version__} — LLM Security Testing\n"
            "[dim]Find prompt injection, jailbreak & data extraction vulns[/dim]",
            expand=False,
            border_style="dim",
        )
    )
    _console.print()

    choice = questionary.select(
        "How would you like to set up your scan?",
        choices=[
            questionary.Choice(
                "Configure manually          Step-by-step guided setup",
                value="manual",
            ),
            questionary.Choice(
                "Import from curl command    Paste a cURL — auto-detects URL, headers & fields",
                value="curl",
            ),
            questionary.Choice(
                "Quick scan                  Fire all attacks at a URL right now (uses defaults)",
                value="quick",
            ),
            questionary.Choice(
                "Help & examples             Show usage guide and DevTools instructions",
                value="help",
            ),
        ],
    ).ask()

    if choice is None:
        sys.exit(0)

    if choice == "manual":
        _run_manual_wizard()
    elif choice == "curl":
        _run_curl_wizard()
    elif choice == "quick":
        _run_quick_scan()
    elif choice == "help":
        _show_help()


# ---------------------------------------------------------------------------
# Scan launcher
# ---------------------------------------------------------------------------


def _launch_scan(
    target: str,
    categories: list[str],
    output_fmt: str,
    severity: str,
    output_field: str = "response",
    extra_fields: dict[str, str] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Execute the scan with the wizard's chosen settings.

    Args:
        target: URL or 'module:function' import path.
        categories: Attack categories to run.
        output_fmt: One of 'terminal', 'txt', 'html', 'all'.
        severity: Minimum severity to display ('low', 'medium', 'high', 'critical').
        output_field: JSON key containing the model reply in HTTP mode.
        extra_fields: Extra fixed key-value pairs merged into every request payload.
        headers: HTTP headers to include in every request.
    """
    import importlib

    from promptfuzz.fuzzer import SEVERITY_WEIGHTS, Fuzzer  # noqa: F401

    # Resolve target
    if target.startswith("http://") or target.startswith("https://"):
        resolved: object = target
    else:
        module_path, func_name = target.rsplit(":", 1)
        try:
            mod = importlib.import_module(module_path)
            resolved = getattr(mod, func_name)
        except (ModuleNotFoundError, AttributeError) as exc:
            _console.print(f"[bold red]Error:[/bold red] Cannot resolve target: {exc}")
            sys.exit(1)

    fuzzer = Fuzzer(
        target=resolved,
        context="LLM application",
        categories=categories,
        output_field=output_field,
        extra_fields=extra_fields or {},
        headers=headers or {},
        verbose=False,
    )

    result = fuzzer.run()

    SEVERITY_ORDER = ["critical", "high", "medium", "low"]
    sev_idx = SEVERITY_ORDER.index(severity)
    result.vulnerabilities = [  # type: ignore[assignment]
        v for v in result.vulnerabilities
        if SEVERITY_ORDER.index(v.severity) <= sev_idx
    ]

    result.report()

    if output_fmt in {"txt", "all"}:
        result.to_txt("report.txt")
        _console.print("[dim]Saved report.txt[/dim]")

    if output_fmt in {"html", "all"}:
        result.save("report.html")
        _console.print("[dim]Saved report.html[/dim]")

    if output_fmt == "all":
        result.to_json("report.json")
        _console.print("[dim]Saved report.json[/dim]")
