"""Tests for scripts.helpers.build.main."""

from __future__ import annotations

import json
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING, Any

import pytest

if TYPE_CHECKING:
    from scripts.helpers.build.argv import BuildOptions

from scripts.helpers.build import main as build_main
from scripts.helpers.build.main import _run_shell, main, run_build, run_test
from tests.helpers.cli_runner import run_cli_main
from tests.regression.scripts.helpers.build.conftest import (
    SubprocessRunCapture,
    build_options,
    fake_build_bash,
    patch_subprocess_run,
    patch_uv_in_path,
)


def test_run_test_without_test_map_path_returns_1(
    repo_root: Path,
    with_uv: None,
    caplog: pytest.LogCaptureFixture,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    del repo_root
    monkeypatch.delenv("MSMODELING_TEST_MAP_PATH", raising=False)
    with caplog.at_level("ERROR"):
        assert run_test(build_options(is_test=True)) == 1
    assert "test_map_path" in caplog.text


def test_run_test_delegates_env_and_tee(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    map_file = repo_root / "map.json"
    map_file.write_text("{}", encoding="utf-8")

    options = build_options(
        is_test=True,
        extras={
            "test_map_path": str(map_file),
            "base_branch": "develop",
            "offline": "1",
            "weights_prune": "1",
        },
    )
    assert run_test(options) == 0
    assert len(subprocess_capture.merged_output_calls) == 1
    call = subprocess_capture.merged_output_calls[0]
    assert call["cmd"] == ["bash", str(repo_root / "scripts" / "run_ci_gate.sh")]
    assert call["env"]["MSMODELING_TEST_MAP_PATH"] == str(map_file)
    assert call["env"]["MSMODELING_TEST_BASE_BRANCH"] == "develop"
    assert call["env"]["MSMODELING_OFFLINE"] == "1"
    assert call["env"]["MSMODELING_TEST_WEIGHTS_PRUNE"] == "1"
    log_path = repo_root / "artifacts" / "test-reports" / "ci_gate.log"
    assert log_path.is_file()
    assert log_path.read_text(encoding="utf-8") == "gate output\n"


def test_run_test_uses_env_test_map_path(
    repo_root: Path,
    monkeypatch: pytest.MonkeyPatch,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    map_file = repo_root / "env_map.json"
    map_file.write_text("{}", encoding="utf-8")
    monkeypatch.setenv("MSMODELING_TEST_MAP_PATH", str(map_file))

    assert run_test(build_options(is_test=True)) == 0
    call = subprocess_capture.merged_output_calls[0]
    assert call["env"]["MSMODELING_TEST_MAP_PATH"] == str(map_file)


def test_run_test_propagates_subprocess_exit_code(
    repo_root: Path,
    monkeypatch: pytest.MonkeyPatch,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    map_file = repo_root / "map.json"
    map_file.write_text("{}", encoding="utf-8")

    def fail_gate(_cmd: list[str], **_kwargs: Any) -> int:
        return 17

    subprocess_capture.on_merged_output = fail_gate

    options = build_options(is_test=True, extras={"test_map_path": str(map_file)})
    assert run_test(options) == 17
    summary = json.loads(
        (repo_root / "artifacts" / "test-reports" / "gate-summary.json").read_text(encoding="utf-8"),
    )
    assert summary["exit_code"] == 17
    assert summary["test_map_path"] == str(map_file)


def test_run_build_delegates_to_build_sh(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    subprocess_capture.on_bash = fake_build_bash(
        repo_root,
        wheel_name="msmodeling-3.2.1-py3-none-any.whl",
    )

    assert run_build(build_options(version="3.2.1", version_explicit=True)) == 0
    call = subprocess_capture.shell_calls[0]
    assert call["cmd"] == ["bash", str(repo_root / "scripts" / "build.sh")]
    assert call["kwargs"]["env"]["MSMODELING_WHEEL_OUTPUT_DIR"] == str(repo_root / "artifacts")
    manifest = json.loads((repo_root / "artifacts" / "build-manifest.json").read_text(encoding="utf-8"))
    assert manifest["version"] == "3.2.1"
    assert manifest["pyproject_version"] == "0.2.0"
    assert manifest["version_explicit"] is True
    assert manifest["wheel_path"].endswith("msmodeling-3.2.1-py3-none-any.whl")
    assert subprocess_capture.version_calls == ["3.2.1", "0.2.0"]


def test_run_build_stages_and_restores_pyproject_version(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    subprocess_capture.on_bash = fake_build_bash(
        repo_root,
        wheel_name="msmodeling-9.9.9-py3-none-any.whl",
    )

    assert run_build(build_options(version="9.9.9", version_explicit=True)) == 0
    assert subprocess_capture.version_calls == ["9.9.9", "0.2.0"]
    wheel_dir = repo_root / "artifacts"
    assert (wheel_dir / "msmodeling-9.9.9-py3-none-any.whl").is_file()
    manifest = json.loads((repo_root / "artifacts" / "build-manifest.json").read_text(encoding="utf-8"))
    assert manifest["version"] == "9.9.9"
    assert manifest["version_explicit"] is True


def test_run_build_restores_version_when_build_fails(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    def fail_bash(cmd: list[str], _kwargs: dict[str, Any]) -> subprocess.CompletedProcess[str]:
        return subprocess.CompletedProcess(cmd, 3, "", "")

    subprocess_capture.on_bash = fail_bash

    assert run_build(build_options(version="9.9.9", version_explicit=True)) == 3
    assert subprocess_capture.version_calls == ["9.9.9", "0.2.0"]


def test_run_build_propagates_subprocess_exit_code_without_staging(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    def fail_bash(cmd: list[str], _kwargs: dict[str, Any]) -> subprocess.CompletedProcess[str]:
        return subprocess.CompletedProcess(cmd, 5, "", "")

    subprocess_capture.on_bash = fail_bash
    assert run_build(build_options()) == 5


def test_local_token_routes_to_build_not_test(repo_root: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    del repo_root
    calls: list[str] = []

    def fake_build(options: BuildOptions) -> int:
        calls.append("build")
        assert options.is_local is True
        return 0

    def fake_test(options: BuildOptions) -> int:
        calls.append("test")
        return 0

    monkeypatch.setattr(build_main, "run_build", fake_build)
    monkeypatch.setattr(build_main, "run_test", fake_test)
    assert main(["local"]) == 0
    assert calls == ["build"]


def test_main_test_branch_via_cli_runner(
    repo_root: Path,
    monkeypatch: pytest.MonkeyPatch,
    with_uv: None,
) -> None:
    del with_uv
    capture = patch_subprocess_run(monkeypatch, SubprocessRunCapture())
    map_file = repo_root / "test_map.json"
    map_file.write_text("{}", encoding="utf-8")

    result = run_cli_main(
        main,
        ["test", "-e", f"test_map_path={map_file}"],
        prog="build.py",
    )
    assert result.returncode == 0
    assert capture.merged_output_calls[0]["cmd"] == ["bash", str(repo_root / "scripts" / "run_ci_gate.sh")]


def test_cli_test_without_test_map_exits_1(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.delenv("MSMODELING_TEST_MAP_PATH", raising=False)
    result = run_cli_main(main, ["test"], prog="build.py")
    assert result.returncode == 1


def test_malformed_extra_via_cli_runner() -> None:
    result = run_cli_main(main, ["test", "-e", "not-key-value"], prog="build.py")
    assert result.returncode == 2
    assert "KEY=VALUE" in result.stderr


def test_run_build_without_uv_returns_1(repo_root: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    del repo_root
    patch_uv_in_path(monkeypatch, uv_path=None)
    assert run_build(build_options()) == 1


def test_run_test_without_uv_returns_1(repo_root: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    patch_uv_in_path(monkeypatch, uv_path=None)
    assert (
        run_test(
            build_options(is_test=True, extras={"test_map_path": str(repo_root / "x.json")}),
        )
        == 1
    )


def test_run_test_missing_test_map_file_returns_1(
    repo_root: Path,
    with_uv: None,
    caplog: pytest.LogCaptureFixture,
) -> None:
    del with_uv
    missing = repo_root / "missing.json"
    with caplog.at_level("ERROR"):
        assert run_test(build_options(is_test=True, extras={"test_map_path": str(missing)})) == 1
    assert "not a file" in caplog.text


def test_run_build_version_stage_failure_returns_exit_code(
    repo_root: Path,
    monkeypatch: pytest.MonkeyPatch,
    with_uv: None,
) -> None:
    del repo_root, with_uv
    capture = patch_subprocess_run(monkeypatch, SubprocessRunCapture())

    def fail_stage(version: str) -> subprocess.CompletedProcess[str]:
        if version == "9.9.9":
            return subprocess.CompletedProcess(["uv", "version", version], 2, "", "")
        return subprocess.CompletedProcess(["uv", "version", version], 0, "", "")

    capture.on_uv_version = fail_stage
    assert run_build(build_options(version="9.9.9", version_explicit=True)) == 2


def test_run_build_restore_failure_returns_1_after_successful_build(
    repo_root: Path,
    monkeypatch: pytest.MonkeyPatch,
    with_uv: None,
) -> None:
    del with_uv
    capture = patch_subprocess_run(monkeypatch, SubprocessRunCapture())
    capture.on_bash = fake_build_bash(repo_root, wheel_name="msmodeling-9.9.9-py3-none-any.whl")

    def fail_restore(version: str) -> subprocess.CompletedProcess[str]:
        if version == "0.2.0":
            return subprocess.CompletedProcess(["uv", "version", version], 2, "", "")
        return subprocess.CompletedProcess(["uv", "version", version], 0, "", "")

    capture.on_uv_version = fail_restore

    assert run_build(build_options(version="9.9.9", version_explicit=True)) == 1
    assert capture.version_calls == ["9.9.9", "0.2.0"]
    assert not (repo_root / "artifacts" / "build-manifest.json").exists()


def test_run_shell_without_tee_uses_run_not_popen(monkeypatch: pytest.MonkeyPatch) -> None:
    popen_called = False
    merged_called = False

    def fake_run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")

    def fake_popen(*_args: Any, **_kwargs: Any) -> None:
        nonlocal popen_called
        popen_called = True
        return None

    def fake_merged(*_args: Any, **_kwargs: Any) -> int:
        nonlocal merged_called
        merged_called = True
        return 0

    monkeypatch.setattr("scripts.helpers.build.main.subprocess.run", fake_run)
    monkeypatch.setattr("scripts.helpers.build.main.subprocess.Popen", fake_popen)
    monkeypatch.setattr("scripts.helpers.build.main.run_merged_output", fake_merged)
    _run_shell(["bash", "x.sh"], cwd=Path("."), env={}, timeout=10, tee_path=None)
    assert not popen_called
    assert not merged_called


def test_run_shell_with_tee_merges_stderr_into_stdout(tmp_path: Path) -> None:
    tee = tmp_path / "out.log"
    _run_shell(
        ["bash", "-c", "echo line1; echo line2 >&2"],
        cwd=tmp_path,
        env={},
        timeout=30,
        tee_path=tee,
    )
    assert tee.read_text(encoding="utf-8") == "line1\nline2\n"


def test_run_shell_nonzero_exit_preserves_tee(tmp_path: Path) -> None:
    tee = tmp_path / "fail.log"
    with pytest.raises(subprocess.CalledProcessError) as exc_info:
        _run_shell(
            ["bash", "-c", "echo before-fail; exit 3"],
            cwd=tmp_path,
            env={},
            timeout=30,
            tee_path=tee,
        )
    assert exc_info.value.returncode == 3
    assert "before-fail" in tee.read_text(encoding="utf-8")


def test_run_shell_timeout_preserves_partial_tee(tmp_path: Path) -> None:
    tee = tmp_path / "timeout.log"
    with pytest.raises(subprocess.TimeoutExpired):
        _run_shell(
            ["bash", "-c", "echo banner; sleep 5"],
            cwd=tmp_path,
            env={},
            timeout=1,
            tee_path=tee,
        )
    assert "banner" in tee.read_text(encoding="utf-8")


def test_e2e_explicit_version_stages_pyproject(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    subprocess_capture.on_bash = fake_build_bash(
        repo_root,
        wheel_name="msmodeling-26.1.1-py3-none-any.whl",
    )
    wheel_dir = repo_root / "artifacts"
    wheel_dir.mkdir(parents=True, exist_ok=True)
    (wheel_dir / "msmodeling-1.0.0-py3-none-any.whl").write_bytes(b"stale")

    assert run_build(build_options(version="26.1.1", version_explicit=True)) == 0
    assert subprocess_capture.version_calls == ["26.1.1", "0.2.0"]
    wheels = list(wheel_dir.glob("msmodeling-*.whl"))
    assert len(wheels) == 1
    assert wheels[0].name == "msmodeling-26.1.1-py3-none-any.whl"
    manifest = json.loads((repo_root / "artifacts" / "build-manifest.json").read_text(encoding="utf-8"))
    assert manifest["version"] == "26.1.1"
    assert manifest["version_explicit"] is True


def test_e2e_default_version_keeps_pyproject_wheel_name(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    subprocess_capture.on_bash = fake_build_bash(
        repo_root,
        wheel_name="msmodeling-0.2.0-py3-none-any.whl",
    )
    wheel_dir = repo_root / "artifacts"
    wheel_dir.mkdir(parents=True, exist_ok=True)
    (wheel_dir / "msmodeling-9.9.9-py3-none-any.whl").write_bytes(b"stale")

    assert run_build(build_options()) == 0
    assert subprocess_capture.version_calls == []
    wheels = list(wheel_dir.glob("msmodeling-*.whl"))
    assert len(wheels) == 1
    assert wheels[0].name == "msmodeling-0.2.0-py3-none-any.whl"
    manifest = json.loads((repo_root / "artifacts" / "build-manifest.json").read_text(encoding="utf-8"))
    assert manifest["version"] == "0.2.0"
    assert manifest["version_explicit"] is False


def test_e2e_explicit_version_matching_pyproject_skips_staging(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    subprocess_capture.on_bash = fake_build_bash(
        repo_root,
        wheel_name="msmodeling-0.2.0-py3-none-any.whl",
    )
    assert run_build(build_options(version="0.2.0", version_explicit=True)) == 0
    assert subprocess_capture.version_calls == []


def test_e2e_cli_short_version_flag(
    repo_root: Path,
    monkeypatch: pytest.MonkeyPatch,
    with_uv: None,
) -> None:
    del with_uv
    capture = patch_subprocess_run(monkeypatch, SubprocessRunCapture())
    capture.on_bash = fake_build_bash(repo_root, wheel_name="msmodeling-26.1.1-py3-none-any.whl")
    result = run_cli_main(main, ["-v", "26.1.1"], prog="build.py")
    assert result.returncode == 0
    assert capture.version_calls == ["26.1.1", "0.2.0"]


def test_e2e_build_subprocess_failure_propagates(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    def fail_bash(cmd: list[str], _kwargs: dict[str, Any]) -> subprocess.CompletedProcess[str]:
        return subprocess.CompletedProcess(cmd, 9, "", "")

    subprocess_capture.on_bash = fail_bash
    assert run_build(build_options(version="1.2.3", version_explicit=True)) == 9
    assert not (repo_root / "artifacts" / "build-manifest.json").exists()


def test_e2e_missing_build_script_returns_1(repo_root: Path, with_uv: None) -> None:
    del with_uv
    (repo_root / "scripts" / "build.sh").unlink()
    assert run_build(build_options()) == 1


def test_e2e_no_wheel_produced_writes_empty_manifest_path(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    def empty_build(cmd: list[str], _kwargs: dict[str, Any]) -> subprocess.CompletedProcess[str]:
        return subprocess.CompletedProcess(cmd, 0, "ok\n", "")

    subprocess_capture.on_bash = empty_build
    assert run_build(build_options(version="3.0.0", version_explicit=True)) == 0
    manifest = json.loads((repo_root / "artifacts" / "build-manifest.json").read_text(encoding="utf-8"))
    assert manifest["wheel_path"] == ""


def test_main_build_via_cli_runner(
    repo_root: Path,
    monkeypatch: pytest.MonkeyPatch,
    with_uv: None,
) -> None:
    del with_uv
    capture = patch_subprocess_run(monkeypatch, SubprocessRunCapture())
    capture.on_bash = fake_build_bash(repo_root, wheel_name="msmodeling-0.2.0-py3-none-any.whl")
    result = run_cli_main(main, [], prog="build.py")
    assert result.returncode == 0
    assert capture.shell_calls[0]["cmd"] == ["bash", str(repo_root / "scripts" / "build.sh")]


def test_run_build_malformed_pyproject_returns_1(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
    caplog: pytest.LogCaptureFixture,
) -> None:
    (repo_root / "pyproject.toml").write_text("[project\nversion = bad\n", encoding="utf-8")
    with caplog.at_level("ERROR"):
        assert run_build(build_options()) == 1
    assert subprocess_capture.shell_calls == []


def test_run_build_missing_version_returns_1_without_build_sh(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
    caplog: pytest.LogCaptureFixture,
) -> None:
    (repo_root / "pyproject.toml").write_text('[project]\nname = "msmodeling"\n', encoding="utf-8")
    with caplog.at_level("ERROR"):
        assert run_build(build_options()) == 1
    assert subprocess_capture.shell_calls == []


def test_run_build_version_explicit_without_value_returns_1(
    repo_root: Path,
    subprocess_capture: SubprocessRunCapture,
    caplog: pytest.LogCaptureFixture,
) -> None:
    del repo_root
    with caplog.at_level("ERROR"):
        assert run_build(build_options(version=None, version_explicit=True)) == 1
    assert "internal error" in caplog.text
    assert subprocess_capture.shell_calls == []


def test_run_build_read_project_version_from_repo_root(
    repo_root: Path,
    monkeypatch: pytest.MonkeyPatch,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    seen_roots: list[Path | None] = []

    def fake_read_project_version(*, repo_root: Path | None = None) -> str:
        seen_roots.append(repo_root)
        return "4.5.6"

    monkeypatch.setattr("scripts.helpers.build.main.read_project_version", fake_read_project_version)
    subprocess_capture.on_bash = fake_build_bash(
        repo_root,
        wheel_name="msmodeling-4.5.6-py3-none-any.whl",
    )

    assert run_build(build_options()) == 0
    assert seen_roots == [repo_root]
    manifest = json.loads((repo_root / "artifacts" / "build-manifest.json").read_text(encoding="utf-8"))
    assert manifest["pyproject_version"] == "4.5.6"
    assert manifest["version"] == "4.5.6"


def test_run_build_read_project_version_config_error_returns_1(
    repo_root: Path,
    monkeypatch: pytest.MonkeyPatch,
    subprocess_capture: SubprocessRunCapture,
    caplog: pytest.LogCaptureFixture,
) -> None:
    from scripts.helpers._config import ConfigError

    def raise_config(*, repo_root: Path | None = None) -> str:
        del repo_root
        raise ConfigError("pyproject.toml: invalid project table")

    monkeypatch.setattr("scripts.helpers.build.main.read_project_version", raise_config)
    with caplog.at_level("ERROR"):
        assert run_build(build_options()) == 1
    assert "failed to read version from pyproject.toml" in caplog.text
    assert subprocess_capture.shell_calls == []


def test_run_build_reads_version_when_tomllib_missing(
    repo_root: Path,
    monkeypatch: pytest.MonkeyPatch,
    subprocess_capture: SubprocessRunCapture,
) -> None:
    import builtins

    real_import = builtins.__import__

    class FakeTomli:
        @staticmethod
        def loads(text: str) -> dict[str, object]:
            del text
            return {"project": {"version": "0.2.0"}}

        class TOMLDecodeError(ValueError):
            pass

    def fake_import(name: str, *args: Any, **kwargs: Any) -> Any:
        if name == "tomllib":
            msg = "No module named 'tomllib'"
            raise ModuleNotFoundError(msg)
        if name == "tomli":
            return FakeTomli()
        return real_import(name, *args, **kwargs)

    monkeypatch.setattr(builtins, "__import__", fake_import)
    subprocess_capture.on_bash = fake_build_bash(
        repo_root,
        wheel_name="msmodeling-0.2.0-py3-none-any.whl",
    )
    assert run_build(build_options()) == 0
    assert subprocess_capture.shell_calls