"""Promptfoo provider that orchestrates inspect_evals' OSWorld task.

This is intentionally wrapper-shaped: Inspect owns the computer-use agent loop,
Docker sandbox, screenshots, tool calls, and OSWorld scorer. Promptfoo starts one
Inspect eval, dumps the Inspect `.eval` log to JSON, and exposes the final score.

Observed `inspect log dump` shape with Inspect 0.3.213:
  data["results"]["scores"][0]["metrics"]["accuracy"]["value"] -> aggregate pass rate
  data["samples"][0]["scores"][<scorer_name>]["value"] -> per-sample score
  data["samples"][0]["output"]["completion"] -> final assistant text when present
  data["stats"]["model_usage"][<model>] -> token counts
"""

from __future__ import annotations

import json
import os
import shlex
import shutil
import subprocess
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

DEFAULT_MODEL = "openai/gpt-5.5"
DEFAULT_TIMEOUT_SECONDS = 1800
DEFAULT_TASK = "inspect_evals/osworld_small"
OSWORLD_SCORER = "osworld_scorer"


def call_api(prompt: str, options: dict, context: dict) -> dict:
    """Run one OSWorld sample through inspect_evals and return Promptfoo output."""
    del prompt
    config = options.get("config") or {}
    vars_ = context.get("vars") or {}
    requested_sample_id = vars_.get("sample_id")
    app = vars_.get("app")
    if not requested_sample_id:
        return {
            "error": (
                "OSWorld sample_id is required. Use osworld_tests.py or set "
                "vars.sample_id."
            )
        }

    model = vars_.get("model") or config.get("defaultModel") or DEFAULT_MODEL
    timeout_seconds = _int_config(config.get("timeoutSeconds"), DEFAULT_TIMEOUT_SECONDS)
    base_path = _resolve_base_path(config)
    log_root = _resolve_log_root(config, base_path)
    log_dir = _new_log_dir(log_root, str(app or requested_sample_id))
    inspect_cmd = _inspect_command(config)
    if not inspect_cmd:
        return {
            "error": (
                "Inspect CLI command is empty. Set providers[0].config.inspectCommand "
                "or PROMPTFOO_OSWORLD_INSPECT_COMMAND."
            )
        }

    task = config.get("task") or DEFAULT_TASK
    task_parameters = _task_parameters(config.get("taskParameters"))

    eval_cmd = [
        *inspect_cmd,
        "eval",
        task,
        *task_parameters,
        "--model",
        str(model),
        "--log-dir",
        str(log_dir),
        "--sample-id",
        str(requested_sample_id),
    ]

    started = time.monotonic()
    try:
        eval_result = subprocess.run(
            eval_cmd,
            check=False,
            capture_output=True,
            text=True,
            timeout=timeout_seconds,
            cwd=str(base_path),
        )
    except FileNotFoundError:
        return {"error": _missing_inspect_message(inspect_cmd[0])}
    except subprocess.TimeoutExpired:
        return {
            "error": (
                f"Inspect OSWorld run timed out after {timeout_seconds}s. "
                "Increase providers[0].config.timeoutSeconds for real runs."
            ),
            "metadata": {
                "inspect_log_path": str(log_dir),
                "status": "error",
                "duration_seconds": round(time.monotonic() - started, 3),
            },
        }

    duration = time.monotonic() - started
    if eval_result.returncode != 0:
        return {
            "error": _humanize_inspect_failure(eval_result),
            "metadata": {
                "inspect_log_path": str(log_dir),
                "status": "error",
                "duration_seconds": round(duration, 3),
            },
        }

    eval_log = _find_eval_log(log_dir)
    if eval_log is None:
        return {
            "error": f"Inspect completed but no .eval log was found in {log_dir}.",
            "metadata": {"inspect_log_path": str(log_dir), "status": "error"},
        }

    dump_cmd = [*inspect_cmd, "log", "dump", str(eval_log)]
    try:
        dump_result = subprocess.run(
            dump_cmd,
            check=False,
            capture_output=True,
            text=True,
            timeout=300,
            cwd=str(base_path),
        )
    except FileNotFoundError:
        return {"error": _missing_inspect_message(inspect_cmd[0])}
    except subprocess.TimeoutExpired:
        return {
            "error": f"Timed out while dumping Inspect log {eval_log} to JSON.",
            "metadata": {"inspect_log_path": str(eval_log), "status": "error"},
        }

    if dump_result.returncode != 0:
        return {
            "error": _humanize_log_dump_failure(dump_result, eval_log),
            "metadata": {"inspect_log_path": str(eval_log), "status": "error"},
        }

    try:
        log_json = json.loads(dump_result.stdout)
    except json.JSONDecodeError as exc:
        return {
            "error": f"Could not parse Inspect log dump JSON for {eval_log}: {exc}",
            "metadata": {"inspect_log_path": str(eval_log), "status": "error"},
        }

    parsed = _parse_inspect_log(log_json)
    parsed_sample_id = parsed.get("sample_id")
    sample_id = parsed_sample_id or "unknown sample"
    if parsed_sample_id and sample_id != str(requested_sample_id):
        return {
            "error": (
                f"Inspect returned sample {sample_id}, but Promptfoo requested "
                f"{requested_sample_id}."
            ),
            "metadata": {
                "inspect_log_path": str(eval_log),
                "status": "error",
                "sample_id": sample_id,
                "requested_sample_id": str(requested_sample_id),
                "duration_seconds": round(duration, 3),
                "task": task,
                "app": str(app or "unknown"),
            },
        }

    score = parsed.get("score")
    status = "pass" if isinstance(score, (int, float)) and score >= 1.0 else "fail"
    metadata = {
        "inspect_log_path": str(eval_log),
        "score": score,
        "status": status,
        "sample_id": sample_id,
        "model": str(model),
        "num_messages": parsed.get("num_messages", 0),
        "duration_seconds": round(duration, 3),
        "task": task,
        "app": str(app or "unknown"),
        "requested_sample_id": str(requested_sample_id),
    }
    token_usage = parsed.get("token_usage")

    inspect_error = _inspect_log_error(parsed)
    if inspect_error:
        metadata["status"] = "error"
        metadata["inspect_status"] = parsed.get("inspect_status")
        metadata["inspect_error"] = inspect_error
        result: dict[str, Any] = {
            "error": (
                f"Inspect OSWorld run did not produce a score for sample {sample_id}: "
                f"{inspect_error}"
            ),
            "metadata": metadata,
        }
        if token_usage:
            result["tokenUsage"] = token_usage
        return result

    final_answer = (
        parsed.get("final_answer") or "(no final assistant text found in Inspect log)"
    )
    output = (
        f"Sample {sample_id} on app {app or 'unknown'}: "
        f"score={score if score is not None else 'unknown'} "
        f"status={status}\n\nFinal answer: {final_answer}"
    )

    result: dict[str, Any] = {"output": output, "metadata": metadata}
    if token_usage:
        result["tokenUsage"] = token_usage
    return result


