"""
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 / "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",
	"pre-receive.branch-permissions": "Branch Write Permissions",
	"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)