"""Scan bootstrap shared by the CLI entry point and the TUI setup flow.

Target resolution, run preparation, model preflight, and start-of-run
telemetry live here so ``strix.interface.main`` (the CLI) and
``strix.interface.tui.runtime`` (interactive setup) depend on one module
instead of each other. Everything raises ordinary exceptions; rendering
errors and exiting the process is the caller's job.
"""

from __future__ import annotations

import asyncio
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from strix.config import Settings, codex, load_settings
from strix.core.paths import run_dir_for
from strix.interface.utils import (
    assign_workspace_subdirs,
    clone_repository,
    collect_local_sources,
    dedupe_local_targets,
    derive_local_base_name,
    generate_run_name,
    infer_target_type,
    is_whitebox_scan,
    read_target_list_file,
    resolve_diff_scope_context,
    rewrite_localhost_targets,
    stage_api_specs,
    write_fetched_collection,
)
from strix.telemetry import posthog, scarf
from strix.utils.api_spec import (
    SpecParseError,
    fetch_postman_collection,
    fetch_postman_environment,
    load_spec,
    spec_base_urls,
    spec_title,
)


if TYPE_CHECKING:
    import argparse

logger = logging.getLogger(__name__)

HOST_GATEWAY_HOSTNAME = "host.docker.internal"


class ModelConnectionError(RuntimeError):
    """An ordinary model preflight failure, annotated with its model route."""

    def __init__(self, model_name: str, cause: BaseException) -> None:
        super().__init__(str(cause))
        self.model_name = model_name


async def preflight_model_connection(
    model_name: str,
    *,
    settings: Settings | None = None,
) -> None:
    """Verify the configured model route before starting a scan."""
    from agents.models.interface import ModelTracing

    from strix.config.models import StrixProvider, configure_sdk_model_defaults
    from strix.core.inputs import make_model_settings

    resolved_settings = load_settings() if settings is None else settings
    configure_sdk_model_defaults(resolved_settings)
    model = StrixProvider().get_model(model_name)
    request_settings = make_model_settings(
        None,
        model_name=model_name,
        request_timeout=resolved_settings.llm.timeout,
        prompt_cache=False,
        extra_headers=resolved_settings.llm.extra_headers,
        has_tools=False,
    )
    await asyncio.wait_for(
        model.get_response(
            system_instructions="You are a helpful assistant.",
            input="Reply with just 'OK'.",
            model_settings=request_settings,
            tools=[],
            output_schema=None,
            handoffs=[],
            tracing=ModelTracing.DISABLED,
            previous_response_id=None,
            conversation_id=None,
            prompt=None,
        ),
        timeout=resolved_settings.llm.timeout,
    )


def build_targets_info(args: argparse.Namespace) -> None:
    """Populate ``args.targets_info`` from target/target-list inputs.

    Raises :class:`ValueError` with a user-facing message on any bad input so
    callers can surface it via ``parser.error`` (CLI) or a console panel (home
    page).
    """
    args.targets_info = []
    targets = list(args.target or [])
    for target_list_path in args.target_list or []:
        targets.extend(read_target_list_file(target_list_path))

    for target in targets:
        try:
            target_type, target_dict = infer_target_type(target)
        except ValueError as e:
            raise ValueError(f"Invalid target '{target}': {e}") from None

        if target_type == "local_code":
            display_target = target_dict.get("target_path", target)
        else:
            display_target = target

        if target_type == "api_spec":
            _resolve_api_spec(target, target_dict)

        args.targets_info.append(
            {"type": target_type, "details": target_dict, "original": display_target}
        )

    args.targets_info = dedupe_local_targets(args.targets_info)

    assign_workspace_subdirs(args.targets_info)
    rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)


def _resolve_api_spec(target: str, details: dict[str, Any]) -> None:
    """Read the spec up front so bad input fails before the run starts.

    Records the declared base URLs (the only thing scope authorization can take
    from a spec) and, for a ``postman://`` target, downloads the collection to a
    local file so the sandbox never needs the Postman API key.
    """
    try:
        if details.get("source") == "postman_api":
            collection_uid = str(details["collection_uid"])
            api_key = load_settings().integrations.postman_api_key or ""
            raw = fetch_postman_collection(collection_uid, api_key)
            environment_uid = str(details.get("environment_uid") or "")
            extra_variables = (
                fetch_postman_environment(environment_uid, api_key) if environment_uid else None
            )
            details["target_spec"] = write_fetched_collection(raw, collection_uid)
        else:
            raw = load_spec(str(details["target_spec"]))
            extra_variables = None
        base_urls = spec_base_urls(raw, extra_variables=extra_variables)
    except SpecParseError as exc:
        raise ValueError(f"Invalid API spec '{target}': {exc}") from None

    details["spec_title"] = spec_title(raw)
    details["base_urls"] = base_urls


