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,
)


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]