from __future__ import annotations

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

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_SMOKE_RUN_RESULTS, ScriptRunResult, default_smoke_env, load_script_specs, out_dir_smoke, repo_root

CRITICAL_SMOKE_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",
}

REMOVED_SMOKE_ENTRYPOINTS = {
    "skills/tools/notifications/desktop-notify/scripts/codex-notify.sh",
}


def parse_shebang(script_path: Path) -> list[str]:
    first = script_path.read_text("utf-8", errors="ignore").splitlines()[:1]
    if not first:
        return []
    line = first[0].strip()
    if not line.startswith("#!"):
        return []

    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, case: str, stdout: str, stderr: str) -> tuple[str, str]:
    logs_root = out_dir_smoke() / "logs"
    suffix = f".{case}" if case else ""
    stdout_path = logs_root / f"{script}{suffix}.stdout.txt"
    stderr_path = logs_root / f"{script}{suffix}.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_smoke_script(
    script: str,
    case: str,
    spec: dict[str, Any],
    repo: Path,
    *,
    cwd: Path | None = None,
) -> ScriptRunResult:
    args = spec.get("args", [])
    if not isinstance(args, list) or not all(isinstance(x, str) for x in args):
        raise TypeError(f"spec.args must be a list of strings: {script} ({case})")

    script_path = repo / script
    command = spec.get("command")
    if command is not None:
        if not isinstance(command, list) or not command or not all(isinstance(x, str) for x in command):
            raise TypeError(f"spec.command must be a non-empty list of strings: {script} ({case})")
        if args:
            raise TypeError(f"spec.args must be empty when spec.command is set: {script} ({case})")
    else:
        if not script_path.exists():
            raise FileNotFoundError(script)

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

    env = default_smoke_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} ({case})")
        for key, value in extra_env.items():
            if value is None:
                env.pop(str(key), None)
            else:
                env[str(key)] = str(value)

    if command is not None:
        argv = list(command)
    else:
        shebang = parse_shebang(script_path)
        if not shebang:
            raise ValueError(f"missing shebang: {script}")
        argv = shebang + [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} ({case})")

    exit_codes = expect.get("exit_codes", [0])
    if not isinstance(exit_codes, list) or not all(isinstance(x, int) for x in exit_codes):
        raise TypeError(f"expect.exit_codes must be a list of ints: {script} ({case})")

    stdout_re = compile_optional_regex(expect.get("stdout_regex"))
    stderr_re = compile_optional_regex(expect.get("stderr_regex"))

    start = time.monotonic()
    try:
        completed = subprocess.run(
            argv,
            cwd=str(cwd or 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, case, 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,
            case=case,
        )
    except subprocess.TimeoutExpired:
        duration_ms = int((time.monotonic() - start) * 1000)
        stdout_path, stderr_path = write_logs(script, case, "", "")
        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",
            case=case,
        )


def discover_smoke_cases() -> list[tuple[str, str, dict[str, Any]]]:
    repo = repo_root()
    specs = load_script_specs(repo / "tests" / "script_specs")
    discovered: list[tuple[str, str, dict[str, Any]]] = []

    for script, spec in sorted(specs.items()):
        smoke = spec.get("smoke")
        if not smoke:
            continue

        smoke_cases: list[Any]
        if isinstance(smoke, list):
            smoke_cases = smoke
        elif isinstance(smoke, dict) and isinstance(smoke.get("cases"), list):
            smoke_cases = smoke["cases"]
        else:
            raise TypeError(f"spec.smoke must be a list (or {{cases:[...]}}): {script}")

        for idx, case in enumerate(smoke_cases, start=1):
            if not isinstance(case, dict):
                raise TypeError(f"smoke case must be a JSON object: {script} (case {idx})")
            name = case.get("name", f"case-{idx}")
            if not isinstance(name, str) or not name.strip():
                raise TypeError(f"smoke case name must be a non-empty string: {script} (case {idx})")
            discovered.append((script, name.strip(), case))

    return sorted(discovered, key=lambda x: (x[0], x[1]))


