"""Recon CLI commands — scan, jobs, and pull.

Provides the ``sika recon`` command group with subcommands for
submitting scans, listing/viewing jobs, and downloading reports.
Supports batch scanning from a file with auto-tagging.
"""

from __future__ import annotations

import getpass
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import httpx
import typer
from rich.progress import (
    BarColumn,
    MofNCompleteColumn,
    Progress,
    SpinnerColumn,
    TextColumn,
)
from rich.table import Table

from sika import SikaClient
from sika._errors import QuotaExceededError
from sika.auth import CredentialFile
from sika.recon.models import Scan, ScanStatus
from sika_cli.errors import handle_errors
from sika_cli.output import get_console, get_error_console, print_json, status_style

# ---------------------------------------------------------------------------
# Typer groups
# ---------------------------------------------------------------------------

recon_app = typer.Typer(name="recon", help="Recon scanning commands.")
jobs_app = typer.Typer(name="jobs", help="Manage scan jobs.")
recon_app.add_typer(jobs_app)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_TERMINAL_STATUSES = {ScanStatus.completed, ScanStatus.failed}
_POLL_INTERVAL = 5.0
_BATCH_DELAY = 1.0


def _get_client(ctx: typer.Context) -> SikaClient:
    """Build a :class:`SikaClient` from global CLI options."""
    obj: dict[str, Any] = ctx.ensure_object(dict)
    return SikaClient(
        profile=obj.get("profile"),
        environment=obj.get("environment", "production"),
    )


def _scan_to_dict(scan: Any) -> dict[str, Any]:
    """Serialise a :class:`Scan` to a plain dict for JSON output."""
    result: dict[str, Any] = scan.model_dump(mode="json")
    return result


def _truncate_id(scan_id: str, length: int = 8) -> str:
    """Return the first *length* characters of a scan ID."""
    return scan_id[:length]


def _parse_domains_file(path: Path) -> list[str]:
    """Read domains from a file, skipping blank lines and ``#`` comments.

    Args:
        path: Path to a text file with one domain per line.

    Returns:
        List of non-empty domain strings.
    """
    lines = path.read_text(encoding="utf-8").splitlines()
    domains: list[str] = []
    for line in lines:
        stripped = line.strip()
        if stripped and not stripped.startswith("#"):
            domains.append(stripped)
    return domains


def _generate_batch_tag(profile: str | None) -> str:
    """Generate an auto-tag for batch scans: ``{user}-{DDMM-HHMM}``.

    Uses the email prefix from the credential file, falling back to
    the system username.

    Args:
        profile: Credential profile name (``None`` → ``"default"``).

    Returns:
        A short tag string like ``sam-1603-1430``.
    """
    cred_file = CredentialFile()
    email = cred_file.read_email(profile=profile or "default")
    user = email.split("@")[0] if email and "@" in email else getpass.getuser()

    now = datetime.now(UTC)
    return f"{user}-{now.strftime('%d%m-%H%M')}"


# ---------------------------------------------------------------------------
# sika recon scan <domain>
# ---------------------------------------------------------------------------


@recon_app.command()
@handle_errors
def scan(
    ctx: typer.Context,
    domain: str | None = typer.Argument(
        None,
        help="Target domain to scan.",
    ),
    attach: bool = typer.Option(
        False,
        "--attach",
        help="Wait for the scan to complete.",
    ),
    file: Path | None = typer.Option(
        None,
        "--file",
        "-f",
        help="File with domains to scan (one per line).",
    ),
    tag: str | None = typer.Option(
        None,
        "--tag",
        "-t",
        help="Tag to apply to scans for grouping.",
    ),
) -> None:
    """Submit a new recon scan.

    Provide a single DOMAIN argument, or use ``--file`` to scan
    multiple domains from a file (one per line).  Batch scans are
    automatically tagged for easy grouping.
    """
    # Validate: exactly one of domain or --file
    if domain and file:
        err_console = get_error_console(ctx)
        err_console.print(
            "Cannot use both DOMAIN argument and --file.", style="red"
        )
        raise typer.Exit(code=5) from None
    if not domain and not file:
        err_console = get_error_console(ctx)
        err_console.print(
            "Provide a DOMAIN argument or --file <path>.", style="red"
        )
        raise typer.Exit(code=5) from None

    if file:
        if attach:
            err_console = get_error_console(ctx)
            err_console.print(
                "Cannot use --attach with --file.", style="red"
            )
            raise typer.Exit(code=5) from None
        _scan_batch(ctx, file, tag)
    else:
        assert domain is not None
        _scan_single(ctx, domain, attach, tag)


