"""Typer application for the Sentra CLI."""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
import json
import os
from pathlib import Path
from typing import TypeVar

import typer
from pydantic import ValidationError
from rich.panel import Panel

from sentra import __version__
from sentra.console import console, stderr_console
from sentra.discovery import (
    PassiveDiscoveryRequest,
    SummaryFilePassiveProvider,
    collect_passive_observations,
    estimate_target_hosts,
    scan_network,
)
from sentra.fingerprints import validate_fingerprint_entries
from sentra.firmware import analyze_firmware
from sentra.graph import build_evidence_graph
from sentra.models import CorrelationArtifact, FirmwareEvidence, NetworkScanArtifact, PassiveScanArtifact
from sentra.plugins import get_builtin_plugins, reset_protocol_analyzers
from sentra.reporting import build_scan_diff, write_diff_report
from sentra.reporting.renderer import build_report, render_report, write_report
from sentra.state import artifact_path, load_json, normalize_path, save_json
from sentra.ux import (
    LONG_SCAN_HOST_THRESHOLD,
    build_confirmation_message,
    build_correlation_table,
    build_error_panel,
    build_firmware_scan_table,
    build_network_scan_table,
    build_root_overview,
    build_startup_banner,
    build_status_table,
    build_workspace_inspection_table,
    correlation_markdown,
    default_correlation_summary,
    default_firmware_summary,
    default_quick_scan_summary,
    default_report_summary,
    default_scan_summary,
    default_status_summary,
    default_workspace_inspection_summary,
    firmware_scan_markdown,
    network_scan_markdown,
    progress_message,
)
from sentra.vulns import LocalJsonVulnerabilityProvider
from sentra.workspace import build_status_artifact, inspect_workspace

ROOT_HELP = """Sentra is a defensive IoT evidence graph CLI for authorized security testing, asset visibility, firmware inspection, and operator-ready reporting.

Start with `sentra quick-scan` for the fastest guided workflow, or use the scan -> correlate -> report flow when you want step-by-step control."""
ROOT_EPILOG = """Start here:
  sentra quick-scan --target 192.168.1.0/24 --yes

Step-by-step assessment:
  sentra scan net --target 192.168.1.0/24 --yes
  sentra scan firmware --path ./firmware.zip
  sentra correlate
  sentra report --format html

Workspace and intelligence checks:
  sentra status
  sentra inspect-workspace
  sentra fingerprints validate
  sentra vulns inspect"""

app = typer.Typer(
    help=ROOT_HELP,
    epilog=ROOT_EPILOG,
    invoke_without_command=True,
    rich_markup_mode="rich",
)
scan_app = typer.Typer(help="Collection workflows for active network discovery, passive summaries, and firmware evidence intake.")
fingerprints_app = typer.Typer(help="Fingerprint intelligence tools for validating catalogs and understanding device matches.")
vulns_app = typer.Typer(help="Offline vulnerability intelligence tools for reviewing the workspace feed and enrichment inputs.")
app.add_typer(scan_app, name="scan")
app.add_typer(fingerprints_app, name="fingerprints")
app.add_typer(vulns_app, name="vulns")
ArtifactModel = TypeVar("ArtifactModel", NetworkScanArtifact, FirmwareEvidence, CorrelationArtifact)


def _version_callback(value: bool) -> None:
    if value:
        typer.echo(f"sentra {__version__}")
        raise typer.Exit()


@dataclass(frozen=True)
class OutputOptions:
    mode: str = "terminal"
    quiet: bool = False
    verbose: bool = False

    @property
    def is_json(self) -> bool:
        return self.mode == "json"

    @property
    def is_markdown(self) -> bool:
        return self.mode == "markdown"

    @property
    def is_terminal(self) -> bool:
        return self.mode == "terminal"


@dataclass(frozen=True)
class GlobalOptions:
    workspace: Path = Path(".")
    output: OutputOptions = OutputOptions()


@app.callback()
def main(
    ctx: typer.Context,
    workspace: Path = typer.Option(Path("."), "--workspace", help="Default workspace for Sentra artifacts."),
    json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout by default."),
    quiet: bool = typer.Option(False, "--quiet", help="Reduce terminal output."),
    verbose: bool = typer.Option(False, "--verbose", help="Show extra terminal details."),
    version: bool = typer.Option(False, "--version", callback=_version_callback, is_eager=True, help="Show the installed Sentra version."),
) -> None:
    """Initialize the CLI and plugin registry."""
    try:
        ctx.obj = GlobalOptions(
            workspace=_normalize_workspace(workspace),
            output=OutputOptions(mode="json" if json_output else "terminal", quiet=quiet, verbose=verbose and not quiet),
        )
    except (ValueError, OSError) as exc:
        _emit_error(str(exc), OutputOptions(mode="json" if json_output else "terminal", quiet=quiet, verbose=False))
        raise typer.Exit(code=1) from exc
    reset_protocol_analyzers()
    for plugin in get_builtin_plugins():
        plugin.register()
    if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
        if json_output:
            typer.echo(
                json.dumps(
                    {
                        "tool": "sentra",
                        "version": __version__,
                        "workspace": str(ctx.obj.workspace),
                        "hero_workflow": "quick-scan",
                    },
                    indent=2,
                    sort_keys=True,
                )
            )
        else:
            console.print(build_startup_banner("Sentra", "Defensive IoT evidence graph and reporting for authorized operators"))
            console.print(build_root_overview(__version__))
            console.print("Run [bold]sentra --help[/bold] for the full command reference.")
        raise typer.Exit()


