"""
Install, remove, and inspect pygittools hook samples in a project's hooks directory.

A "sample" is a file in `pygittools/hook_samples/` whose name encodes the target git
hook as the prefix before the first dot (e.g. `post-receive.notify` installs as
`<hooks_dir>/post-receive`). The hooks directory is whichever path pygit2 reports for
the repo (`<repo>/.git/hooks` for non-bare, `<repo>/hooks` for bare).
"""

from __future__ import annotations

import contextlib
import hashlib
import os
import re
import shutil
import stat
from enum import StrEnum
from pathlib import Path
from typing import TypedDict

import pygit2

import pygittools
from pygitweb.config import settings

SAMPLES_DIR: Path = Path(pygittools.__file__).parent.parent.parent / "hook_samples"

_VERSION_RE: re.Pattern[str] = re.compile(r"""^__VERSION__\s*=\s*(['"])(.*?)\1""", re.MULTILINE)

_SAMPLE_LABELS: dict[str, str] = {
    "post-commit.notify": "Post-commit Notify",
    "post-receive.notify": "Post-receive Notify",
    "pre-commit.ruff": "Ruff Pre-commit",
    "commit-msg.pattern": "Commit Message Pattern",
    "pre-receive.protected-pattern": "Protected Branch Commit Messages",
    "post-commit.push-remotes": "Post-commit Push Remotes",
}


HOOK_BUNDLES: dict[str, list[str]] = {
    # "Update Hook" wires both server (post-receive on push) and client (post-commit on local
    # commit) sides into the long-poll change queue, so subscribers wake regardless of where
    # the commit was made.
    "update": ["post-commit.notify", "post-receive.notify"],
}

_BUNDLE_LABELS: dict[str, str] = {
    "update": "Update Hook",
}


class HookStatus(StrEnum):
    INSTALLED = "installed"
    NOT_INSTALLED = "not_installed"
    DIFFERENT = "different"


class HookSample(TypedDict):
    name: str
    target: str
    source: str
    label: str


class HookBundle(TypedDict):
    name: str
    label: str
    members: list[HookSample]


class InstalledHookInfo(TypedDict):
    name: str
    label: str
    target: str
    status: str
    version: str | None
    content_hash: str | None


def content_hash(path: Path) -> str:
    """Return the SHA-256 hex digest of a hook file's contents."""
    return hashlib.sha256(path.read_bytes()).hexdigest()


def sample_content_hash(sample_name: str) -> str:
    sample: HookSample | None = get_sample(sample_name)
    if sample is None:
        raise KeyError(f"Unknown hook sample: {sample_name}")
    return content_hash(Path(sample["source"]))


def read_hook_version(path: Path) -> str | None:
    """Return the __VERSION__ string from a hook file, if present."""
    try:
        text: str = path.read_text(encoding="utf-8")
    except OSError:
        return None
    match: re.Match[str] | None = _VERSION_RE.search(text)
    if match is None:
        return None
    return match.group(2)


def get_installed_version(project: str, sample_name: str) -> str | None:
    sample: HookSample | None = get_sample(sample_name)
    if sample is None:
        raise KeyError(f"Unknown hook sample: {sample_name}")
    target: Path = _target_path(project, sample)
    if target.is_file():
        version: str | None = read_hook_version(target)
        if version is not None:
            return version
    return read_hook_version(Path(sample["source"]))


def list_installed_hooks(project: str) -> list[InstalledHookInfo]:
    """Return pygittools hook samples that are installed or conflict with a custom hook."""
    installed: list[InstalledHookInfo] = []
    for sample in list_samples():
        st: HookStatus = status(project, sample["name"])
        if st == HookStatus.NOT_INSTALLED:
            continue
        if st == HookStatus.DIFFERENT:
            owner: str | None = _installed_sample_for_target(project, sample["target"])
            if owner is not None and owner != sample["name"]:
                continue
        target_path: Path = _target_path(project, sample)
        version: str | None = None
        if st == HookStatus.INSTALLED:
            version = get_installed_version(project, sample["name"])
        else:
            version = read_hook_version(target_path)
        installed_hash: str | None = None
        if target_path.is_file():
            with contextlib.suppress(OSError):
                installed_hash = content_hash(target_path)
        installed.append({
            "name": sample["name"],
            "label": sample["label"],
            "target": sample["target"],
            "status": st.value,
            "version": version,
            "content_hash": installed_hash,
        })
    return installed


def list_installable_hooks(project: str) -> list[HookSample]:
    """Return pygittools hook samples that can still be installed in this project."""
    installable: list[HookSample] = []
    for sample in list_samples():
        if status(project, sample["name"]) == HookStatus.NOT_INSTALLED:
            installable.append(sample)
    return installable


def list_samples() -> list[HookSample]:
    if not SAMPLES_DIR.is_dir():
        return []
    samples: list[HookSample] = []
    for entry in sorted(SAMPLES_DIR.iterdir()):
        if not entry.is_file() or "." not in entry.name or entry.name.lower().endswith(".md"):
            continue
        target: str = entry.name.split(".", 1)[0]
        samples.append({
            "name": entry.name,
            "target": target,
            "source": str(entry),
            "label": _SAMPLE_LABELS.get(entry.name, entry.name),
        })
    return samples