def _scan_single(
    ctx: typer.Context,
    domain: str,
    attach: bool,
    tag: str | None,
) -> None:
    """Submit a single scan."""
    obj: dict[str, Any] = ctx.ensure_object(dict)
    json_mode = obj.get("json", False)
    quiet = obj.get("quiet", False)
    console = get_console(ctx)

    client = _get_client(ctx)
    tags = [tag] if tag else None
    result = client.recon.scans.create(domain, tags=tags)

    if quiet:
        console.print(result.scan_id)
        return

    if json_mode and not attach:
        print_json(_scan_to_dict(result))
        return

    if not attach:
        console.print(f"Scan submitted: {result.scan_id}")
        console.print(f"Status: {status_style(result.status)}")
        return

    # --attach: poll until terminal status
    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        console=console,
        transient=True,
    ) as progress:
        progress.add_task(
            description=f"Scanning {domain}...",
            total=None,
        )
        current = result
        while current.status not in _TERMINAL_STATUSES:
            time.sleep(_POLL_INTERVAL)
            current = client.recon.scans.get(current.scan_id)

    if json_mode:
        print_json(_scan_to_dict(current))
        return

    console.print(f"Scan {current.scan_id} {status_style(current.status)}")
    if current.status == ScanStatus.completed and current.summary:
        s = current.summary
        console.print(f"  Subdomains: {s.total_subdomains}")
        console.print(f"  Unique IPs: {s.unique_ips}")
        console.print(f"  Providers:  {s.providers_found}")
        console.print(f"  Leaked origins: {s.leaked_origins}")
        console.print(f"  Avg resilience: {s.avg_resilience_score:.1f}")
    elif current.status == ScanStatus.failed:
        err_console = get_error_console(ctx)
        err_console.print("Scan failed.", style="red")


def _scan_batch(
    ctx: typer.Context,
    file: Path,
    tag: str | None,
) -> None:
    """Submit scans for all domains in a file.

    Reads domains from *file* (one per line, skipping blanks and ``#``
    comments), submits each with a 1-second delay, and prints a summary
    table.  On :class:`~sika._errors.QuotaExceededError`, stops and
    reports partial results.
    """
    obj: dict[str, Any] = ctx.ensure_object(dict)
    json_mode = obj.get("json", False)
    quiet = obj.get("quiet", False)
    console = get_console(ctx)
    err_console = get_error_console(ctx)

    domains = _parse_domains_file(file)
    if not domains:
        if json_mode:
            print_json({"error": "No domains found in file.", "file": str(file)})
        else:
            console.print("No domains found in file.")
        return

    # Resolve tag: explicit --tag, or auto-generate
    profile = obj.get("profile")
    batch_tag = tag or _generate_batch_tag(profile)

    results: list[Scan] = []
    client = _get_client(ctx)
    quota_error = False

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(),
        MofNCompleteColumn(),
        console=console,
        transient=True,
    ) as progress:
        task = progress.add_task("Submitting scans...", total=len(domains))
        for i, domain in enumerate(domains):
            if i > 0:
                time.sleep(_BATCH_DELAY)
            progress.update(task, description=f"Submitting {domain}...")
            try:
                result = client.recon.scans.create(domain, tags=[batch_tag])
                results.append(result)
            except QuotaExceededError:
                quota_error = True
                break
            progress.advance(task)

    # Output results
    if quiet:
        for r in results:
            console.print(r.scan_id)
        if quota_error:
            raise typer.Exit(code=3) from None
        return

    if json_mode:
        payload: dict[str, Any] = {
            "scans": [_scan_to_dict(r) for r in results],
            "tag": batch_tag,
            "total_requested": len(domains),
            "total_submitted": len(results),
        }
        if quota_error:
            payload["error"] = "Quota exceeded"
        print_json(payload)
        if quota_error:
            raise typer.Exit(code=3) from None
        return

    # Summary table
    table = Table(title=f"Batch Scan — tag: {batch_tag}")
    table.add_column("Domain")
    table.add_column("Scan ID", style="dim")
    table.add_column("Status")

    for r in results:
        table.add_row(r.domain, _truncate_id(r.scan_id), status_style(r.status))

    console.print(table)
    console.print(
        f"\n{len(results)}/{len(domains)} scans submitted (tag: {batch_tag})"
    )

    if quota_error:
        err_console.print(
            "Stopped: scan quota exceeded.", style="red"
        )
        raise typer.Exit(code=3) from None


# ---------------------------------------------------------------------------
# sika recon jobs list
# ---------------------------------------------------------------------------