@pytest.mark.script_smoke
def test_script_smoke_critical_entrypoints_have_explicit_specs():
    repo = repo_root()
    specs = load_script_specs(repo / "tests" / "script_specs")
    smoke_defined = {script for script, spec in specs.items() if spec.get("smoke")}

    missing = sorted(CRITICAL_SMOKE_ENTRYPOINTS - smoke_defined)
    assert not missing, (
        "missing explicit smoke specs for critical entrypoints:\n"
        + "\n".join(f"- {script}" for script in missing)
    )

    stale = sorted(REMOVED_SMOKE_ENTRYPOINTS & smoke_defined)
    assert not stale, (
        "stale smoke specs should be removed after pruning:\n"
        + "\n".join(f"- {script}" for script in stale)
    )


@pytest.mark.script_smoke
@pytest.mark.parametrize(("script", "case", "spec"), discover_smoke_cases())
def test_script_smoke_spec(script: str, case: str, spec: dict[str, Any]):
    repo = repo_root()

    result = run_smoke_script(script, case, spec, repo)
    SCRIPT_SMOKE_RUN_RESULTS.append(result)

    artifacts = spec.get("artifacts", [])
    if artifacts:
        if not isinstance(artifacts, list) or not all(isinstance(x, str) for x in artifacts):
            raise TypeError(f"spec.artifacts must be a list of strings: {script} ({case})")
        for rel in artifacts:
            path = repo / rel
            assert path.exists(), f"missing artifact: {rel} (from {script} {case})"

    assert result.status == "pass", (
        f"script smoke failed: {script} ({case}) (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'}"
    )


@pytest.mark.script_smoke
def test_script_smoke_fixture_staged_context(tmp_path: Path):
    work_tree = tmp_path / "repo"
    work_tree.mkdir(parents=True, exist_ok=True)

    def run(cmd: list[str]) -> None:
        subprocess.run(cmd, cwd=str(work_tree), check=True, text=True, capture_output=True)

    run(["git", "init"])
    run(["git", "config", "user.email", "fixture@example.com"])
    run(["git", "config", "user.name", "Fixture User"])

    tracked = work_tree / "hello.txt"
    tracked.write_text("one\n", "utf-8")
    run(["git", "add", "hello.txt"])
    run(["git", "commit", "-m", "init"])

    tracked.write_text("two\n", "utf-8")
    run(["git", "add", "hello.txt"])

    repo = repo_root()
    script = "semantic-commit"
    spec: dict[str, Any] = {
        "command": ["semantic-commit", "staged-context"],
        "timeout_sec": 10,
        "env": {"AGENT_HOME": None},
        "expect": {
            "exit_codes": [0],
            "stdout_regex": r"(?s)===== commit-context\.json =====.*hello\.txt.*===== staged\.patch =====.*diff --git a/hello\.txt b/hello\.txt",
        },
    }

    result = run_smoke_script(script, "staged-context", spec, repo, cwd=work_tree)
    SCRIPT_SMOKE_RUN_RESULTS.append(result)

    assert result.status == "pass", (
        f"script smoke (fixture) 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'}"
    )


@pytest.mark.script_smoke
def test_script_smoke_fixture_semantic_commit_staged_context_cleans_tmp(tmp_path: Path):
    work_tree = tmp_path / "repo"
    work_tree.mkdir(parents=True, exist_ok=True)

    def run(cmd: list[str]) -> None:
        subprocess.run(cmd, cwd=str(work_tree), check=True, text=True, capture_output=True)

    run(["git", "init"])
    run(["git", "config", "user.email", "fixture@example.com"])
    run(["git", "config", "user.name", "Fixture User"])

    tracked = work_tree / "hello.txt"
    tracked.write_text("one\n", "utf-8")
    run(["git", "add", "hello.txt"])
    run(["git", "commit", "-m", "init"])

    tracked.write_text("two\n", "utf-8")
    run(["git", "add", "hello.txt"])

    tmpdir = tmp_path / "tmpdir"
    tmpdir.mkdir(parents=True, exist_ok=True)

    repo = repo_root()
    script = "semantic-commit"
    spec: dict[str, Any] = {
        "command": ["semantic-commit", "staged-context"],
        "timeout_sec": 10,
        "env": {
            "AGENT_HOME": None,
            "TMPDIR": str(tmpdir),
        },
        "expect": {
            "exit_codes": [0],
            "stdout_regex": r"(?s)===== commit-context\.json =====.*hello\.txt.*===== staged\.patch =====.*diff --git a/hello\.txt b/hello\.txt",
        },
    }

    result = run_smoke_script(script, "staged-context-tmp-cleanup", spec, repo, cwd=work_tree)
    SCRIPT_SMOKE_RUN_RESULTS.append(result)

    assert result.status == "pass", (
        f"script smoke (fixture) 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'}"
    )

    leftovers = sorted(p.name for p in tmpdir.iterdir())
    assert not leftovers, f"unexpected TMPDIR leftovers from semantic-commit staged-context: {leftovers}"


