"""Metalware CLI — batch submission and monitoring for firmware analysis."""

import json
import os
import sys
import time
import warnings
from pathlib import Path

import requests
import typer
import urllib3

from metalware_sdk.havoc_client import HavocClient
from metalware_sdk.havoc_common_schema import (
    CortexMGeneric,
    Image,
    ImageFormat,
    Mcu,
)

app = typer.Typer(help="Metalware firmware analysis CLI", no_args_is_help=True, add_completion=False)

CONFIG_PATH = Path.home() / ".config" / "metalware" / "config.json"


def _load_config() -> dict:
    if CONFIG_PATH.exists():
        try:
            return json.loads(CONFIG_PATH.read_text())
        except (json.JSONDecodeError, OSError):
            return {}
    return {}


def _save_config(config: dict) -> None:
    CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    CONFIG_PATH.write_text(json.dumps(config, indent=2) + "\n")
    CONFIG_PATH.chmod(0o600)


def _get_settings() -> dict:
    """Resolve server settings: url, verify_ssl, token.

    Environment variables take precedence over config file:
    - METALWARE_URL: server URL
    - METALWARE_API_KEY: API key for authentication
    """
    config = _load_config()
    return {
        "url": os.environ.get("METALWARE_URL") or config.get("url", "http://localhost:8080"),
        "verify_ssl": config.get("verify_ssl", True),
        "token": os.environ.get("METALWARE_API_KEY") or config.get("token"),
    }


def _get_client() -> HavocClient:
    if not CONFIG_PATH.exists() and not os.environ.get("METALWARE_URL") and not os.environ.get("METALWARE_API_KEY"):
        typer.echo("No configuration found. Running setup first.\n")
        setup()
        typer.echo()
    s = _get_settings()
    if not s["verify_ssl"]:
        urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    return HavocClient(base_url=s["url"], verify_ssl=s["verify_ssl"], token=s["token"])


def _ping_server(
    url: str, verify_ssl: bool, token: str | None = None
) -> tuple[str, str]:
    """Try to reach the server. Returns (status, message).

    status is one of: "ok", "auth", "ssl", "error".
    """
    headers = {"Authorization": f"Bearer {token}"} if token else {}
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", urllib3.exceptions.InsecureRequestWarning)
        try:
            resp = requests.get(
                f"{url}/api/dashboard",
                timeout=5,
                verify=verify_ssl,
                headers=headers,
            )
        except requests.exceptions.SSLError:
            return "ssl", "SSL verification failed"
        except requests.exceptions.ConnectionError as e:
            return "error", f"Connection failed: {e}"
        except requests.exceptions.RequestException as e:
            return "error", str(e)

    if resp.status_code in (401, 403):
        detail = ""
        try:
            detail = resp.json().get("error", "")
        except Exception:
            pass
        reason = f"Authentication required ({resp.status_code})"
        if detail:
            reason += f": {detail}"
        return "auth", reason

    try:
        qs = resp.json()
        return "ok", (
            f"Connected — pool size {qs['pool_size']}, "
            f"{qs['active_workers']} active workers, "
            f"{qs['queued_count']} queued"
        )
    except Exception:
        return "ok", f"Server reached (HTTP {resp.status_code})"