@jobs_app.command(name="list")
@handle_errors
def jobs_list(
    ctx: typer.Context,
    status: str | None = typer.Option(
        None,
        "--status",
        help="Filter by scan status.",
    ),
    limit: int = typer.Option(
        20,
        "--limit",
        help="Maximum items to display.",
    ),
) -> None:
    """List recon scan jobs."""
    obj: dict[str, Any] = ctx.ensure_object(dict)
    json_mode = obj.get("json", False)
    quiet = obj.get("quiet", False)
    console = get_console(ctx)

    client = _get_client(ctx)

    scan_status = ScanStatus(status) if status else None
    iterator = client.recon.scans.list(status=scan_status, limit=limit)

    scans = list(iterator)

    if not scans:
        if json_mode:
            print_json([])
        elif not quiet:
            console.print("No scans found.")
        return

    if quiet:
        for s in scans:
            console.print(s.scan_id)
        return

    if json_mode:
        print_json([_scan_to_dict(s) for s in scans])
        return

    table = Table(title="Recon Scans")
    table.add_column("ID", style="dim")
    table.add_column("Domain")
    table.add_column("Status")
    table.add_column("Created")

    for s in scans:
        table.add_row(
            _truncate_id(s.scan_id),
            s.domain,
            status_style(s.status),
            s.created_at.strftime("%Y-%m-%d %H:%M"),
        )

    console.print(table)


# ---------------------------------------------------------------------------
# sika recon jobs get <scan_id>
# ---------------------------------------------------------------------------


@jobs_app.command(name="get")
@handle_errors
def jobs_get(
    ctx: typer.Context,
    scan_id: str = typer.Argument(help="Scan ID to retrieve."),
) -> None:
    """Get details for a scan job."""
    obj: dict[str, Any] = ctx.ensure_object(dict)
    json_mode = obj.get("json", False)
    console = get_console(ctx)

    client = _get_client(ctx)
    result = client.recon.scans.get(scan_id)

    if json_mode:
        print_json(_scan_to_dict(result))
        return

    console.print(f"Scan:    {result.scan_id}")
    console.print(f"Domain:  {result.domain}")
    console.print(f"Status:  {status_style(result.status)}")
    console.print(f"Created: {result.created_at.strftime('%Y-%m-%d %H:%M')}")
    if result.updated_at:
        console.print(f"Updated: {result.updated_at.strftime('%Y-%m-%d %H:%M')}")
    if result.completed_at:
        console.print(
            f"Completed: {result.completed_at.strftime('%Y-%m-%d %H:%M')}"
        )

    if result.status == ScanStatus.completed and result.summary:
        s = result.summary
        console.print()
        console.print("[bold]Summary[/bold]")
        console.print(f"  Subdomains:     {s.total_subdomains}")
        console.print(f"  Unique IPs:     {s.unique_ips}")
        console.print(f"  Providers:      {s.providers_found}")
        console.print(f"  Leaked origins: {s.leaked_origins}")
        console.print(f"  Avg resilience: {s.avg_resilience_score:.1f}")


# ---------------------------------------------------------------------------
# sika recon pull <scan_id>
# ---------------------------------------------------------------------------


@recon_app.command()
@handle_errors
def pull(
    ctx: typer.Context,
    scan_id: str = typer.Argument(help="Scan ID to download report for."),
    format: str = typer.Option(
        "json",
        "--format",
        "-f",
        help="Report format: json, html, or csv.",
    ),
    output: Path | None = typer.Option(
        None,
        "--output",
        "-o",
        help="Output file path.",
    ),
) -> None:
    """Download a scan report."""
    obj: dict[str, Any] = ctx.ensure_object(dict)
    json_mode = obj.get("json", False)
    console = get_console(ctx)

    client = _get_client(ctx)

    # Check scan status first
    scan_result = client.recon.scans.get(scan_id)
    if scan_result.status != ScanStatus.completed:
        if json_mode:
            print_json(
                {
                    "error": f"Scan is still running (status: {scan_result.status})",
                    "scan_id": scan_id,
                    "status": str(scan_result.status),
                }
            )
        else:
            err_console = get_error_console(ctx)
            err_console.print(
                f"Scan is still running (status: {scan_result.status})",
                style="yellow",
            )
        raise typer.Exit(code=1)

    # Get presigned download URL
    report = client.recon.scans.report(scan_id, format=format)

    # Determine output path
    dest = output or Path("reports") / f"{scan_id}.{format}"
    dest.parent.mkdir(parents=True, exist_ok=True)

    # Download the report
    with httpx.stream("GET", report.url) as response:
        response.raise_for_status()
        total = int(response.headers.get("content-length", 0))
        with Progress(console=console, transient=True) as progress:
            task = progress.add_task("Downloading report...", total=total or None)
            with open(dest, "wb") as f:
                for chunk in response.iter_bytes(chunk_size=8192):
                    f.write(chunk)
                    progress.update(task, advance=len(chunk))

    size_bytes = dest.stat().st_size

    if json_mode:
        print_json({
            "path": str(dest),
            "format": format,
            "size_bytes": size_bytes,
        })
        return

    console.print(f"Report saved to {dest} ({size_bytes:,} bytes)")