def prepare_run(args: argparse.Namespace) -> None:
    """Resolve the run name, clone repos, compute diff-scope, and persist state.

    Shared by the CLI startup path and the interactive TUI setup phase (once the
    user has supplied a target via ``/target``). Mutates *args* in place and
    raises :class:`ValueError` on any preparation failure.
    """
    args.run_name = args.resume or generate_run_name(args.targets_info)

    if args.resume:
        return

    for target_info in args.targets_info:
        if target_info["type"] == "repository":
            repo_url = target_info["details"]["target_repo"]
            dest_name = target_info["details"].get("workspace_subdir")
            cloned_path = clone_repository(repo_url, args.run_name, dest_name)
            target_info["details"]["cloned_repo_path"] = cloned_path

    args.local_sources = collect_local_sources(args.targets_info)
    args.local_sources.extend(stage_api_specs(args.targets_info, args.run_name))
    diff_scope = resolve_diff_scope_context(
        local_sources=args.local_sources,
        scope_mode=args.scope_mode,
        diff_base=args.diff_base,
        non_interactive=args.non_interactive,
    )
    args.diff_scope = diff_scope.metadata
    if diff_scope.instruction_block:
        if args.instruction:
            args.instruction = f"{diff_scope.instruction_block}\n\n{args.instruction}"
        else:
            args.instruction = diff_scope.instruction_block

    attach_workspace_mount(args)
    _persist_run_record(args)


def attach_workspace_mount(args: argparse.Namespace) -> None:
    """Expose ``args.workspace_mount`` to the sandbox without making it a target.

    A workspace mount is a directory the agent works in, not something to test:
    it stays out of ``targets_info``, so it carries no authorized scope, and it
    is attached after diff-scope resolution so it contributes no diff context.
    The instruction is the only source of truth for what to do with it.
    """
    mount = getattr(args, "workspace_mount", None)
    if not mount:
        return
    args.workspace_subdir = derive_local_base_name(mount)
    local_sources = list(getattr(args, "local_sources", None) or [])
    local_sources.append(
        {
            "source_path": mount,
            "workspace_subdir": args.workspace_subdir,
            "protect_metadata": True,
        }
    )
    args.local_sources = local_sources


def telemetry_start(args: argparse.Namespace) -> None:
    model = load_settings().llm.model
    kwargs = {
        "model": model,
        "auth_mode": codex.auth_mode(model),
        "scan_mode": args.scan_mode,
        "is_whitebox": is_whitebox_scan(args.targets_info),
        "interactive": not args.non_interactive,
        "has_instructions": bool(args.instruction),
    }
    posthog.start(**kwargs)
    scarf.start(**kwargs)


def _persist_run_record(args: argparse.Namespace) -> None:
    from strix.report.writer import write_run_record

    run_dir = run_dir_for(args.run_name)
    run_dir.mkdir(parents=True, exist_ok=True)
    run_record = {
        "run_id": args.run_name,
        "run_name": args.run_name,
        "status": "running",
        "start_time": datetime.now(UTC).isoformat(),
        "end_time": None,
        "auth_mode": codex.auth_mode(load_settings().llm.model),
        "targets_info": args.targets_info,
        "scan_mode": args.scan_mode,
        "instruction": args.instruction,
        # Kept apart from instruction, which carries the diff-scope preamble: the
        # transcript replays this as the user's opening message.
        "user_instruction": getattr(args, "user_instruction", None),
        "non_interactive": args.non_interactive,
        "local_sources": getattr(args, "local_sources", []),
        # Persisted so --resume places the same workspace files again.
        "workspace_files": getattr(args, "workspace_files", []),
        # Persisted so --resume can remount the workspace: it is not a target,
        # so it cannot be rebuilt from targets_info.
        "workspace_mount": getattr(args, "workspace_mount", None),
        "diff_scope": getattr(args, "diff_scope", {"active": False}),
        "scope_mode": args.scope_mode,
        "diff_base": args.diff_base,
    }
    write_run_record(run_dir, run_record)