@app.command("quick-scan", help="Workflow: run the recommended first assessment by scanning a target, correlating evidence, and generating a report in one guided pass.")
def quick_scan(
    ctx: typer.Context,
    target: str = typer.Option(..., "--target", help="CIDR or host identifier to scan."),
    workspace: Path | None = typer.Option(None, "--workspace", help="Workspace for Sentra artifacts."),
    firmware_path: str | None = typer.Option(None, "--firmware-path", help="Optional firmware image to analyze in the same workspace."),
    report_format: str = typer.Option("html", "--report-format", help="terminal, markdown, json, or html"),
    output: Path | None = typer.Option(None, "--output", help="Optional report output path."),
    yes: bool = typer.Option(False, "--yes", help="Skip confirmation prompts for long-running scans."),
    quiet: bool = typer.Option(False, "--quiet", help="Reduce terminal output."),
    verbose: bool = typer.Option(False, "--verbose", help="Show extra terminal details."),
) -> None:
    global_options = _get_global_options(ctx)
    output_options = _merge_output_options(global_options.output, False, False, quiet, verbose)
    workspace_path = workspace or global_options.workspace

    def run() -> None:
        normalized_workspace = _normalize_workspace(workspace_path)
        normalized_target = _normalize_text(target, field_name="target")
        normalized_report_format = _resolve_output_mode(report_format, False, False)
        if output_options.is_terminal and not output_options.quiet:
            console.print(build_startup_banner("Quick Scan", "Sentra will scan, correlate, and report using the current workspace."))

        worker_output = OutputOptions(mode="terminal", quiet=True, verbose=output_options.verbose)
        network_artifact = _execute_network_scan(
            normalized_target,
            normalized_workspace,
            output_options=worker_output,
            yes=yes,
        )
        firmware_artifact: Path | None = None
        if firmware_path:
            firmware_artifact = _execute_firmware_scan(
                firmware_path,
                normalized_workspace,
                output_options=worker_output,
            )
        correlation_artifact = _execute_correlate(normalized_workspace, output_options=worker_output)
        report_output = _execute_report(
            normalized_workspace,
            normalized_report_format,
            output,
            output_options=worker_output,
        )

        payload = {
            "target": normalized_target,
            "workspace": str(normalized_workspace),
            "network_artifact": str(network_artifact),
            "firmware_artifact": str(firmware_artifact) if firmware_artifact else None,
            "correlation_artifact": str(correlation_artifact),
            "report_format": normalized_report_format,
            "report_output": str(report_output) if report_output else None,
        }
        if output_options.is_json:
            typer.echo(json.dumps(payload, indent=2, sort_keys=True))
            return

        if output_options.is_terminal:
            console.print(f"Collected network evidence for [bold]{normalized_target}[/bold].")
            if firmware_artifact:
                console.print("Added firmware evidence to the workspace.")
            console.print(f"Correlated evidence graph at [bold]{correlation_artifact}[/bold].")
            if report_output:
                console.print(f"Generated {normalized_report_format} report at [bold]{report_output}[/bold].")
            elif normalized_report_format == "terminal":
                console.print("Rendered the terminal report.")
            if not output_options.quiet:
                console.print(
                    default_quick_scan_summary(
                        normalized_target,
                        normalized_workspace,
                        network_artifact,
                        correlation_artifact,
                        report_output,
                        firmware_artifact=firmware_artifact,
                    )
                )

    _run_with_friendly_errors(run, output=output_options)


@scan_app.command("net", help="Collection: discover reachable hosts, exposed services, and protocol clues from a host or CIDR target.")
def scan_net(
    ctx: typer.Context,
    target: str = typer.Option(..., "--target", help="CIDR or host identifier to scan."),
    workspace: Path | None = typer.Option(None, "--workspace", help="Workspace for Sentra artifacts."),
    yes: bool = typer.Option(False, "--yes", help="Skip confirmation prompts for long-running scans."),
    json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
    markdown_output: bool = typer.Option(False, "--markdown", help="Emit Markdown to stdout."),
    quiet: bool = typer.Option(False, "--quiet", help="Reduce terminal output."),
    verbose: bool = typer.Option(False, "--verbose", help="Show extra terminal details."),
) -> None:
    global_options = _get_global_options(ctx)
    output = _merge_output_options(global_options.output, json_output, markdown_output, quiet, verbose)
    _run_with_friendly_errors(
        lambda: _execute_network_scan(target, workspace or global_options.workspace, output_options=output, yes=yes),
        output=output,
    )


