from __future__ import annotations

import json
import time
from pathlib import Path
from typing import Any

import pytest

from icsd.cli.main import EXIT_FINDINGS, EXIT_OK, EXIT_TOOL_ERROR, build_parser, main
from icsd.core.batch import validate_states
from icsd.core.invariants import Invariant, InvariantSet
from icsd.tools import parallel as parallel_tools
from icsd.tools.parallel import lint_targets_parallel, validate_states_parallel


def _parallel_invariants() -> InvariantSet:
    def _check(state: dict[str, Any]) -> bool:
        delay_seconds = float(state.get("delay_seconds", 0.0))
        if delay_seconds > 0:
            time.sleep(delay_seconds)
        return isinstance(state.get("value"), int) and state["value"] >= 0

    return InvariantSet([Invariant(name="non_negative", check=_check)])


def test_validate_states_parallel_workers_one_matches_single_process() -> None:
    states = [{"value": 1}, {"value": -1}, "bad-state", {"value": 0}]
    context = {"batch_id": "batch-0074"}

    sequential = validate_states(_parallel_invariants(), states, context=context)
    parallel = validate_states_parallel(_parallel_invariants(), states, context=context, workers=1)

    assert parallel.as_dict() == sequential.as_dict()


def test_validate_states_parallel_workers_one_avoids_thread_pool(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _unexpected_thread_pool(*args: object, **kwargs: object) -> object:
        raise AssertionError("ThreadPoolExecutor should not be used when workers=1")

    monkeypatch.setattr(parallel_tools, "ThreadPoolExecutor", _unexpected_thread_pool)

    report = validate_states_parallel(_parallel_invariants(), [{"value": 1}], workers=1)

    assert report.results[0].valid is True


def test_validate_states_parallel_workers_many_matches_single_process() -> None:
    states = [{"value": 1}, {"value": -1}, "bad-state", {"value": 0}]
    context = {"batch_id": "batch-0074"}

    sequential = validate_states(_parallel_invariants(), states, context=context)
    parallel = validate_states_parallel(_parallel_invariants(), states, context=context, workers=4)

    assert parallel.as_dict() == sequential.as_dict()


def test_validate_states_parallel_preserves_index_order_with_skewed_durations() -> None:
    report = validate_states_parallel(
        _parallel_invariants(),
        [
            {"value": 1, "delay_seconds": 0.03},
            {"value": 1, "delay_seconds": 0.01},
            {"value": -1, "delay_seconds": 0.02},
            {"value": 1, "delay_seconds": 0.00},
        ],
        workers=4,
    )

    assert [result.index for result in report.results] == [0, 1, 2, 3]
    assert [result.valid for result in report.results] == [True, True, False, True]


def test_validate_states_parallel_argument_guards() -> None:
    with pytest.raises(ValueError, match="workers must be an integer greater than or equal to 1"):
        validate_states_parallel(_parallel_invariants(), [{"value": 1}], workers=0)

    with pytest.raises(TypeError, match="invariant_set must be an InvariantSet instance"):
        validate_states_parallel(object(), [{"value": 1}])  # type: ignore[arg-type]

    with pytest.raises(TypeError, match="states must be an iterable of state values"):
        validate_states_parallel(_parallel_invariants(), "bad-input")

    with pytest.raises(TypeError, match="context must be a mapping when provided"):
        validate_states_parallel(
            _parallel_invariants(),
            [{"value": 1}],
            context="bad-context",  # type: ignore[arg-type]
        )


def test_lint_targets_parallel_preserves_input_order_and_error_envelope(tmp_path: Path) -> None:
    ordered_paths = [tmp_path / "c.py", tmp_path / "a.py", tmp_path / "b.py"]
    for path in ordered_paths:
        path.write_text("pass\n", encoding="utf-8")

    def lint_file(path: Path) -> tuple[str, ...]:
        if path.name == "a.py":
            time.sleep(0.03)
            return ("finding-a",)
        if path.name == "b.py":
            time.sleep(0.01)
            raise UnicodeDecodeError("utf-8", b"\x80", 0, 1, "invalid start byte")
        return ("finding-c",)

    results = lint_targets_parallel(ordered_paths, lint_file=lint_file, workers=3)

    assert [result.path.name for result in results] == ["c.py", "a.py", "b.py"]
    assert [result.index for result in results] == [0, 1, 2]
    assert results[0].findings == ("finding-c",)
    assert results[1].findings == ("finding-a",)
    assert results[2].error is not None
    assert "unable to read" in results[2].error
    assert "b.py" in results[2].error


def test_lint_targets_parallel_workers_one_avoids_thread_pool(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    ordered_paths = [tmp_path / "c.py", tmp_path / "a.py"]
    for path in ordered_paths:
        path.write_text("pass\n", encoding="utf-8")

    def _unexpected_thread_pool(*args: object, **kwargs: object) -> object:
        raise AssertionError("ThreadPoolExecutor should not be used when workers=1")

    monkeypatch.setattr(parallel_tools, "ThreadPoolExecutor", _unexpected_thread_pool)

    results = lint_targets_parallel(ordered_paths, lint_file=lambda path: (path.name,), workers=1)

    assert [result.path.name for result in results] == ["c.py", "a.py"]
    assert [result.findings for result in results] == [("c.py",), ("a.py",)]


def test_build_parser_lint_jobs_defaults_and_explicit_value() -> None:
    parser = build_parser()

    default_args = parser.parse_args(["lint", "."])
    explicit_args = parser.parse_args(["lint", ".", "--jobs", "4"])

    assert default_args.jobs == 1
    assert explicit_args.jobs == 4


def test_build_parser_lint_jobs_help_describes_opt_in_deterministic_parallelism() -> None:
    parser = build_parser()
    subparsers_action = next(
        action for action in parser._subparsers._group_actions if hasattr(action, "choices")
    )
    lint_parser = subparsers_action.choices["lint"]
    help_text = " ".join(lint_parser.format_help().split())

    assert (
        "Number of lint workers (default: 1, single-threaded; >1 enables opt-in "
        "deterministic parallel linting)."
    ) in help_text


def test_main_lint_parallel_json_output_preserves_report_shape(
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    (tmp_path / "ok.py").write_text("def hello() -> str:\n    return 'ok'\n", encoding="utf-8")
    (tmp_path / "ok.json").write_text('{"hello": "world"}\n', encoding="utf-8")

    exit_code = main(["lint", str(tmp_path), "--jobs", "2", "--format", "json"])
    report = json.loads(capsys.readouterr().out)

    assert exit_code == EXIT_OK
    assert report["status"] == "ok"
    assert report["finding_count"] == 0
    assert report["findings"] == []


def test_main_lint_jobs_less_than_one_returns_tool_error(
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    exit_code = main(["lint", str(tmp_path), "--jobs", "0", "--format", "json"])
    report = json.loads(capsys.readouterr().out)

    assert exit_code == EXIT_TOOL_ERROR
    assert report["status"] == "error"
    assert report["error"] == "jobs must be >= 1: 0."
    assert report["finding_count"] == 0


def test_main_lint_parallel_matches_sequential_findings(
    tmp_path: Path,
    capsys: pytest.CaptureFixture[str],
) -> None:
    (tmp_path / "a_broken.json").write_text("{broken}\n", encoding="utf-8")
    (tmp_path / "b_broken.py").write_text("def broken(:\n    return 1\n", encoding="utf-8")

    sequential_exit = main(["lint", str(tmp_path), "--format", "json"])
    sequential_report = json.loads(capsys.readouterr().out)

    parallel_exit = main(["lint", str(tmp_path), "--jobs", "4", "--format", "json"])
    parallel_report = json.loads(capsys.readouterr().out)

    assert sequential_exit == EXIT_FINDINGS
    assert parallel_exit == EXIT_FINDINGS
    assert parallel_report["findings"] == sequential_report["findings"]
    assert parallel_report["finding_count"] == sequential_report["finding_count"]