def get_sample(name: str) -> HookSample | None:
    for sample in list_samples():
        if sample["name"] == name:
            return sample
    return None


def _hooks_dir(project: str) -> Path:
    repo: pygit2.Repository = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
    return Path(repo.path) / "hooks"


def _target_path(project: str, sample: HookSample) -> Path:
    return _hooks_dir(project) / sample["target"]


def _installed_sample_for_target(project: str, target: str) -> str | None:
    """Return the sample name whose content is installed at target, if any."""
    target_path: Path = _hooks_dir(project) / target
    if not target_path.is_file():
        return None
    try:
        installed_hash: str = content_hash(target_path)
    except OSError:
        return None
    for sample in list_samples():
        if sample["target"] != target:
            continue
        try:
            if content_hash(Path(sample["source"])) == installed_hash:
                return sample["name"]
        except OSError:
            continue
    return None


def status(project: str, sample_name: str) -> HookStatus:
    sample: HookSample | None = get_sample(sample_name)
    if sample is None:
        raise KeyError(f"Unknown hook sample: {sample_name}")
    target: Path = _target_path(project, sample)
    if not target.is_file():
        return HookStatus.NOT_INSTALLED
    try:
        installed_bytes: bytes = target.read_bytes()
        source_bytes: bytes = Path(sample["source"]).read_bytes()
    except OSError:
        return HookStatus.NOT_INSTALLED
    return HookStatus.INSTALLED if installed_bytes == source_bytes else HookStatus.DIFFERENT


def is_installed(project: str, sample_name: str) -> bool:
    return status(project, sample_name) == HookStatus.INSTALLED


def install(project: str, sample_name: str) -> HookStatus:
    """Copy the sample to the hooks dir. Refuses to overwrite a file with different content."""
    sample: HookSample | None = get_sample(sample_name)
    if sample is None:
        raise KeyError(f"Unknown hook sample: {sample_name}")
    current: HookStatus = status(project, sample_name)
    if current == HookStatus.DIFFERENT:
        return current
    target: Path = _target_path(project, sample)
    target.parent.mkdir(parents=True, exist_ok=True)
    shutil.copyfile(sample["source"], target)
    try:
        mode: int = target.stat().st_mode
        target.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
    except OSError:
        pass
    return status(project, sample_name)


def remove(project: str, sample_name: str) -> HookStatus:
    """Delete the hook file only if its content still matches the sample (preserves custom hooks)."""
    sample: HookSample | None = get_sample(sample_name)
    if sample is None:
        raise KeyError(f"Unknown hook sample: {sample_name}")
    current: HookStatus = status(project, sample_name)
    if current == HookStatus.INSTALLED:
        _target_path(project, sample).unlink(missing_ok=True)
    return status(project, sample_name)


def list_bundles() -> list[HookBundle]:
    bundles: list[HookBundle] = []
    for name, member_names in HOOK_BUNDLES.items():
        members: list[HookSample] = []
        for member_name in member_names:
            sample: HookSample | None = get_sample(member_name)
            if sample is not None:
                members.append(sample)
        bundles.append({
            "name": name,
            "label": _BUNDLE_LABELS.get(name, name),
            "members": members,
        })
    return bundles


def get_bundle(name: str) -> HookBundle | None:
    for bundle in list_bundles():
        if bundle["name"] == name:
            return bundle
    return None


def bundle_status(project: str, bundle_name: str) -> HookStatus:
    """Aggregate status for a bundle: DIFFERENT if any member conflicts, INSTALLED if all are,
    otherwise NOT_INSTALLED (treats partial installs as not-installed so re-clicking install
    completes the bundle)."""
    bundle: HookBundle | None = get_bundle(bundle_name)
    if bundle is None:
        raise KeyError(f"Unknown hook bundle: {bundle_name}")
    if not bundle["members"]:
        return HookStatus.NOT_INSTALLED
    statuses: list[HookStatus] = [status(project, member["name"]) for member in bundle["members"]]
    if any(s == HookStatus.DIFFERENT for s in statuses):
        return HookStatus.DIFFERENT
    if all(s == HookStatus.INSTALLED for s in statuses):
        return HookStatus.INSTALLED
    return HookStatus.NOT_INSTALLED


def install_bundle(project: str, bundle_name: str) -> HookStatus:
    """Install every member of the bundle. Refuses (no-op) if any target holds a different file."""
    bundle: HookBundle | None = get_bundle(bundle_name)
    if bundle is None:
        raise KeyError(f"Unknown hook bundle: {bundle_name}")
    for member in bundle["members"]:
        if status(project, member["name"]) == HookStatus.DIFFERENT:
            return HookStatus.DIFFERENT
    for member in bundle["members"]:
        install(project, member["name"])
    return bundle_status(project, bundle_name)


def remove_bundle(project: str, bundle_name: str) -> HookStatus:
    """Remove every member of the bundle. Refuses (no-op) if any target holds a different file."""
    bundle: HookBundle | None = get_bundle(bundle_name)
    if bundle is None:
        raise KeyError(f"Unknown hook bundle: {bundle_name}")
    for member in bundle["members"]:
        if status(project, member["name"]) == HookStatus.DIFFERENT:
            return HookStatus.DIFFERENT
    for member in bundle["members"]:
        remove(project, member["name"])
    return bundle_status(project, bundle_name)