@scan_app.command("firmware", help="Collection: inspect a firmware image or archive to extract defensive evidence, findings, and software signals.")
def scan_firmware(
    ctx: typer.Context,
    path: str = typer.Option(..., "--path", help="Firmware image path."),
    workspace: Path | None = typer.Option(None, "--workspace", help="Workspace for Sentra artifacts."),
    json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
    markdown_output: bool = typer.Option(False, "--markdown", help="Emit Markdown to stdout."),
    quiet: bool = typer.Option(False, "--quiet", help="Reduce terminal output."),
    verbose: bool = typer.Option(False, "--verbose", help="Show extra terminal details."),
) -> None:
    global_options = _get_global_options(ctx)
    output = _merge_output_options(global_options.output, json_output, markdown_output, quiet, verbose)
    _run_with_friendly_errors(
        lambda: _execute_firmware_scan(path, workspace or global_options.workspace, output_options=output),
        output=output,
    )


@scan_app.command("passive", help="Collection: turn an authorized passive-summary file into structured observations for later correlation.")
def scan_passive(
    ctx: typer.Context,
    summary_file: Path = typer.Option(..., "--summary-file", help="Passive traffic summary file to parse."),
    workspace: Path | None = typer.Option(None, "--workspace", help="Workspace for Sentra artifacts."),
    duration: float = typer.Option(5.0, "--duration", help="Recorded passive observation duration in seconds."),
    json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
    quiet: bool = typer.Option(False, "--quiet", help="Reduce terminal output."),
    verbose: bool = typer.Option(False, "--verbose", help="Show extra terminal details."),
) -> None:
    global_options = _get_global_options(ctx)
    output = _merge_output_options(global_options.output, json_output, False, quiet, verbose)
    _run_with_friendly_errors(
        lambda: _execute_passive_scan(summary_file, workspace or global_options.workspace, duration=duration, output_options=output),
        output=output,
    )


@app.command(help="Workflow: connect saved network and firmware evidence into an explainable graph with ranked risk insights.")
def correlate(
    ctx: typer.Context,
    workspace: Path | None = typer.Option(None, "--workspace", help="Workspace for Sentra artifacts."),
    json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
    markdown_output: bool = typer.Option(False, "--markdown", help="Emit Markdown to stdout."),
    quiet: bool = typer.Option(False, "--quiet", help="Reduce terminal output."),
    verbose: bool = typer.Option(False, "--verbose", help="Show extra terminal details."),
) -> None:
    global_options = _get_global_options(ctx)
    output = _merge_output_options(global_options.output, json_output, markdown_output, quiet, verbose)
    _run_with_friendly_errors(
        lambda: _execute_correlate(workspace or global_options.workspace, output_options=output),
        output=output,
    )


@app.command(help="Workflow: generate operator-facing terminal, Markdown, JSON, or HTML reports from the current workspace.")
def report(
    ctx: typer.Context,
    workspace: Path | None = typer.Option(None, "--workspace", help="Workspace for Sentra artifacts."),
    output_format: str = typer.Option("terminal", "--format", help="terminal, markdown, json, or html"),
    output: Path | None = typer.Option(None, "--output", help="Optional report file path."),
    json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
    markdown_output: bool = typer.Option(False, "--markdown", help="Emit Markdown to stdout."),
    quiet: bool = typer.Option(False, "--quiet", help="Reduce terminal output."),
    verbose: bool = typer.Option(False, "--verbose", help="Show extra terminal details."),
) -> None:
    global_options = _get_global_options(ctx)
    resolved_format = _resolve_output_mode(output_format, json_output, markdown_output)
    output_options = _merge_output_options(global_options.output, json_output, markdown_output, quiet, verbose)
    _run_with_friendly_errors(
        lambda: _execute_report(workspace or global_options.workspace, resolved_format, output, output_options=output_options),
        output=output_options,
    )


@app.command("status", help="Workspace: show what has been collected, analyzed, and reported in the current workspace.")
def status_command(
    ctx: typer.Context,
    workspace: Path | None = typer.Option(None, "--workspace", help="Workspace for Sentra artifacts."),
    json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
    quiet: bool = typer.Option(False, "--quiet", help="Reduce terminal output."),
    verbose: bool = typer.Option(False, "--verbose", help="Show extra terminal details."),
) -> None:
    global_options = _get_global_options(ctx)
    output = _merge_output_options(global_options.output, json_output, False, quiet, verbose)
    _run_with_friendly_errors(
        lambda: _execute_status(workspace or global_options.workspace, output_options=output),
        output=output,
    )