@app.command()
def setup():
    """Configure the Metalware server URL and verify connectivity."""
    current = _get_settings()
    url = typer.prompt("Metalware server URL", default=current["url"]).rstrip("/")

    verify_ssl = True
    token = None

    existing_token = current.get("token")

    typer.echo(f"\nConnecting to {url} ...")
    status, msg = _ping_server(url, verify_ssl=True, token=existing_token)

    # Handle SSL errors
    if status == "ssl" and url.startswith("https"):
        typer.echo(f"  {msg}.", err=True)
        if typer.confirm("Disable SSL verification for this server?", default=True):
            verify_ssl = False
            status, msg = _ping_server(url, verify_ssl=False, token=existing_token)
        else:
            if not typer.confirm("Save this URL anyway?", default=False):
                raise typer.Exit(1)
            status = "skip"

    if status == "ok" and existing_token:
        typer.echo(f"  {msg}")
        token = existing_token
    else:
        # Report connectivity result
        if status == "ok":
            typer.echo(f"  {msg}")
        elif status == "auth":
            typer.echo(f"  {msg}.")
        elif status == "error":
            typer.echo(f"  {msg}", err=True)
            if not typer.confirm("Save this URL anyway?", default=False):
                raise typer.Exit(1)

        # Prompt for API key
        typer.echo(f"\n  Enter an API key (create one at {url}/api-keys in the web UI).")
        token_input = typer.prompt("API key", default="", hide_input=True).strip()
        if token_input.lower().startswith("bearer "):
            token_input = token_input[7:].strip()
        if token_input:
            token = token_input
            status, msg = _ping_server(url, verify_ssl=verify_ssl, token=token)
            if status == "ok":
                typer.echo(f"  {msg}")
            elif status == "auth":
                typer.echo(f"  API key rejected — {msg}", err=True)
                token = None
                if not typer.confirm("Save without authentication?", default=False):
                    raise typer.Exit(1)
            else:
                typer.echo(f"  {msg}")
        else:
            token = None
            typer.echo("  Skipping authentication.")

    config = _load_config()
    config["url"] = url
    config["verify_ssl"] = verify_ssl
    config["token"] = token
    _save_config(config)
    typer.echo(f"\nSaved to {CONFIG_PATH}")


# ELF e_machine values for supported MCU architectures
_MCU_ELF_MACHINES: dict[str, set[int]] = {
    "cortexm": {40},  # EM_ARM
}


def _validate_firmware_elf(file_path: Path, mcu: str) -> None:
    """Validate that a file is an ELF binary targeting the expected architecture."""
    try:
        with open(file_path, "rb") as f:
            header = f.read(20)
    except OSError as e:
        raise ValueError(f"Cannot read {file_path.name}: {e}")

    if len(header) < 20 or header[:4] != b"\x7fELF":
        raise ValueError(
            f"{file_path.name} is not an ELF binary. "
            "Raw binary submission requires a load address and is only supported via the web UI."
        )

    # e_machine is a 16-bit little-endian field at offset 18
    e_machine = int.from_bytes(header[18:20], "little")
    expected = _MCU_ELF_MACHINES.get(mcu)
    if expected and e_machine not in expected:
        raise ValueError(
            f"{file_path.name} has ELF machine type {e_machine}, "
            f"expected {expected} for MCU '{mcu}'. Is this firmware?"
        )


import re

_TAG_RE = re.compile(r"^[a-zA-Z0-9._-]+$")


def _validate_tags(tags: list[str]) -> None:
    """Validate tags match server rules. Raises typer.BadParameter on failure."""
    if len(tags) > 16:
        raise typer.BadParameter(f"Too many tags ({len(tags)}), max 16")
    for tag in tags:
        if not tag:
            raise typer.BadParameter("Tag cannot be empty")
        if len(tag) > 32:
            raise typer.BadParameter(f"Tag '{tag}' exceeds max length of 32 characters")
        if not _TAG_RE.match(tag):
            raise typer.BadParameter(
                f"Tag '{tag}' contains invalid characters. "
                "Allowed: letters, numbers, hyphens, underscores, periods"
            )
    if len(set(tags)) != len(tags):
        raise typer.BadParameter("Duplicate tags are not allowed")


def _parse_duration(value: str) -> int:
    """Parse a duration string like '3600', '10m', or '24h' into seconds."""
    value = value.strip()
    if value.endswith("h") and value[:-1].isdigit():
        return int(value[:-1]) * 3600
    elif value.endswith("m") and value[:-1].isdigit():
        return int(value[:-1]) * 60
    elif value.isdigit():
        return int(value)
    else:
        raise typer.BadParameter(f"Invalid duration '{value}'. Use seconds, or suffix with 'm' or 'h' (e.g. 10m, 24h)")


