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,
    content_hash,
    get_bundle,
    get_installed_version,
    get_sample,
    install,
    install_bundle,
    is_installed,
    list_bundles,
    list_installable_hooks,
    list_installed_hooks,
    list_samples,
    read_hook_version,
    remove,
    remove_bundle,
    sample_content_hash,
    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_dropdown_when_no_hooks_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 'id="hook-install-select"' in resp.text
        assert "hook-install-btn" in resp.text
        assert "Post-receive Notify" in resp.text
        assert "No hooks installed." in resp.text

    def test_summary_renders_uninstall_buttons_for_installed_hooks(self, project_env: dict[str, str]) -> None:
        install(project_env["project"], "post-receive.notify")
        install(project_env["project"], "pre-commit.ruff")
        with _client() as client:
            resp = client.get(f"/project/{project_env['project']}")
        assert resp.status_code == 200
        assert resp.text.count("Uninstall") == 2
        assert 'data-sample="post-receive.notify"' in resp.text
        assert 'data-sample="pre-commit.ruff"' in resp.text
        assert ">v1<" in resp.text or "v1" in resp.text

    def test_summary_hides_installed_hooks_from_install_dropdown(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 'data-sample="post-commit.notify"' in resp.text
        assert 'value="post-commit.notify"' not in resp.text

    def test_summary_shows_custom_hook_conflict(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.get(f"/project/{project_env['project']}")
        assert resp.status_code == 200
        assert "custom hook installed; refusing to manage" in resp.text


class TestHookVersion:
    def test_read_hook_version_from_sample(self) -> None:
        sample = get_sample("post-receive.notify")
        assert sample is not None
        assert read_hook_version(Path(sample["source"])) == "1"

    def test_get_installed_version_reads_installed_file(self, project_env: dict[str, str]) -> None:
        install(project_env["project"], "post-receive.notify")
        assert get_installed_version(project_env["project"], "post-receive.notify") == "1"

    def test_list_installed_hooks_includes_only_present_hooks(self, project_env: dict[str, str]) -> None:
        notify_sample = get_sample("post-commit.notify")
        push_sample = get_sample("post-commit.push-remotes")
        assert notify_sample is not None
        assert push_sample is not None
        notify_hash = sample_content_hash("post-commit.notify")
        push_hash = sample_content_hash("post-commit.push-remotes")
        assert notify_hash != push_hash
        assert content_hash(Path(notify_sample["source"])) == notify_hash
        assert content_hash(Path(push_sample["source"])) == push_hash

        install(project_env["project"], "post-commit.notify")
        installed = list_installed_hooks(project_env["project"])
        assert {entry["name"] for entry in installed} == {"post-commit.notify"}
        assert installed[0]["version"] == "1"
        assert installed[0]["content_hash"] == notify_hash

        remove(project_env["project"], "post-commit.notify")
        install(project_env["project"], "post-commit.push-remotes")
        installed = list_installed_hooks(project_env["project"])
        assert {entry["name"] for entry in installed} == {"post-commit.push-remotes"}
        assert installed[0]["version"] == "1"
        assert installed[0]["content_hash"] == push_hash

    def test_list_installable_hooks_excludes_installed(self, project_env: dict[str, str]) -> None:
        install(project_env["project"], "post-commit.notify")
        installable = list_installable_hooks(project_env["project"])
        assert all(entry["name"] != "post-commit.notify" for entry in installable)


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
        assert "summary-tree-container" in body
        assert "tree_html" in body