@app.command("inspect-workspace", help="Workspace: inspect saved artifacts, report files, and firmware extraction directories with resolved paths.")
def inspect_workspace_command(
    ctx: typer.Context,
    workspace: Path | None = typer.Option(None, "--workspace", help="Workspace for Sentra artifacts."),
    json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
    quiet: bool = typer.Option(False, "--quiet", help="Reduce terminal output."),
    verbose: bool = typer.Option(False, "--verbose", help="Show extra terminal details."),
) -> None:
    global_options = _get_global_options(ctx)
    output = _merge_output_options(global_options.output, json_output, False, quiet, verbose)
    _run_with_friendly_errors(
        lambda: _execute_inspect_workspace(workspace or global_options.workspace, output_options=output),
        output=output,
    )


@fingerprints_app.command("validate", help="Intelligence: validate built-in and custom fingerprint catalogs before using them in matching.")
def fingerprints_validate(
    ctx: typer.Context,
    workspace: Path | None = typer.Option(None, "--workspace", help="Workspace for Sentra artifacts."),
    override: list[Path] = typer.Option(None, "--override", help="Optional fingerprint override file path.", show_default=False),
    json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
) -> None:
    global_options = _get_global_options(ctx)
    output = _merge_output_options(global_options.output, json_output, False, False, False)

    def run() -> None:
        effective_workspace = workspace or global_options.workspace
        result = validate_fingerprint_entries(effective_workspace, user_override_paths=list(override or []))
        payload = {
            "entries_checked": result.entries_checked,
            "issue_count": result.issue_count,
            "issues": [issue.__dict__ for issue in result.issues],
        }
        if output.is_json:
            typer.echo(json.dumps(payload, indent=2, sort_keys=True))
            return
        console.print(f"Validated {result.entries_checked} fingerprint entries.")
        console.print(f"Issues: {result.issue_count}")
        for issue in result.issues[:20]:
            console.print(f"[{issue.level}] {issue.fingerprint}: {issue.message}")

    _run_with_friendly_errors(run, output=output)


@vulns_app.command("inspect", help="Intelligence: inspect the offline vulnerability feed available in a workspace and review its coverage.")
def vulns_inspect(
    ctx: typer.Context,
    workspace: Path | None = typer.Option(None, "--workspace", help="Workspace for Sentra artifacts."),
    json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
) -> None:
    global_options = _get_global_options(ctx)
    output = _merge_output_options(global_options.output, json_output, False, False, False)

    def run() -> None:
        effective_workspace = _normalize_workspace(workspace or global_options.workspace)
        provider = LocalJsonVulnerabilityProvider(effective_workspace)
        payload = {
            "provider": provider.name,
            "feed_name": provider.feed_name,
            "entries": len(provider.feed),
            "products": sorted(
                {
                    str(item.get("product", "")).strip()
                    for item in provider.feed
                    if str(item.get("product", "")).strip()
                }
            )[:25],
        }
        if output.is_json:
            typer.echo(json.dumps(payload, indent=2, sort_keys=True))
            return
        console.print(f"Provider: {provider.name}")
        console.print(f"Feed: {provider.feed_name}")
        console.print(f"Entries: {len(provider.feed)}")
        if payload["products"]:
            console.print("Products: " + ", ".join(payload["products"]))

    _run_with_friendly_errors(run, output=output)


@app.command(help="Workflow: step through a guided network or firmware assessment when you want prompts instead of flags.")
def wizard() -> None:
    """Run an interactive guided workflow."""

    def run() -> None:
        console.print(
            Panel(
                "Answer a few questions and Sentra will run the matching workflow for you.",
                title="Sentra Wizard",
                border_style="cyan",
            )
        )
        target_type = _prompt_target_type()
        target_value = _prompt_non_empty("Target value")
        output_value = _prompt_non_empty("Output path (file path, or directory ending with a path separator)")
        run_follow_up = typer.confirm("Run correlate and report after the scan?", default=True)
        output_path = _resolve_user_output_path(output_value, target_type=target_type, run_follow_up=run_follow_up)
        workspace = output_path.parent

        if target_type == "network":
            _execute_network_scan(
                target_value,
                workspace,
                output_options=OutputOptions(),
                export_path=None if run_follow_up else output_path,
                export_path_is_resolved=True,
            )
        else:
            _execute_firmware_scan(
                target_value,
                workspace,
                output_options=OutputOptions(),
                export_path=None if run_follow_up else output_path,
                export_path_is_resolved=True,
            )

        if run_follow_up:
            _execute_correlate(workspace, output_options=OutputOptions())
            report_format = _infer_report_format(output_path)
            _execute_report(
                workspace,
                report_format,
                output_path,
                output_options=OutputOptions(),
                destination_is_resolved=True,
            )

    _run_with_friendly_errors(run)