def _submit_one(
    client: HavocClient, file_path: Path, mcu: str, workers: int | None = None, timeout: int | None = None,
    tags: list[str] | None = None,
) -> str:
    """Upload a firmware file and create an auto-mode analysis. Returns the analysis ID."""
    _validate_firmware_elf(file_path, mcu)

    metadata = client.upload_file(str(file_path), label=file_path.name)

    image = Image(
        name=file_path.name,
        image_format=ImageFormat(Elf=metadata.id),
        entry_address=0,
        dma_buffers=[],
        patches=[],
        symbols=[],
        handlers=[],
    )

    mcu_config: Mcu
    if mcu == "cortexm":
        mcu_config = Mcu(CortexMGeneric=CortexMGeneric())
    else:
        raise ValueError(f"Unsupported MCU type: {mcu}. Supported: cortexm")

    analysis = client.create_analysis(mcu_config, image, auto=True, worker_count=workers, timeout_secs=timeout, tags=tags)
    return analysis.analysis_id


@app.command()
def submit(
    path: Path = typer.Argument(..., help="Firmware ELF file"),
    mcu: str = typer.Option("cortexm", help="MCU type"),
    workers: int = typer.Option(None, help="Workers per analysis (default: server setting)"),
    timeout: str = typer.Option(None, help="Analysis timeout, e.g. 3600, 10m, 24h; 0 to disable (default: server setting)"),
    tags: list[str] = typer.Option([], "--tag", "-t", help="Tags for the analysis (repeatable)"),
):
    """Submit a firmware file for analysis."""
    if tags:
        _validate_tags(tags)

    client = _get_client()

    if not path.is_file():
        typer.echo(f"File not found: {path}", err=True)
        raise typer.Exit(1)

    timeout_secs = _parse_duration(timeout) if timeout is not None else None
    analysis_id = _submit_one(client, path, mcu, workers=workers, timeout=timeout_secs, tags=tags or None)
    typer.echo(f"{path.name} -> {analysis_id}")

    try:
        ds = client.get_dashboard_status()
        typer.echo(
            f"Queue: {ds['active_workers']}/{ds['worker_pool_size']} workers active, "
            f"{ds['queued_count']} queued"
        )
    except Exception:
        pass


def _format_stage(stage: str) -> str:
    """Return stage with a fixed-width format."""
    return stage.ljust(12)


def _format_time(timestamp: int) -> str:
    """Format unix timestamp as human-readable local time."""
    return time.strftime("%Y-%m-%d %H:%M", time.localtime(timestamp))


def _print_status_table(analyses: list, empty_msg: str = "No analyses found."):
    """Print a formatted table of analyses."""
    if not analyses:
        typer.echo(empty_msg)
        return

    # Header
    typer.echo(
        f"{'IMAGE':<30} {'STAGE':<14} {'CREATED':<18} {'ANALYSIS ID':<38} {'TAGS'}"
    )
    typer.echo("-" * 120)

    for a in analyses:
        image = a.get("image_name", a.get("file_id", "")[:8])
        stage = a.get("stage", "?")
        created = _format_time(a.get("created_at", 0))
        aid = a.get("analysis_id", "?")
        tags = ", ".join(a.get("tags") or [])
        typer.echo(f"{image:<30} {stage:<14} {created:<18} {aid:<38} {tags}")


_ACTIVE_STAGES = {"Fuzzing", "DryRun", "InferConfig", "Reconfigure", "Intervention", "Manual"}
_TERMINAL_STAGES = {"Completed", "Stopped", "Failed"}