@pytest.mark.script_smoke
def test_setup_macos_links_codex_agents_to_global_policy_source(tmp_path: Path):
    repo = repo_root()
    source_repo = tmp_path / "source-agent-kit"
    source_repo.mkdir(parents=True)

    def run_git(cmd: list[str], cwd: Path) -> None:
        subprocess.run(["git", *cmd], cwd=str(cwd), check=True, text=True, capture_output=True)

    run_git(["init"], source_repo)
    run_git(["config", "user.email", "fixture@example.com"], source_repo)
    run_git(["config", "user.name", "Fixture User"], source_repo)
    (source_repo / "AGENTS.md").write_text("# LOCAL AGENTS\n", "utf-8")
    (source_repo / "CODEX_AGENTS.md").write_text("# GLOBAL AGENTS\n", "utf-8")
    run_git(["add", "-f", "AGENTS.md", "CODEX_AGENTS.md"], source_repo)
    run_git(["commit", "-m", "init"], source_repo)

    physical_agent_home = tmp_path / "physical-agent-kit"
    run_git(["clone", str(source_repo), str(physical_agent_home)], tmp_path)

    agent_home = tmp_path / ".agents"
    agent_home.symlink_to(physical_agent_home, target_is_directory=True)
    codex_home = tmp_path / ".codex"
    codex_home.mkdir()
    codex_agents = codex_home / "AGENTS.md"
    codex_agents.symlink_to(physical_agent_home / "AGENTS.md")

    env = default_smoke_env(repo)
    env.update(
        {
            "AGENT_KIT_SETUP_ASSUME_MACOS": "true",
            "CODEX_BREW_STUB_MODE_ENABLED": "true",
            "CODEX_BREW_STUB_LOG_DIR": str(tmp_path / "brew-log"),
            "CODEX_BREW_STUB_STATE_DIR": str(tmp_path / "brew-state"),
            "HOME": str(tmp_path / "home"),
        }
    )
    (tmp_path / "home").mkdir()

    completed = subprocess.run(
        [
            str(repo / "scripts" / "setup-macos.sh"),
            "--profile",
            "core",
            "--repo-url",
            str(source_repo),
            "--agent-home",
            str(agent_home),
            "--codex-home",
            str(codex_home),
            "--skip-brew-update",
            "--skip-command-verify",
            "--skip-hooks-sync",
        ],
        cwd=str(repo),
        env=env,
        text=True,
        capture_output=True,
        timeout=30,
    )

    assert completed.returncode == 0, completed.stderr
    assert codex_agents.is_symlink()
    assert os.readlink(codex_agents) == str(agent_home / "CODEX_AGENTS.md")
    assert (agent_home / "AGENTS.md").is_file()
    assert not (agent_home / "AGENTS.md").is_symlink()
    assert (agent_home / "AGENTS.md").read_text("utf-8") == "# LOCAL AGENTS\n"
    assert codex_agents.read_text("utf-8") == "# GLOBAL AGENTS\n"


@pytest.mark.script_smoke
def test_install_sh_standalone_downloads_setup_script(tmp_path: Path):
    repo = repo_root()
    standalone = tmp_path / "install.sh"
    standalone.write_text((repo / "install.sh").read_text("utf-8"), "utf-8")
    standalone.chmod(0o755)

    env = default_smoke_env(repo)
    env["PATH"] = os.environ.get("PATH", "")
    env["AGENT_KIT_SETUP_URL"] = (repo / "scripts" / "setup-macos.sh").as_uri()

    completed = subprocess.run(
        [str(standalone), "--help"],
        cwd=str(tmp_path),
        env=env,
        text=True,
        capture_output=True,
        timeout=10,
    )

    assert completed.returncode == 0, completed.stderr
    assert "Usage: setup-macos.sh" in completed.stdout