@app.command(help="Workflow: compare two workspaces to highlight changed devices, findings, firmware signals, and correlations.")
def diff(
    ctx: typer.Context,
    before_workspace: Path = typer.Option(..., "--before-workspace", help="Workspace for the baseline scan."),
    after_workspace: Path | None = typer.Option(None, "--after-workspace", help="Workspace for the comparison scan."),
    output_format: str = typer.Option("markdown", "--format", help="terminal, markdown, json, or html"),
    output: Path | None = typer.Option(None, "--output", help="Optional diff report file path."),
) -> None:
    global_options = _get_global_options(ctx)
    effective_after = after_workspace or global_options.workspace

    def run() -> None:
        normalized_format = _normalize_diff_format(output_format)
        before_scan = _load_model(before_workspace, "network_scan", NetworkScanArtifact)
        after_scan = _load_model(effective_after, "network_scan", NetworkScanArtifact)
        before_firmware = _load_model(before_workspace, "firmware_scan", FirmwareEvidence)
        after_firmware = _load_model(effective_after, "firmware_scan", FirmwareEvidence)
        before_correlation = _load_model(before_workspace, "correlation", CorrelationArtifact)
        after_correlation = _load_model(effective_after, "correlation", CorrelationArtifact)
        content = build_scan_diff(
            before_scan,
            after_scan,
            normalized_format,
            previous_firmware=before_firmware,
            current_firmware=after_firmware,
            previous_correlation=before_correlation,
            current_correlation=after_correlation,
        )
        if output is None:
            typer.echo(content)
            return
        destination = _resolve_output_destination(
            output,
            default_filename=f"scan-diff.{_report_extension(normalized_format)}",
        )
        write_diff_report(destination, content)
        _print_if_human(f"Saved diff report to [bold]{destination}[/bold]", global_options.output)

    _run_with_friendly_errors(run, output=global_options.output)


def _load_model(workspace: Path, name: str, model_type: type[ArtifactModel]) -> ArtifactModel | None:
    workspace = _normalize_workspace(workspace)
    path = artifact_path(workspace, name)
    if not path.exists():
        return None
    try:
        return model_type.model_validate(load_json(path))
    except ValidationError as exc:
        raise typer.BadParameter(f"Invalid artifact in {path}: {exc}") from exc


def _default_report_path(workspace: Path, output_format: str) -> Path:
    workspace = _normalize_workspace(workspace)
    extension = {"markdown": "md", "json": "json", "html": "html"}.get(output_format, "txt")
    return workspace / ".sentra" / f"report.{extension}"


def _artifact_count(workspace: Path) -> int:
    workspace = _normalize_workspace(workspace)
    state_dir = workspace / ".sentra"
    if not state_dir.exists():
        return 0
    artifact_names = {
        "network_scan.json",
        "passive_scan.json",
        "firmware_scan.json",
        "correlation.json",
        "report.json",
        "report.md",
        "report.html",
    }
    return sum(1 for path in state_dir.iterdir() if path.name in artifact_names)


def _execute_network_scan(
    target: str,
    workspace: Path,
    *,
    output_options: OutputOptions,
    yes: bool = False,
    export_path: Path | None = None,
    export_path_is_resolved: bool = False,
) -> Path:
    target = _normalize_text(target, field_name="target")
    workspace = _normalize_workspace(workspace)
    host_count = estimate_target_hosts(target)
    if host_count > LONG_SCAN_HOST_THRESHOLD and not yes:
        should_continue = typer.confirm(build_confirmation_message(target, host_count))
        if not should_continue:
            raise typer.Abort()

    with _progress("Discovering devices and services...", output_options):
        artifact = scan_network(target, workspace=workspace)

    artifact_output = artifact_path(workspace, "network_scan")
    save_json(artifact_output, artifact.model_dump(mode="json"))

    _print_verbose(f"Workspace: {workspace}", output_options)
    _print_verbose(f"Artifact path: {artifact_output}", output_options)
    _emit_network_scan(artifact, output_options, artifact_output=artifact_output)
    if export_path is not None:
        if not export_path_is_resolved:
            export_path = _resolve_output_destination(export_path, default_filename="network_scan.json")
        save_json(export_path, artifact.model_dump(mode="json"))
        _print_if_human(f"Exported network scan to [bold]{export_path}[/bold]", output_options)
    if output_options.is_terminal and not output_options.quiet:
        console.print(default_scan_summary(artifact, export_path or artifact_output))
    return artifact_output