def _inspect_command(config: dict) -> list[str]:
    env_command = os.environ.get("PROMPTFOO_OSWORLD_INSPECT_COMMAND")
    command = config.get("inspectCommand") or env_command or "inspect"
    if isinstance(command, list):
        return [str(part) for part in command]
    return shlex.split(str(command))


def _task_parameters(configured: Any) -> list[str]:
    if not isinstance(configured, dict):
        return []

    parameters: list[str] = []
    for key, value in configured.items():
        if value is None:
            continue
        parameters.extend(["-T", f"{key}={_format_task_parameter_value(value)}"])
    return parameters


def _format_task_parameter_value(value: Any) -> str:
    if isinstance(value, bool):
        return str(value).lower()
    return str(value)


def _resolve_base_path(config: dict) -> Path:
    configured = config.get("basePath")
    if configured:
        return Path(configured).expanduser().resolve()
    return Path(__file__).resolve().parent


def _resolve_log_root(config: dict, base_path: Path) -> Path:
    configured = config.get("logRoot")
    path = Path(configured).expanduser() if configured else base_path / "inspect_logs"
    if not path.is_absolute():
        path = base_path / path
    return path.resolve()


def _new_log_dir(log_root: Path, app: str) -> Path:
    safe_app = "".join(ch if ch.isalnum() or ch in ("-", "_") else "-" for ch in app)
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    path = log_root / f"{stamp}-{safe_app}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
    path.mkdir(parents=True, exist_ok=False)
    return path


def _find_eval_log(log_dir: Path) -> Path | None:
    logs = sorted(
        log_dir.rglob("*.eval"), key=lambda path: path.stat().st_mtime, reverse=True
    )
    return logs[0] if logs else None


def _parse_inspect_log(data: dict[str, Any]) -> dict[str, Any]:
    samples = data.get("samples") or []
    sample = samples[0] if samples else {}
    sample_score, score_error = _sample_score(sample) if sample else (None, None)
    score = None if score_error else sample_score
    return {
        "score": score,
        "score_error": score_error,
        "sample_id": sample.get("id") or sample.get("uuid"),
        "final_answer": _final_answer(sample),
        "num_messages": len(sample.get("messages") or []),
        "token_usage": _token_usage(data, sample),
        "inspect_status": data.get("status"),
        "sample_error": sample.get("error"),
    }


def _sample_score(sample: dict[str, Any]) -> tuple[float | None, str | None]:
    scores = sample.get("scores") or {}
    preferred_score = scores.get(OSWORLD_SCORER)
    if preferred_score is not None:
        numeric = _score_value(preferred_score)
        if numeric is not None:
            return numeric, None
        return (
            None,
            f"Inspect recorded {OSWORLD_SCORER}, but its value was not numeric.",
        )

    if scores:
        return (
            None,
            f"Inspect did not record {OSWORLD_SCORER} for the selected sample.",
        )
    return None, None


def _score_value(score: Any) -> float | None:
    if not isinstance(score, dict):
        return None
    return _coerce_score(score.get("value"))


