from __future__ import annotations

import subprocess
from collections.abc import Generator
from pathlib import Path
from unittest.mock import patch

import pygit2
import pytest

from pygitweb.config import settings
from pygitweb.git_helpers import (
    _config_cache,
    config_to_bool,
    config_to_int,
    get_blob_at_ref_path,
    get_blob_unified_diff,
    get_commit_history,
    get_commit_unified_diff,
    get_commits_in_range,
    get_readme_at_ref_path,
    get_tree_at_ref_path,
    git_get_file_or_project_config,
    git_get_heads_list,
    git_get_project_config,
    git_get_project_description,
    git_get_references,
    git_get_remotes_info,
    git_get_tags_list,
    git_get_type,
    git_parse_project_config,
    hash_set_multi,
    parse_commit,
    parse_tag,
    resolve_mr_opener,
    signature_from_principal_email,
)
from pygitweb.permissions import PermissionPrincipal


def _run_git(repo_dir: Path, *args: str) -> str:
    completed: subprocess.CompletedProcess[str] = subprocess.run(
        ["git", *args],
        cwd=repo_dir,
        capture_output=True,
        text=True,
        check=True,
    )
    return completed.stdout.strip()


class TestGitHelpers:
    class TestPure:
        def test_hash_set_multi_promotes_to_list(self) -> None:
            data: dict[str, object] = {}
            hash_set_multi(data, "k", "v1")
            hash_set_multi(data, "k", "v2")
            assert data["k"] == ["v1", "v2"]

        @pytest.mark.parametrize(
            ("value", "expected"),
            [("true", True), ("0", False), (None, True)],
        )
        def test_config_to_bool(self, value: str | None, expected: bool) -> None:
            assert config_to_bool(value) is expected

        @pytest.mark.parametrize(
            ("value", "expected"),
            [("2k", 2048), ("3M", 3145728), ("123", "123"), (None, 0)],
        )
        def test_config_to_int(self, value: str | None, expected: int | str) -> None:
            assert config_to_int(value) == expected

    class TestRepoReadonly:
        @pytest.fixture(scope="class")
        def repo_ctx(self, tmp_path_factory: pytest.TempPathFactory) -> Generator[dict[str, str], None, None]:
            project_root: Path = tmp_path_factory.mktemp("git-helpers")
            repo_dir: Path = project_root / "sample"
            repo_dir.mkdir()

            _run_git(repo_dir, "init", "-b", "main")
            _run_git(repo_dir, "config", "user.name", "Alice")
            _run_git(repo_dir, "config", "user.email", "alice@example.com")
            _run_git(repo_dir, "remote", "add", "origin", "https://example.com/repo.git")
            _run_git(repo_dir, "remote", "set-url", "--push", "origin", "ssh://example.com/repo.git")
            _run_git(repo_dir, "config", "gitweb.owner", "alice")
            _run_git(repo_dir, "config", "--add", "gitweb.category", "infra")
            _run_git(repo_dir, "config", "--add", "gitweb.category", "tools")

            (repo_dir / "README.md").write_text("Root README\n", encoding="utf-8")
            (repo_dir / "docs").mkdir()
            (repo_dir / "docs" / "README").write_text("Docs README\n", encoding="utf-8")
            (repo_dir / "docs" / "guide.txt").write_text("hello\n", encoding="utf-8")
            (repo_dir / "src").mkdir()
            (repo_dir / "src" / "app.py").write_text("print('v1')\n", encoding="utf-8")
            _run_git(repo_dir, "add", ".")
            _run_git(repo_dir, "commit", "-m", "initial commit")

            first_commit: str = _run_git(repo_dir, "rev-parse", "HEAD")
            (repo_dir / "docs" / "guide.txt").write_text("hello\nworld\n", encoding="utf-8")
            (repo_dir / "src" / "app.py").write_text("print('v2')\n", encoding="utf-8")
            _run_git(repo_dir, "add", ".")
            _run_git(repo_dir, "commit", "-m", "update docs and app")
            second_commit: str = _run_git(repo_dir, "rev-parse", "HEAD")

            _run_git(
                repo_dir,
                "tag",
                "-a",
                "v1.0",
                "-m",
                "release v1.0",
                second_commit,
            )
            _run_git(repo_dir, "tag", "lightweight", first_commit)
            _run_git(repo_dir, "branch", "feature/readme", second_commit)

            git_dir: Path = repo_dir / ".git"
            (git_dir / "description").write_text("Repo description\n", encoding="utf-8")

            ctx: dict[str, str] = {
                "project": "sample/.git",
                "first_commit": first_commit,
                "second_commit": second_commit,
            }

            _config_cache.clear()
            with patch.object(settings, "PROJECTROOT", str(project_root)):
                yield ctx
            _config_cache.clear()

        def test_git_get_type(self, repo_ctx: dict[str, str]) -> None:
            assert git_get_type(repo_ctx["project"], "HEAD") == "commit"
            assert git_get_type(repo_ctx["project"], "HEAD^{tree}") == "tree"

        def test_git_parse_and_get_project_config(self, repo_ctx: dict[str, str]) -> None:
            cfg: dict[str, object] = git_parse_project_config(repo_ctx["project"], "gitweb")
            assert cfg["gitweb.owner"] == "alice"
            assert cfg["gitweb.category"] == ["infra", "tools"]
            assert git_get_project_config(repo_ctx["project"], "owner") == "alice"
            assert git_get_project_config(repo_ctx["project"], "category") == ["infra", "tools"]

        def test_git_get_file_or_project_config_prefers_file(self, repo_ctx: dict[str, str]) -> None:
            assert git_get_file_or_project_config(repo_ctx["project"], "description") == "Repo description"
            assert git_get_project_description(repo_ctx["project"]) == "Repo description"

        def test_references_heads_tags_and_remotes(self, repo_ctx: dict[str, str]) -> None:
            heads: list[tuple[str, str, str]] = git_get_heads_list(repo_ctx["project"])
            head_names: set[str] = {name for name, _ref, _oid in heads}
            assert {"main", "feature/readme"} <= head_names

            refs: list[tuple[str, str]] = git_get_references(repo_ctx["project"])
            assert any(name == "refs/heads/main" for name, _oid in refs)

            tags: list[tuple[str, str, str]] = git_get_tags_list(repo_ctx["project"])
            tag_names: set[str] = {name for name, _ref, _oid in tags}
            assert {"v1.0", "lightweight"} <= tag_names

            remotes: list[dict[str, object]] = git_get_remotes_info(repo_ctx["project"])
            assert remotes == [
                {
                    "name": "origin",
                    "url": "https://example.com/repo.git",
                    "push_url": "ssh://example.com/repo.git",
                }
            ]

        def test_parse_commit_and_parse_tag(self, repo_ctx: dict[str, str]) -> None:
            commit_data: dict[str, object] = parse_commit(repo_ctx["project"], repo_ctx["second_commit"])
            assert commit_data["subject"] == "update docs and app"
            assert commit_data["author"] == "Alice"
            assert isinstance(commit_data["parent"], list)

            repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / repo_ctx["project"]))
            tag_oid: str = str(repo.references["refs/tags/v1.0"].target)
            tag_data: dict[str, object] = parse_tag(repo_ctx["project"], tag_oid)
            assert tag_data["type"] == "tag"
            assert tag_data["message"] == "release v1.0\n"

        def test_tree_blob_and_readme_resolution(self, repo_ctx: dict[str, str]) -> None:
            tree_result = get_tree_at_ref_path(repo_ctx["project"], "HEAD", "docs")
            assert tree_result is not None
            tree, ref_oid = tree_result
            assert ref_oid == repo_ctx["second_commit"]
            assert tree["guide.txt"].type_str == "blob"

            blob_result = get_blob_at_ref_path(repo_ctx["project"], "HEAD", "docs/guide.txt")
            assert blob_result is not None
            blob, _resolved_ref = blob_result
            assert blob.data.decode("utf-8") == "hello\nworld\n"

            readme_root = get_readme_at_ref_path(repo_ctx["project"], "HEAD", "")
            assert readme_root == ("README.md", "Root README\n")
            readme_docs = get_readme_at_ref_path(repo_ctx["project"], "HEAD", "docs")
            assert readme_docs == ("README", "Docs README\n")

        def test_commit_history_and_ranges(self, repo_ctx: dict[str, str]) -> None:
            all_history: list[dict[str, object]] = get_commit_history(repo_ctx["project"], max_count=10)
            assert [entry["oid"] for entry in all_history] == [repo_ctx["second_commit"], repo_ctx["first_commit"]]

            path_history: list[dict[str, object]] = get_commit_history(
                repo_ctx["project"],
                path="docs/guide.txt",
                max_count=10,
            )
            assert [entry["oid"] for entry in path_history] == [repo_ctx["second_commit"], repo_ctx["first_commit"]]

            in_range: list[str] = get_commits_in_range(repo_ctx["project"], "HEAD", repo_ctx["first_commit"])
            assert in_range == [repo_ctx["second_commit"]]

        def test_blob_and_commit_unified_diff(self, repo_ctx: dict[str, str]) -> None:
            blob_diff = get_blob_unified_diff(
                repo_ctx["project"],
                repo_ctx["first_commit"],
                "src/app.py",
                repo_ctx["second_commit"],
                "src/app.py",
            )
            assert blob_diff is not None
            assert "-print('v1')" in blob_diff
            assert "+print('v2')" in blob_diff

            commit_diff, oid_short = get_commit_unified_diff(repo_ctx["project"], "HEAD")
            assert "docs/guide.txt" in commit_diff
            assert "src/app.py" in commit_diff
            assert oid_short == repo_ctx["second_commit"][:7]


