from __future__ import annotations

import os
import re
import shlex
import subprocess
import time
from pathlib import Path
from typing import Any, cast

import pytest

# Keep script coverage artifacts scoped to the active checkout under test.
os.environ["AGENT_HOME"] = str(Path(__file__).resolve().parents[1])

from .conftest import SCRIPT_RUN_RESULTS, ScriptRunResult, default_env, discover_scripts, load_script_specs, out_dir, repo_root

CRITICAL_REGRESSION_ENTRYPOINTS = {
    "scripts/check.sh",
    "scripts/check_plan_issue_worktree_cleanup.sh",
    "scripts/install-homebrew-nils-cli.sh",
    "scripts/setup-macos.sh",
    "skills/tools/notifications/desktop-notify/scripts/desktop-notify.sh",
    "skills/tools/notifications/desktop-notify/scripts/project-notify.sh",
}


def parse_shebang(script_path: Path) -> list[str]:
    try:
        with script_path.open("rb") as handle:
            first = handle.readline(4096)
    except OSError:
        return []
    if not first.startswith(b"#!"):
        return []

    line = first.decode("utf-8", errors="ignore").strip()

    tokens = shlex.split(line[2:].strip())
    if not tokens:
        return []

    if Path(tokens[0]).name == "env":
        tokens = tokens[1:]
        if tokens[:1] == ["-S"]:
            tokens = tokens[1:]

    return tokens


def write_logs(script: str, stdout: str, stderr: str) -> tuple[str, str]:
    logs_root = out_dir() / "logs"
    stdout_path = logs_root / f"{script}.stdout.txt"
    stderr_path = logs_root / f"{script}.stderr.txt"
    stdout_path.parent.mkdir(parents=True, exist_ok=True)
    stderr_path.parent.mkdir(parents=True, exist_ok=True)
    stdout_path.write_text(stdout, "utf-8")
    stderr_path.write_text(stderr, "utf-8")
    return (str(stdout_path), str(stderr_path))


def compile_optional_regex(pattern: str | None) -> re.Pattern[str] | None:
    if not pattern:
        return None
    return re.compile(pattern, re.MULTILINE)


def run_script(script: str, spec: dict[str, Any], repo: Path) -> ScriptRunResult:
    script_path = repo / script
    if not script_path.exists():
        raise FileNotFoundError(script)

    args_value = spec.get("args", ["--help"])
    if not isinstance(args_value, list):
        raise TypeError(f"spec.args must be a list of strings: {script}")
    args_list = cast(list[object], args_value)
    if not all(isinstance(item, str) for item in args_list):
        raise TypeError(f"spec.args must be a list of strings: {script}")
    args = cast(list[str], args_list)

    timeout_sec = spec.get("timeout_sec", 5)
    if not isinstance(timeout_sec, (int, float)):
        raise TypeError(f"spec.timeout_sec must be a number: {script}")

    env = default_env(repo)
    extra_env = spec.get("env", {})
    if extra_env:
        if not isinstance(extra_env, dict):
            raise TypeError(f"spec.env must be a JSON object: {script}")
        extra_env_dict = cast(dict[object, object], extra_env)
        for key, value in extra_env_dict.items():
            if value is None:
                env.pop(str(key), None)
            else:
                env[str(key)] = str(value)

    shebang = parse_shebang(script_path)
    if shebang:
        argv = shebang + [str(script_path)] + list(args)
    else:
        if not os.access(script_path, os.X_OK):
            raise ValueError(f"missing shebang: {script}")
        argv = [str(script_path)] + list(args)

    expect = spec.get("expect", {})
    if expect and not isinstance(expect, dict):
        raise TypeError(f"spec.expect must be a JSON object: {script}")

    expect_dict: dict[object, object] = {}
    if isinstance(expect, dict):
        expect_dict = cast(dict[object, object], expect)

    exit_codes_value = expect_dict.get("exit_codes", [0])
    if not isinstance(exit_codes_value, list):
        raise TypeError(f"expect.exit_codes must be a list of ints: {script}")
    exit_codes_list = cast(list[object], exit_codes_value)
    if not all(isinstance(code, int) for code in exit_codes_list):
        raise TypeError(f"expect.exit_codes must be a list of ints: {script}")
    exit_codes = cast(list[int], exit_codes_list)

    stdout_pattern = expect_dict.get("stdout_regex")
    stderr_pattern = expect_dict.get("stderr_regex")
    stdout_re = compile_optional_regex(stdout_pattern if isinstance(stdout_pattern, str) else None)
    stderr_re = compile_optional_regex(stderr_pattern if isinstance(stderr_pattern, str) else None)

    start = time.monotonic()
    try:
        completed = subprocess.run(
            argv,
            cwd=str(repo),
            env=env,
            text=True,
            capture_output=True,
            timeout=float(timeout_sec),
        )
        duration_ms = int((time.monotonic() - start) * 1000)
        stdout = completed.stdout
        stderr = completed.stderr
        stdout_path, stderr_path = write_logs(script, stdout, stderr)

        ok = completed.returncode in exit_codes
        note_parts: list[str] = []
        if completed.returncode not in exit_codes:
            note_parts.append(f"exit={completed.returncode} expected={exit_codes}")
        if stdout_re and not stdout_re.search(stdout):
            ok = False
            note_parts.append("stdout_regex_mismatch")
        if stderr_re and not stderr_re.search(stderr):
            ok = False
            note_parts.append("stderr_regex_mismatch")

        status = "pass" if ok else "fail"
        note = "; ".join(note_parts) if note_parts else None
        return ScriptRunResult(
            script=script,
            argv=argv,
            exit_code=completed.returncode,
            duration_ms=duration_ms,
            stdout_path=stdout_path,
            stderr_path=stderr_path,
            status=status,
            note=note,
        )
    except subprocess.TimeoutExpired:
        duration_ms = int((time.monotonic() - start) * 1000)
        stdout_path, stderr_path = write_logs(script, "", "")
        return ScriptRunResult(
            script=script,
            argv=argv,
            exit_code=124,
            duration_ms=duration_ms,
            stdout_path=stdout_path,
            stderr_path=stderr_path,
            status="fail",
            note=f"timeout after {timeout_sec}s",
        )


@pytest.mark.script_regression
def test_script_regression_covers_critical_entrypoints():
    discovered = set(discover_scripts())
    missing = sorted(CRITICAL_REGRESSION_ENTRYPOINTS - discovered)
    assert not missing, (
        "missing critical script entrypoints from regression discovery:\n"
        + "\n".join(f"- {script}" for script in missing)
    )


@pytest.mark.script_regression
@pytest.mark.parametrize("script", discover_scripts())
def test_script_regression(script: str):
    repo = repo_root()
    specs = load_script_specs(repo / "tests" / "script_specs")
    spec = specs.get(script, {})

    result = run_script(script, spec, repo)
    SCRIPT_RUN_RESULTS.append(result)

    assert result.status == "pass", (
        f"script regression failed: {script} (exit={result.exit_code})\n"
        f"argv: {' '.join(result.argv)}\n"
        f"stdout: {result.stdout_path}\n"
        f"stderr: {result.stderr_path}\n"
        f"note: {result.note or 'None'}"
    )