def _coerce_score(value: Any) -> float | None:
    if isinstance(value, bool):
        return 1.0 if value else 0.0
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        normalized = value.strip().upper()
        if normalized == "C":
            return 1.0
        if normalized == "I":
            return 0.0
        try:
            return float(value)
        except ValueError:
            return None
    return None


def _final_answer(sample: dict[str, Any]) -> str | None:
    output = sample.get("output") or {}
    completion = output.get("completion")
    if isinstance(completion, str) and completion.strip():
        return completion.strip()

    for message in reversed(sample.get("messages") or []):
        if message.get("role") == "assistant":
            content = message.get("content")
            if isinstance(content, str) and content.strip():
                return content.strip()
    return None


def _token_usage(data: dict[str, Any], sample: dict[str, Any]) -> dict[str, int] | None:
    usage_sources = [
        sample.get("model_usage"),
        (data.get("stats") or {}).get("model_usage"),
    ]
    totals = {"prompt": 0, "completion": 0, "total": 0}
    found = False
    for usage_by_model in usage_sources:
        if not isinstance(usage_by_model, dict):
            continue
        for usage in usage_by_model.values():
            if not isinstance(usage, dict):
                continue
            totals["prompt"] += int(
                usage.get("input_tokens") or usage.get("prompt_tokens") or 0
            )
            totals["completion"] += int(
                usage.get("output_tokens") or usage.get("completion_tokens") or 0
            )
            totals["total"] += int(usage.get("total_tokens") or 0)
            found = True
        if found:
            break
    if found and totals["total"] == 0:
        totals["total"] = totals["prompt"] + totals["completion"]
    return totals if found else None


def _inspect_log_error(parsed: dict[str, Any]) -> str | None:
    score_error = parsed.get("score_error")
    if score_error:
        return str(score_error)

    sample_error = parsed.get("sample_error")
    if isinstance(sample_error, dict):
        message = sample_error.get("message") or sample_error.get("traceback")
        if message:
            return _summarize_inspect_error(str(message))
    elif sample_error:
        return _summarize_inspect_error(str(sample_error))

    if parsed.get("inspect_status") == "error":
        return "Inspect log status is error and no scorer result was recorded."

    if parsed.get("score") is None:
        sample_id = parsed.get("sample_id")
        if sample_id:
            return (
                f"Inspect completed sample {sample_id}, but no OSWorld scorer result "
                "was recorded."
            )
        return (
            "Inspect completed, but selected zero samples or recorded no scorer result. "
            "Check vars.sample_id."
        )

    return None


def _summarize_inspect_error(message: str) -> str:
    if "Failure executing command:" in message:
        return (
            "Inspect computer tool failed while executing a model-requested desktop "
            "command. See the Inspect log for the full traceback and command output."
        )

    if "image_original.png" in message:
        return (
            "OSWorld scorer could not read an expected image artifact. See the Inspect "
            "log for the full traceback."
        )

    if "'OSWorldDesktopEnv' object has no attribute 'vm_ip'" in message:
        return (
            "OSWorld scorer could not read VLC desktop state. See the Inspect log for "
            "the full traceback."
        )

    return "Inspect reported an unscored sample error. See the Inspect log for details."


def _int_config(value: Any, default: int) -> int:
    try:
        return int(value)
    except (TypeError, ValueError):
        return default


def _missing_inspect_message(command: str) -> str:
    if shutil.which(command) is None:
        return (
            f"Could not find Inspect CLI command '{command}'. Install prerequisites with "
            "`python -m pip install 'inspect-evals[osworld]'` plus the SDK for your selected model, or set "
            "providers[0].config.inspectCommand."
        )
    return f"Could not execute Inspect CLI command '{command}'."


def _humanize_inspect_failure(result: subprocess.CompletedProcess) -> str:
    tail = _tail(result.stderr) or _tail(result.stdout) or "(no output)"
    hint = ""
    lowered = tail.lower()
    if "specified dataset is empty" in lowered or "no samples" in lowered:
        hint = " Check vars.sample_id. Inspect selected no OSWorld samples for this id."
    elif "docker compose" in lowered or "compose" in lowered:
        hint = " Check that Docker Compose V2 is installed and available as `docker compose`."
    elif "docker" in lowered:
        hint = " Check that Docker is installed, running, and usable by your user."
    elif "requires optional dependencies" in lowered:
        hint = " Install the selected Inspect model provider SDK, such as `openai` or `anthropic`."
    elif "api_key" in lowered or "api key" in lowered or "authentication" in lowered:
        hint = " Check that the selected model provider API key is exported."
    elif "model" in lowered:
        hint = " Check providers[0].config.defaultModel or vars.model."
    return f"Inspect eval failed with exit code {result.returncode}.{hint}"


def _humanize_log_dump_failure(
    result: subprocess.CompletedProcess, eval_log: Path
) -> str:
    return f"Inspect log dump failed for {eval_log} with exit code {result.returncode}."


def _tail(text: Any, limit: int = 4000) -> str:
    if not text:
        return ""
    text = text.decode(errors="replace") if isinstance(text, bytes) else str(text)
    return text[-limit:]