def _execute_firmware_scan(
    path: str,
    workspace: Path,
    *,
    output_options: OutputOptions,
    export_path: Path | None = None,
    export_path_is_resolved: bool = False,
) -> Path:
    path = _normalize_text(path, field_name="path")
    workspace = _normalize_workspace(workspace)
    with _progress("Analyzing firmware image...", output_options):
        artifact = analyze_firmware(path, workspace=workspace)

    artifact_output = artifact_path(workspace, "firmware_scan")
    save_json(artifact_output, artifact.model_dump(mode="json"))

    _print_verbose(f"Workspace: {workspace}", output_options)
    _print_verbose(f"Artifact path: {artifact_output}", output_options)
    _emit_firmware_scan(artifact, output_options, artifact_output=artifact_output)
    if export_path is not None:
        if not export_path_is_resolved:
            export_path = _resolve_output_destination(export_path, default_filename="firmware_scan.json")
        save_json(export_path, artifact.model_dump(mode="json"))
        _print_if_human(f"Exported firmware scan to [bold]{export_path}[/bold]", output_options)
    if output_options.is_terminal and not output_options.quiet:
        console.print(default_firmware_summary(artifact, export_path or artifact_output))
    return artifact_output


def _execute_passive_scan(
    summary_file: Path,
    workspace: Path,
    *,
    duration: float,
    output_options: OutputOptions,
) -> Path:
    workspace = _normalize_workspace(workspace)
    summary_path = normalize_path(summary_file, field_name="summary file")
    if not summary_path.exists() or not summary_path.is_file():
        raise FileNotFoundError(f"Summary file not found: {summary_path}")

    provider = SummaryFilePassiveProvider(summary_path)
    request = PassiveDiscoveryRequest(duration_seconds=max(0.0, duration), max_events=256, protocols=("mdns", "ssdp", "dhcp", "snmp"))
    with _progress("Parsing passive traffic summaries...", output_options):
        result = collect_passive_observations([provider], request)

    artifact = PassiveScanArtifact(
        observations=result.observations,
        discovery_providers=result.providers,
        duration_seconds=request.duration_seconds,
        source=str(summary_path),
    )
    artifact_output = artifact_path(workspace, "passive_scan")
    save_json(artifact_output, artifact.model_dump(mode="json"))

    _print_verbose(f"Workspace: {workspace}", output_options)
    _print_verbose(f"Artifact path: {artifact_output}", output_options)
    if output_options.is_json:
        typer.echo(artifact.model_dump_json(indent=2))
    elif output_options.is_terminal and not output_options.quiet:
        console.print(f"Parsed {len(artifact.observations)} passive observation(s) from [bold]{summary_path}[/bold]")
        console.print(f"Saved passive scan to [bold]{artifact_output}[/bold]")
    return artifact_output


def _execute_correlate(workspace: Path, *, output_options: OutputOptions) -> Path:
    workspace = _normalize_workspace(workspace)
    network_scan = _load_model(workspace, "network_scan", NetworkScanArtifact)
    firmware_scan = _load_model(workspace, "firmware_scan", FirmwareEvidence)

    with _progress("Building evidence graph...", output_options):
        artifact = build_evidence_graph(network_scan, firmware_scan)

    artifact_output = artifact_path(workspace, "correlation")
    save_json(artifact_output, artifact.model_dump(mode="json"))

    _print_verbose(f"Workspace: {workspace}", output_options)
    _print_verbose(f"Artifact path: {artifact_output}", output_options)
    _emit_correlation(artifact, output_options, artifact_output=artifact_output)
    if output_options.is_terminal and not output_options.quiet:
        console.print(default_correlation_summary(artifact, artifact_output))
    return artifact_output


def _execute_report(
    workspace: Path,
    output_format: str,
    destination: Path | None = None,
    *,
    output_options: OutputOptions,
    destination_is_resolved: bool = False,
) -> Path | None:
    workspace = _normalize_workspace(workspace)
    normalized_format = output_format.lower()
    if normalized_format not in {"terminal", "markdown", "json", "html"}:
        raise typer.BadParameter("Report format must be terminal, markdown, json, or html.")

    network_scan = _load_model(workspace, "network_scan", NetworkScanArtifact)
    firmware_scan = _load_model(workspace, "firmware_scan", FirmwareEvidence)
    correlation = _load_model(workspace, "correlation", CorrelationArtifact)

    with _progress("Generating report...", output_options):
        artifact, content = build_report(network_scan, firmware_scan, correlation, normalized_format)

    _print_verbose(f"Workspace: {workspace}", output_options)
    if output_options.is_terminal:
        if normalized_format == "terminal" and destination is None:
            render_report(artifact)
            if not output_options.quiet:
                console.print(default_report_summary(normalized_format, _artifact_count(workspace)))
            return None

        if destination is None:
            destination = _resolve_output_destination(
                _default_report_path(workspace, normalized_format),
                default_filename=f"report.{_report_extension(normalized_format)}",
            )
        elif not destination_is_resolved:
            destination = _resolve_output_destination(
                destination,
                default_filename=f"report.{_report_extension(normalized_format)}",
            )
        write_report(destination, content)
        _print_verbose(f"Report path: {destination}", output_options)
        _print_if_human(f"Saved {normalized_format} report to [bold]{destination}[/bold]", output_options)
        if not output_options.quiet:
            console.print(default_report_summary(normalized_format, _artifact_count(workspace), destination))
        return destination

    if output_options.is_markdown:
        typer.echo(content)
    else:
        typer.echo(content if normalized_format == "json" else json.dumps(artifact.model_dump(mode="json"), indent=2))
    return destination