class TestResolveMrOpener:
    def test_prefers_principal_email(self, tmp_path: Path) -> None:
        repo = pygit2.init_repository(str(tmp_path / "bare"), bare=True)
        principal = PermissionPrincipal(username="alice", email="alice@idp.example")
        opener, warning = resolve_mr_opener(repo, principal)
        assert warning is None
        assert opener.email == "alice@idp.example"
        assert opener.name == "alice"

    def test_falls_back_to_repo_git_user(self, tmp_path: Path) -> None:
        repo_dir = tmp_path / "configured"
        pygit2.init_repository(str(repo_dir), bare=True)
        config_path = repo_dir / "config"
        config_path.write_text(
            f"{config_path.read_text(encoding='utf-8')}\n[user]\n\tname = Repo Bot\n\temail = bot@example.com\n",
            encoding="utf-8",
        )
        repo = pygit2.Repository(str(repo_dir))
        opener, warning = resolve_mr_opener(repo, None)
        assert warning is None
        assert opener.email == "bot@example.com"

    def test_falls_back_to_user_credentials(self, tmp_path: Path) -> None:
        repo = pygit2.init_repository(str(tmp_path / "bare"), bare=True)
        principal = PermissionPrincipal(username="bob", email=None)
        opener, warning = resolve_mr_opener(repo, principal)
        assert warning is None
        assert opener.name == "bob"
        assert opener.email == "bob@local"

    def test_uses_default_with_warning(self, tmp_path: Path) -> None:
        repo = pygit2.init_repository(str(tmp_path / "bare"), bare=True)
        opener, warning = resolve_mr_opener(repo, None)
        assert warning is not None
        assert opener.email == "noreply@pygitweb.local"

    def test_signature_from_principal_email_requires_email(self) -> None:
        assert signature_from_principal_email(PermissionPrincipal(username="x", email=None)) is None
        assert signature_from_principal_email(PermissionPrincipal(username="x", email="  ")) is None