@app.command()
def status(
    analysis_id: str = typer.Argument(None, help="Analysis ID (omit for all)"),
    watch: bool = typer.Option(False, "--watch", "-w", help="Poll and refresh every 5s"),
    all_: bool = typer.Option(False, "--all", "-a", help="Show all analyses (default: active + queued only)"),
    tag: list[str] = typer.Option([], "--tag", "-t", help="Filter by tag (repeatable)"),
):
    """Show analysis status. Without arguments, lists active and queued analyses."""
    client = _get_client()

    if analysis_id:
        # Single analysis detail
        try:
            resp = client.get_analysis_status(analysis_id)
            typer.echo(f"Analysis:  {analysis_id}")
            typer.echo(f"Stage:     {resp.stage}")
            typer.echo(f"Runs:      {len(resp.run_ids)}")
            if resp.error:
                typer.echo(f"Error:     {resp.error}")
            if resp.tags:
                typer.echo(f"Tags:      {', '.join(resp.tags)}")
            typer.echo(f"Created:   {_format_time(resp.created_at)}")
            typer.echo(f"Updated:   {_format_time(resp.updated_at)}")
        except Exception as e:
            typer.echo(f"Error: {e}", err=True)
            raise typer.Exit(1)
        return

    def _render():
        resp = client.get_analyses()
        # Server wraps in Ok/Err
        if isinstance(resp, dict) and "Ok" in resp:
            analyses = resp["Ok"]
        elif isinstance(resp, list):
            analyses = resp
        else:
            typer.echo(f"Error: {resp}", err=True)
            raise typer.Exit(1)

        # Filter by tags if requested
        if tag:
            tag_set = set(tag)
            analyses = [a for a in analyses if tag_set.intersection(a.get("tags") or [])]

        # Server info header
        try:
            ds = client.get_dashboard_status()
            timeout = ds.get("analysis_timeout_secs", 0)
            timeout_str = f"{timeout}s" if timeout > 0 else "disabled"
            typer.echo(
                f"Workers: {ds['active_workers']}/{ds['worker_pool_size']} "
                f"({ds['workers_per_analysis']}/analysis)  "
                f"Timeout: {timeout_str}"
            )
        except Exception:
            pass

        active = [a for a in analyses if a.get("stage") in _ACTIVE_STAGES and not a.get("queued")]
        queued = [a for a in analyses if a.get("queued")]
        completed = [a for a in analyses if a.get("stage") in _TERMINAL_STAGES]

        # Summary line
        parts = []
        if active:
            parts.append(f"{len(active)} active")
        if queued:
            parts.append(f"{len(queued)} queued")
        if completed:
            parts.append(f"{len(completed)} completed")
        typer.echo(f"{len(analyses)} analyses: {', '.join(parts) or 'none'}\n")

        # Which analyses to show in the table
        if all_:
            visible = analyses
        else:
            visible = active + queued

        # Sort: active first (newest first), then queued (oldest first)
        visible.sort(
            key=lambda a: (
                0 if a.get("stage") in _ACTIVE_STAGES else 1,
                -a.get("created_at", 0) if a.get("stage") in _ACTIVE_STAGES else a.get("created_at", 0),
            )
        )
        if tag:
            empty_msg = f"No analyses matching tag(s): {', '.join(tag)}"
        elif not all_:
            empty_msg = "No active or queued analyses."
        else:
            empty_msg = "No analyses found."

        if all_:
            _print_status_table(visible, empty_msg)
        else:
            _print_status_table(visible[:10], empty_msg)
            hidden = len(visible) - 10
            if hidden > 0:
                typer.echo(f"{'...':<30} {'':14} {'':18} +{hidden} more (--all)")

        if not all_ and completed:
            typer.echo(f"Hiding {len(completed)} completed/failed. See all: --all | Filter: --tag TAG")

    if watch:
        try:
            while True:
                typer.echo("\033[2J\033[H", nl=False)
                _render()
                typer.echo(f"\nRefreshing every 5s... (Ctrl+C to stop)")
                time.sleep(5)
        except KeyboardInterrupt:
            pass
    else:
        _render()


@app.command()
def stop(
    analysis_id: str = typer.Argument(..., help="Analysis ID to stop"),
):
    """Stop a running analysis."""
    client = _get_client()
    try:
        client.stop_analysis(analysis_id)
        typer.echo(f"Stopped analysis {analysis_id}")
    except Exception as e:
        typer.echo(f"Error: {e}", err=True)
        raise typer.Exit(1)



@app.command()
def timeout(
    seconds: int = typer.Argument(..., help="Timeout in seconds (0 to disable)"),
):
    """Set the analysis timeout."""
    client = _get_client()
    try:
        resp = client.update_settings(analysis_timeout_secs=seconds)
        val = resp.get("analysis_timeout_secs", seconds)
        typer.echo(f"Timeout set to {val}s" if val > 0 else "Timeout disabled")
    except Exception as e:
        typer.echo(f"Error: {e}", err=True)
        raise typer.Exit(1)


if __name__ == "__main__":
    app()