def _execute_status(workspace: Path, *, output_options: OutputOptions) -> None:
    workspace = _normalize_workspace(workspace)
    artifact = build_status_artifact(workspace)
    _print_verbose(f"Workspace: {workspace}", output_options)
    if output_options.is_json:
        typer.echo(artifact.model_dump_json(indent=2))
        return
    console.print(f"Workspace: {artifact.workspace}")
    console.print(build_status_table(artifact))
    if not output_options.quiet:
        console.print(default_status_summary(artifact))


def _execute_inspect_workspace(workspace: Path, *, output_options: OutputOptions) -> None:
    workspace = _normalize_workspace(workspace)
    inspection = inspect_workspace(workspace)
    _print_verbose(f"Workspace: {workspace}", output_options)
    if output_options.is_json:
        typer.echo(inspection.model_dump_json(indent=2))
        return
    console.print(build_workspace_inspection_table(inspection))
    if not output_options.quiet:
        console.print(default_workspace_inspection_summary(inspection))


def _prompt_target_type() -> str:
    while True:
        value = typer.prompt("Target type", default="network").strip().lower()
        if value in {"network", "firmware"}:
            return value
        console.print(build_error_panel("Target type must be 'network' or 'firmware'.", title="Invalid Choice"))


def _prompt_non_empty(label: str) -> str:
    while True:
        value = typer.prompt(label).strip()
        if value:
            return value
        console.print(build_error_panel(f"{label} cannot be empty.", title="Invalid Input"))


def _infer_report_format(output_path: Path) -> str:
    suffix = output_path.suffix.lower()
    if suffix == ".json":
        return "json"
    if suffix == ".html":
        return "html"
    return "markdown"


def _report_extension(output_format: str) -> str:
    return {"markdown": "md", "json": "json", "html": "html", "terminal": "txt"}.get(output_format, "txt")


def _normalize_text(value: str, *, field_name: str) -> str:
    normalized = value.strip()
    if not normalized:
        raise ValueError(f"{field_name.capitalize()} cannot be empty.")
    return normalized


def _normalize_workspace(workspace: Path) -> Path:
    normalized = normalize_path(workspace, field_name="workspace path")
    if normalized.exists() and not normalized.is_dir():
        raise ValueError(f"Workspace path must be a directory: {normalized}")
    return normalized


def _resolve_user_output_path(raw_value: str, *, target_type: str, run_follow_up: bool) -> Path:
    normalized = _normalize_text(raw_value, field_name="output path")
    candidate = normalize_path(normalized, field_name="output path")
    if run_follow_up:
        default_name = "report.md"
    else:
        default_name = "network_scan.json" if target_type == "network" else "firmware_scan.json"
    if _looks_like_explicit_directory(normalized, candidate):
        return _resolve_output_destination(candidate, default_filename=default_name, raw_text=normalized)
    parent = candidate.parent
    if parent.exists() and not parent.is_dir():
        raise ValueError(f"Output parent is not a directory: {parent}")
    return candidate


def _resolve_output_destination(path: Path, *, default_filename: str, raw_text: str | None = None) -> Path:
    raw_text = (raw_text or str(path)).strip()
    candidate = normalize_path(raw_text, field_name="output path")
    if _looks_like_directory(raw_text, candidate):
        candidate = candidate / default_filename
    parent = candidate.parent
    if parent.exists() and not parent.is_dir():
        raise ValueError(f"Output parent is not a directory: {parent}")
    return candidate


def _looks_like_directory(raw_text: str, candidate: Path) -> bool:
    separators = {os.sep}
    if os.altsep:
        separators.add(os.altsep)
    return (
        candidate.is_dir()
        or any(raw_text.endswith(separator) for separator in separators)
        or (not candidate.suffix and not candidate.name.startswith("."))
    )


