from __future__ import annotations

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

import pygit2
import pytest
from fastapi.testclient import TestClient

from pygitweb.config import settings
from pygitweb.hooks_install import (
	HOOK_BUNDLES,
	HookStatus,
	bundle_status,
	get_bundle,
	get_sample,
	install,
	install_bundle,
	is_installed,
	list_bundles,
	list_samples,
	remove,
	remove_bundle,
	status,
)
from pygitweb.main import app


def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
	(repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
	index = repo.index
	index.add("README.md")
	index.write()
	tree = index.write_tree()
	sig = pygit2.Signature("tester", "tester@example.com")
	repo.create_commit("HEAD", sig, sig, "initial", tree, [])


@pytest.fixture
def project_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
	root: Path = tmp_path / "root"
	root.mkdir()
	repo_dir: Path = root / "demo"
	repo_dir.mkdir()
	_create_initial_commit(pygit2.init_repository(str(repo_dir), bare=False), repo_dir)
	with (
		patch.object(settings, "PROJECTROOT", str(root)),
		patch.object(settings, "PROJECTS_LIST", str(root)),
		patch.object(settings, "PROJECT_MAXDEPTH", 3),
		patch.object(settings, "STRICT_EXPORT", False),
		patch.object(settings, "EXPORT_OK", ""),
		patch.object(settings, "LIST_ALL", True),
		patch.object(settings, "AUTH", False),
		patch.object(settings, "MAXLOAD", None),
	):
		yield {"root": str(root), "project": "demo", "hooks_dir": str(repo_dir / ".git" / "hooks")}


class TestRegistry:
	def test_list_samples_includes_notify_pair(self) -> None:
		names: set[str] = {s["name"] for s in list_samples()}
		assert {"post-commit.notify", "post-receive.notify"} <= names

	def test_get_sample_target_is_filename_prefix(self) -> None:
		sample = get_sample("post-receive.notify")
		assert sample is not None
		assert sample["target"] == "post-receive"
		assert Path(sample["source"]).is_file()

	def test_get_sample_unknown_returns_none(self) -> None:
		assert get_sample("does-not-exist.foo") is None

	def test_update_bundle_includes_both_notify_samples(self) -> None:
		bundle = get_bundle("update")
		assert bundle is not None
		assert bundle["label"] == "Update Hook"
		assert {m["name"] for m in bundle["members"]} == {"post-commit.notify", "post-receive.notify"}

	def test_list_bundles_returns_update(self) -> None:
		assert any(b["name"] == "update" for b in list_bundles())
		assert "update" in HOOK_BUNDLES


class TestStatusInstallRemove:
	def test_status_not_installed_when_no_file(self, project_env: dict[str, str]) -> None:
		assert status(project_env["project"], "post-receive.notify") == HookStatus.NOT_INSTALLED
		assert is_installed(project_env["project"], "post-receive.notify") is False

	def test_install_then_status_installed(self, project_env: dict[str, str]) -> None:
		assert install(project_env["project"], "post-receive.notify") == HookStatus.INSTALLED
		assert is_installed(project_env["project"], "post-receive.notify") is True
		target: Path = Path(project_env["hooks_dir"]) / "post-receive"
		assert target.is_file()

	def test_install_is_idempotent(self, project_env: dict[str, str]) -> None:
		install(project_env["project"], "post-receive.notify")
		assert install(project_env["project"], "post-receive.notify") == HookStatus.INSTALLED

	def test_install_refuses_to_overwrite_custom_hook(self, project_env: dict[str, str]) -> None:
		hooks_dir: Path = Path(project_env["hooks_dir"])
		hooks_dir.mkdir(parents=True, exist_ok=True)
		(hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
		assert install(project_env["project"], "post-receive.notify") == HookStatus.DIFFERENT
		assert (hooks_dir / "post-receive").read_text(encoding="utf-8") == "#!/bin/sh\necho custom\n"

	def test_remove_uninstalls_only_when_content_matches(self, project_env: dict[str, str]) -> None:
		install(project_env["project"], "post-receive.notify")
		assert remove(project_env["project"], "post-receive.notify") == HookStatus.NOT_INSTALLED
		assert not (Path(project_env["hooks_dir"]) / "post-receive").exists()

	def test_remove_preserves_custom_hook(self, project_env: dict[str, str]) -> None:
		hooks_dir: Path = Path(project_env["hooks_dir"])
		hooks_dir.mkdir(parents=True, exist_ok=True)
		custom: str = "#!/bin/sh\necho custom\n"
		(hooks_dir / "post-receive").write_text(custom, encoding="utf-8")
		assert remove(project_env["project"], "post-receive.notify") == HookStatus.DIFFERENT
		assert (hooks_dir / "post-receive").read_text(encoding="utf-8") == custom

	def test_unknown_sample_raises(self, project_env: dict[str, str]) -> None:
		with pytest.raises(KeyError):
			status(project_env["project"], "no-such.sample")
		with pytest.raises(KeyError):
			install(project_env["project"], "no-such.sample")
		with pytest.raises(KeyError):
			remove(project_env["project"], "no-such.sample")


class TestBundle:
	def test_bundle_status_starts_not_installed(self, project_env: dict[str, str]) -> None:
		assert bundle_status(project_env["project"], "update") == HookStatus.NOT_INSTALLED

	def test_install_bundle_installs_all_members(self, project_env: dict[str, str]) -> None:
		assert install_bundle(project_env["project"], "update") == HookStatus.INSTALLED
		assert is_installed(project_env["project"], "post-commit.notify")
		assert is_installed(project_env["project"], "post-receive.notify")

	def test_partial_install_reports_not_installed(self, project_env: dict[str, str]) -> None:
		install(project_env["project"], "post-commit.notify")
		assert bundle_status(project_env["project"], "update") == HookStatus.NOT_INSTALLED

	def test_install_bundle_completes_partial(self, project_env: dict[str, str]) -> None:
		install(project_env["project"], "post-commit.notify")
		assert install_bundle(project_env["project"], "update") == HookStatus.INSTALLED
		assert is_installed(project_env["project"], "post-receive.notify")

	def test_bundle_refuses_when_member_has_custom_hook(self, project_env: dict[str, str]) -> None:
		hooks_dir: Path = Path(project_env["hooks_dir"])
		hooks_dir.mkdir(parents=True, exist_ok=True)
		(hooks_dir / "post-commit").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
		assert bundle_status(project_env["project"], "update") == HookStatus.DIFFERENT
		assert install_bundle(project_env["project"], "update") == HookStatus.DIFFERENT
		assert remove_bundle(project_env["project"], "update") == HookStatus.DIFFERENT
		assert (hooks_dir / "post-commit").read_text(encoding="utf-8") == "#!/bin/sh\necho custom\n"

	def test_remove_bundle_uninstalls_only_managed_members(self, project_env: dict[str, str]) -> None:
		install_bundle(project_env["project"], "update")
		assert remove_bundle(project_env["project"], "update") == HookStatus.NOT_INSTALLED
		assert not (Path(project_env["hooks_dir"]) / "post-commit").exists()
		assert not (Path(project_env["hooks_dir"]) / "post-receive").exists()

	def test_unknown_bundle_raises(self, project_env: dict[str, str]) -> None:
		with pytest.raises(KeyError):
			bundle_status(project_env["project"], "no-such-bundle")
		with pytest.raises(KeyError):
			install_bundle(project_env["project"], "no-such-bundle")
		with pytest.raises(KeyError):
			remove_bundle(project_env["project"], "no-such-bundle")


def _client() -> TestClient:
	return TestClient(app)


class TestHookRoute:
	def test_sample_check_then_add_then_remove_round_trip(self, project_env: dict[str, str]) -> None:
		project: str = project_env["project"]
		with _client() as client:
			check_resp = client.post(
				f"/project/{project}/hook",
				params={"name": "post-receive.notify", "op": "check"},
			)
			assert check_resp.status_code == 200
			body = check_resp.json()
			assert body["installed"] is False
			assert body["kind"] == "sample"

			add_resp = client.post(
				f"/project/{project}/hook",
				params={"name": "post-receive.notify", "op": "add"},
			)
			assert add_resp.status_code == 200
			body = add_resp.json()
			assert body["installed"] is True
			assert body["target"] == "post-receive"
			assert body["label"] == "Post-receive Notify"

			remove_resp = client.post(
				f"/project/{project}/hook",
				params={"name": "post-receive.notify", "op": "remove"},
			)
			assert remove_resp.status_code == 200
			assert remove_resp.json()["installed"] is False

	def test_bundle_check_then_add_then_remove_round_trip(self, project_env: dict[str, str]) -> None:
		project: str = project_env["project"]
		hooks_dir: Path = Path(project_env["hooks_dir"])
		with _client() as client:
			check_resp = client.post(
				f"/project/{project}/hook",
				params={"name": "update", "op": "check"},
			)
			assert check_resp.status_code == 200
			body = check_resp.json()
			assert body["installed"] is False
			assert body["kind"] == "bundle"
			assert body["label"] == "Update Hook"
			assert set(body["members"]) == {"post-commit.notify", "post-receive.notify"}

			add_resp = client.post(
				f"/project/{project}/hook",
				params={"name": "update", "op": "add"},
			)
			assert add_resp.status_code == 200
			assert add_resp.json()["installed"] is True
			assert (hooks_dir / "post-commit").is_file()
			assert (hooks_dir / "post-receive").is_file()

			remove_resp = client.post(
				f"/project/{project}/hook",
				params={"name": "update", "op": "remove"},
			)
			assert remove_resp.status_code == 200
			assert remove_resp.json()["installed"] is False
			assert not (hooks_dir / "post-commit").exists()
			assert not (hooks_dir / "post-receive").exists()

	def test_bundle_add_refuses_when_member_has_custom_hook(self, project_env: dict[str, str]) -> None:
		hooks_dir: Path = Path(project_env["hooks_dir"])
		hooks_dir.mkdir(parents=True, exist_ok=True)
		(hooks_dir / "post-commit").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
		with _client() as client:
			resp = client.post(
				f"/project/{project_env['project']}/hook",
				params={"name": "update", "op": "add"},
			)
		assert resp.status_code == 409

	def test_invalid_op_returns_422(self, project_env: dict[str, str]) -> None:
		with _client() as client:
			resp = client.post(
				f"/project/{project_env['project']}/hook",
				params={"name": "post-receive.notify", "op": "noop"},
			)
		assert resp.status_code == 422

	def test_unknown_name_returns_404(self, project_env: dict[str, str]) -> None:
		with _client() as client:
			resp = client.post(
				f"/project/{project_env['project']}/hook",
				params={"name": "no-such.thing", "op": "check"},
			)
		assert resp.status_code == 404

	def test_add_refuses_to_overwrite_custom_hook(self, project_env: dict[str, str]) -> None:
		hooks_dir: Path = Path(project_env["hooks_dir"])
		hooks_dir.mkdir(parents=True, exist_ok=True)
		(hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
		with _client() as client:
			resp = client.post(
				f"/project/{project_env['project']}/hook",
				params={"name": "post-receive.notify", "op": "add"},
			)
		assert resp.status_code == 409

	def test_hooks_list_endpoint_returns_status_for_samples_and_bundles(self, project_env: dict[str, str]) -> None:
		with _client() as client:
			resp = client.get(f"/project/{project_env['project']}/hooks")
		assert resp.status_code == 200
		body = resp.json()
		assert body["project"] == project_env["project"]
		sample_names: set[str] = {entry["name"] for entry in body["hooks"]}
		assert {"post-commit.notify", "post-receive.notify"} <= sample_names
		bundle_names: set[str] = {entry["name"] for entry in body["bundles"]}
		assert "update" in bundle_names

	def test_summary_renders_install_button_when_bundle_not_installed(self, project_env: dict[str, str]) -> None:
		with _client() as client:
			resp = client.get(f"/project/{project_env['project']}")
		assert resp.status_code == 200
		assert "Install Update Hook" in resp.text
		assert 'data-sample="update"' in resp.text
		assert "btn btn-sm btn-primary" in resp.text

	def test_summary_renders_remove_button_when_bundle_fully_installed(self, project_env: dict[str, str]) -> None:
		install_bundle(project_env["project"], "update")
		with _client() as client:
			resp = client.get(f"/project/{project_env['project']}")
		assert resp.status_code == 200
		assert "Remove Update Hook" in resp.text
		assert "btn btn-sm btn-ghost-secondary" in resp.text

	def test_summary_renders_install_button_when_only_one_member_installed(self, project_env: dict[str, str]) -> None:
		install(project_env["project"], "post-commit.notify")
		with _client() as client:
			resp = client.get(f"/project/{project_env['project']}")
		assert resp.status_code == 200
		assert "Install Update Hook" in resp.text


class TestSummaryRefSwitcherStaticScript:
	def test_static_script_subscribes_via_updates_endpoint(self) -> None:
		with _client() as client:
			resp = client.get("/static/summary-ref-switcher.js")
		assert resp.status_code == 200
		body: str = resp.text
		assert "a=heads&updates=true" in body
		assert "subscribeToUpdates" in body
		assert "AbortController" in body
		assert "loadOptions" in body
		assert "loadState" in body