def _looks_like_explicit_directory(raw_text: str, candidate: Path) -> bool:
    separators = {os.sep}
    if os.altsep:
        separators.add(os.altsep)
    return candidate.is_dir() or any(raw_text.endswith(separator) for separator in separators)


def _normalize_diff_format(output_format: str) -> str:
    normalized = output_format.strip().lower()
    if normalized not in {"terminal", "markdown", "json", "html"}:
        raise typer.BadParameter("Diff format must be terminal, markdown, json, or html.")
    return normalized


def _build_output_options(json_output: bool, markdown_output: bool, quiet: bool, verbose: bool) -> OutputOptions:
    mode = _resolve_output_mode("terminal", json_output, markdown_output)
    return OutputOptions(mode=mode, quiet=quiet, verbose=verbose and not quiet)


def _get_global_options(ctx: typer.Context) -> GlobalOptions:
    if isinstance(ctx.obj, GlobalOptions):
        return ctx.obj
    return GlobalOptions()


def _merge_output_options(
    global_output: OutputOptions,
    json_output: bool,
    markdown_output: bool,
    quiet: bool,
    verbose: bool,
) -> OutputOptions:
    default_mode = "json" if global_output.is_json else "terminal"
    mode = _resolve_output_mode(default_mode, json_output, markdown_output)
    merged_quiet = quiet or global_output.quiet
    return OutputOptions(mode=mode, quiet=merged_quiet, verbose=(verbose or global_output.verbose) and not merged_quiet)


def _resolve_output_mode(default: str, json_output: bool, markdown_output: bool) -> str:
    if json_output and markdown_output:
        raise typer.BadParameter("Choose either --json or --markdown, not both.")
    flag_mode = "json" if json_output else "markdown" if markdown_output else None
    normalized_default = default.lower()
    if flag_mode and normalized_default != "terminal" and normalized_default != flag_mode:
        raise typer.BadParameter("Output flags must match --format when both are provided.")
    if flag_mode:
        return flag_mode
    return default


def _emit_network_scan(artifact: NetworkScanArtifact, output: OutputOptions, *, artifact_output: Path) -> None:
    if output.is_json:
        typer.echo(artifact.model_dump_json(indent=2))
    elif output.is_markdown:
        typer.echo(network_scan_markdown(artifact, artifact_output))
    else:
        console.print(build_network_scan_table(artifact))
        _print_if_human(f"Saved network scan to [bold]{artifact_output}[/bold]", output)


def _emit_firmware_scan(artifact: FirmwareEvidence, output: OutputOptions, *, artifact_output: Path) -> None:
    if output.is_json:
        typer.echo(artifact.model_dump_json(indent=2))
    elif output.is_markdown:
        typer.echo(firmware_scan_markdown(artifact, artifact_output))
    else:
        console.print(build_firmware_scan_table(artifact))
        _print_if_human(f"Saved firmware scan to [bold]{artifact_output}[/bold]", output)


def _emit_correlation(artifact: CorrelationArtifact, output: OutputOptions, *, artifact_output: Path) -> None:
    if output.is_json:
        typer.echo(artifact.model_dump_json(indent=2))
    elif output.is_markdown:
        typer.echo(correlation_markdown(artifact, artifact_output))
    else:
        console.print("Evidence graph correlation complete.")
        console.print(build_correlation_table(artifact))
        _print_if_human(f"Saved correlation graph to [bold]{artifact_output}[/bold]", output)


def _print_if_human(message: str, output: OutputOptions) -> None:
    if output.is_terminal and not output.quiet:
        console.print(message)


def _print_verbose(message: str, output: OutputOptions) -> None:
    if output.is_terminal and output.verbose and not output.quiet:
        console.print(f"[dim]{message}[/dim]")


def _progress(message: str, output: OutputOptions):
    if output.is_terminal and not output.quiet:
        return progress_message(message)
    return _noop_progress()


def _noop_progress():
    return _NoopProgress()


class _NoopProgress:
    def __enter__(self) -> None:
        return None

    def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
        return False


def _run_with_friendly_errors(action: Callable[[], None], *, output: OutputOptions | None = None) -> None:
    try:
        action()
    except typer.Abort:
        _emit_error("Command cancelled by user.", output, title="Scan Cancelled")
        raise typer.Exit(code=1) from None
    except typer.BadParameter as exc:
        _emit_error(str(exc), output)
        raise typer.Exit(code=1) from exc
    except (FileNotFoundError, PermissionError, ValueError, OSError) as exc:
        _emit_error(str(exc), output)
        raise typer.Exit(code=1) from exc


def _emit_error(message: str, output: OutputOptions | None, *, title: str = "Sentra Error") -> None:
    if output and output.is_json:
        typer.echo(json.dumps({"error": message, "title": title}, indent=2), err=True)
    else:
        stderr_console.print(build_error_panel(message, title=title))


if __name__ == "__main__":
    app()

