"""
Gitweb action handlers (git_*): summary, tree, blob, log, commit, tag, etc.
Ported from gitweb/gitweb.perl action handlers. Invoked by main.dispatch.
"""

from __future__ import annotations

import base64
import mimetypes
import os
from datetime import UTC, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, TypedDict
from urllib.parse import quote, urlencode

import pygit2
from fastapi import HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
from python_ripgrep import PySortMode, PySortModeKind
from python_ripgrep import search as rg_search

from pygitweb.config import BLOB_LANG, settings
from pygitweb.formatting import age_string, sanitize, to_utf8
from pygitweb.git_helpers import (
	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_heads_list,
	git_get_project_description,
	git_get_references,
	git_get_remotes_info,
	git_get_tags_list,
	git_get_type,
	open_repo,
	parse_commit,
	parse_tag,
)
from pygitweb.projects import git_get_project_owner
from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
from pygitweb.validation import is_valid_pathname, is_valid_ref_format


def _split_query_list(values: list[str]) -> list[str]:
	parts: list[str] = []
	for v in values:
		for seg in (v or "").split(","):
			s = seg.strip()
			if s:
				parts.append(s)
	return parts


def _parse_query_bool(value: str | None) -> bool | None:
	if value is None:
		return None
	v = value.strip().lower()
	if v in ("1", "true", "yes", "on"):
		return True
	if v in ("0", "false", "no", "off"):
		return False
	raise HTTPException(status_code=400, detail=f"Invalid boolean: {value!r}")


def _project_worktree_root(project: str) -> Path:
	root = (Path(settings.PROJECTROOT) / project).resolve()
	if not root.exists() or not root.is_dir():
		raise HTTPException(status_code=404, detail="Project directory not found")
	return root


def _validated_search_paths(project_root: Path, raw_paths: list[str]) -> list[str]:
	root = project_root.resolve()
	if not raw_paths:
		return [str(root)]

	paths: list[str] = []
	for raw in raw_paths:
		p = (raw or "").strip()
		if not p:
			continue
		candidate = Path(p)
		if candidate.is_absolute() or ":" in p:
			raise HTTPException(status_code=400, detail="path not relative to the project root")
		abs_path = (root / p).resolve()
		if not abs_path.is_relative_to(root):
			raise HTTPException(status_code=400, detail="path not within the project root")
		if not abs_path.exists():
			raise HTTPException(status_code=400, detail=f"path entry does not exist: {p}")
		paths.append(str(abs_path))

	if not paths:
		return [str(root)]
	return paths


_SORT_KINDS: tuple[str, ...] = ("Path", "LastModified", "LastAccessed", "Created")


def _parse_search_sort(value: str | None) -> PySortMode | None:
	if value is None or not value.strip():
		return None
	raw = value.strip()
	reverse = False
	if raw.startswith("-"):
		reverse = True
		raw = raw[1:].strip()
	if raw not in _SORT_KINDS:
		allowed = ", ".join(_SORT_KINDS)
		raise HTTPException(status_code=400, detail=f"Invalid sort kind; allowed: {allowed}")
	kind = getattr(PySortModeKind, raw)
	return PySortMode(kind=kind, reverse=reverse)


def _pagination_url(request: Request, page: int, pagecount: int) -> str:
	"""Build URL for a pagination page, preserving path and other query params."""
	params = dict(request.query_params)
	params["page"] = str(page)
	params["pagecount"] = str(pagecount)
	return f"{request.url.path}?{urlencode(params)}"


def parse_pagination(
	page: str | None,
	pagecount: str | None,
) -> tuple[int, int]:
	"""Parse page and pagecount; default 1 and 25; raise if pagecount > 50."""
	p = 1
	pc = 25
	if page is not None:
		try:
			p = int(page)
		except ValueError as e:
			raise HTTPException(status_code=400, detail="page must be an integer") from e
		if p < 1:
			raise HTTPException(status_code=400, detail="page must be at least 1")
	if pagecount is not None:
		try:
			pc = int(pagecount)
		except ValueError as e:
			raise HTTPException(status_code=400, detail="pagecount must be an integer") from e
		if pc < 1:
			raise HTTPException(status_code=400, detail="pagecount must be at least 1")
		if pc > 50:
			raise HTTPException(status_code=400, detail="pagecount must not exceed 50")
	return p, pc


def _tree_url(project: str, h: str, f: str | None, a: str = "tree") -> str:
	"""Build URL for tree or blob: /project/{project}?a=a&h=h&f=f with f quoted."""
	q = f"a={a}&h={quote(h, safe='')}"
	if f:
		q += f"&f={quote(f, safe='/')}"
	return f"/project/{project}?{q}"


def _format_date(epoch: int | None, tz_offset: int | None = None) -> str:
	"""Format epoch timestamp to readable date string.
	tz_offset is in minutes (as returned by pygit2).
	"""
	if epoch is None:
		return ""
	try:
		tz = timezone(timedelta(seconds=tz_offset * 60)) if tz_offset is not None else UTC
		dt = datetime.fromtimestamp(epoch, tz=tz)
		return dt.strftime("%Y-%m-%d %H:%M:%S")
	except (ValueError, OSError):
		return ""


def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool = False) -> list:
	"""Format a single commit as a table row."""
	oid = commit.get("oid", "")
	oid_short = oid[:7] if oid else ""
	subject = commit.get("subject", "")
	author = commit.get("author", "")
	author_epoch = commit.get("author_epoch")

	date_str = _format_date(author_epoch, commit.get("author_tz"))
	age_sec = None
	if author_epoch:
		age_sec = datetime.now(UTC).timestamp() - author_epoch
		age_str = age_string(age_sec) if age_sec > 0 else "right now"
	else:
		age_str = ""

	commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
	diff_link = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
	author_display = jinja_escape(author) or "unknown"

	if short:
		return [
			f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
			jinja_escape(subject),
			author_display,
			jinja_escape(age_str),
			f'<a href="{diff_link}">diff</a>',
		]
	return [
		f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
		jinja_escape(subject),
		author_display,
		jinja_escape(date_str),
		jinja_escape(age_str),
		f'<a href="{diff_link}">diff</a>',
	]


def _render_pagination(
	request: Request,
	page: int,
	pagecount: int,
	has_prev: bool,
	has_next: bool,
	total_pages: int | None = None,
) -> str:
	"""Render Tabler pagination HTML."""
	prev_url = _pagination_url(request, page - 1, pagecount) if has_prev else ""
	next_url = _pagination_url(request, page + 1, pagecount) if has_next else ""
	page_links = None
	if total_pages is not None and total_pages <= 20:
		page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(1, total_pages + 1)]
	elif total_pages is not None:
		start = max(1, page - 2)
		end = min(total_pages, page + 2)
		page_links = [(p, _pagination_url(request, p, pagecount)) for p in range(start, end + 1)]
		if start > 1:
			page_links = [(1, _pagination_url(request, 1, pagecount))] + [(-1, "")] + page_links
		if end < total_pages:
			page_links = page_links + [(-1, "")] + [(total_pages, _pagination_url(request, total_pages, pagecount))]
	return env.get_template("pagination.html").render(
		current_page=page,
		pagecount=pagecount,
		has_prev=has_prev,
		has_next=has_next,
		prev_url=prev_url,
		next_url=next_url,
		total_pages=total_pages,
		page_links=page_links,
	)


def _render_readme_card(
	project: str,
	ref_oid: str,
	readme_filename: str,
	readme_content: str,
	blob_dir: str = "",
) -> str:
	"""Return HTML for the README card (and script for markdown).

	blob_dir is the current tree path for relative links.
	"""
	is_markdown = readme_filename.lower().endswith(".md")
	blob_base = f"/project/{project}?a=blob&h={quote(ref_oid, safe='')}&f="
	blob_base_attr = blob_base.replace("&", "&amp;").replace('"', "&quot;")
	blob_dir_attr = blob_dir.replace("&", "&amp;").replace('"', "&quot;") if blob_dir else ""
	if is_markdown:
		readme_b64 = base64.b64encode(readme_content.encode("utf-8")).decode("ascii")
		card = (
			'<div class="card mt-3"><div class="card-header"><h2 class="card-title">README</h2></div>'
			'<div class="card-body"><div id="readme-container" class="markdown-body" data-readme-b64="'
			+ readme_b64
			+ '" data-blob-base="'
			+ blob_base_attr
			+ '"'
		)
		if blob_dir_attr:
			card += ' data-blob-dir="' + blob_dir_attr + '"'
		card += '></div></div></div><script src="/static/readme-render.js"></script>'
		return card
	return (
		'<div class="card mt-3"><div class="card-header"><h2 class="card-title">README</h2></div>'
		+ '<div class="card-body"><pre class="readme-plain"><code>'
		+ (jinja_escape(sanitize(readme_content)) or "")
		+ "</code></pre></div></div>"
	)


def _commit_unified_diff_or_raise(project: str, h: str) -> tuple[str, str]:
	"""Return (unified_diff_text, oid_short) for commit h. Raises HTTPException on error."""
	if not h:
		raise HTTPException(status_code=400, detail="Commit hash (h) required")
	if not is_valid_ref_format(h):
		raise HTTPException(status_code=400, detail="Invalid ref or hash")
	try:
		return get_commit_unified_diff(project, h)
	except (KeyError, pygit2.GitError, OSError, ValueError) as e:
		raise HTTPException(status_code=404, detail="Commit not found") from e


class SummaryRefOption(TypedDict):
	label: str
	value: str
	kind: str


class SummaryRefState(TypedDict):
	ref: str
	display: str
	commit_href: str
	browse_href: str
	log_href: str
	shortlog_href: str
	readme_html: str


def summary_ref_options(project: str) -> list[SummaryRefOption]:
	options: list[SummaryRefOption] = []
	for name, ref, _oid in git_get_heads_list(project):
		options.append({"label": name, "value": ref, "kind": "branch"})
	for name, ref, _oid in git_get_tags_list(project):
		options.append({"label": name, "value": ref, "kind": "tag"})
	return options


def summary_ref_state(project: str, requested_ref: str | None) -> SummaryRefState:
	ref = requested_ref or "HEAD"
	commit_oid = ""
	browse_ref = ref
	try:
		repo = open_repo(project)
		obj = repo.revparse_single(ref)
	except (KeyError, pygit2.GitError, OSError):
		ref = "HEAD"
		try:
			repo = open_repo(project)
			obj = repo.revparse_single(ref)
		except (KeyError, pygit2.GitError, OSError):
			obj = None
	if isinstance(obj, pygit2.Commit):
		commit_oid = str(obj.id)
	elif isinstance(obj, pygit2.Tag):
		try:
			target_obj = repo[obj.target]
		except (KeyError, pygit2.GitError):
			target_obj = None
		if isinstance(target_obj, pygit2.Commit):
			commit_oid = str(target_obj.id)
		elif target_obj is not None:
			browse_ref = str(target_obj.id)
	readme_html = ""
	readme = get_readme_at_ref_path(project, ref, "")
	if readme:
		readme_filename, readme_content = readme
		readme_html = _render_readme_card(project, ref, readme_filename, readme_content, "")
	log_ref = commit_oid or ref
	return {
		"ref": ref,
		"display": (commit_oid[:7] if commit_oid else ref) or "N/A",
		"commit_href": f"/project/{project}?a=commit&h={quote(commit_oid or ref, safe='')}",
		"browse_href": f"/project/{project}?a=tree&h={quote(browse_ref, safe='')}",
		"log_href": f"/project/{project}?a=log&h={quote(log_ref, safe='')}",
		"shortlog_href": f"/project/{project}?a=shortlog&h={quote(log_ref, safe='')}",
		"readme_html": readme_html,
	}


# ---------- Action handlers ----------


def git_object(project: str, h: str | None) -> Response:
	"""Show object by type: commit, tree, tag, or blob. Dispatches to the appropriate view."""
	if not h:
		raise HTTPException(status_code=400, detail="Object hash (h) required")
	if not is_valid_ref_format(h):
		raise HTTPException(status_code=400, detail="Invalid ref or hash")
	obj_type = git_get_type(project, h)
	if not obj_type:
		raise HTTPException(status_code=404, detail="Object not found")
	if obj_type == "commit":
		return git_commit(project, h)
	if obj_type == "tree":
		return git_tree(project, h, None)
	if obj_type == "tag":
		return git_tag(project, h)
	if obj_type == "blob":
		try:
			repo = open_repo(project)
			obj = repo.revparse_single(h)
		except (KeyError, pygit2.GitError, OSError) as e:
			raise HTTPException(status_code=404, detail="Object not found") from e
		if not isinstance(obj, pygit2.Blob):
			raise HTTPException(status_code=404, detail="Not a blob")
		data = obj.data
		text = to_utf8(data) or ""
		body = jinja_escape(sanitize(text) or "") or ""
		lines = text.split("\n")
		num_lines = max(1, len(lines))
		line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
		oid_short = str(obj.id)[:7]
		blob_html = (
			f'<div class="blob-view">'
			f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
			f'<pre class="blob-content"><code class="hljs">{body}</code></pre>'
			f"</div>"
		)
		pre = PREAMBLE.render(
			title=f"Blob {oid_short} - {project}",
			site_name=settings.SITE_NAME,
		)
		return HTMLResponse(
			f"{pre}<h1>Blob {jinja_escape(oid_short)}</h1>"
			f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
			f"{blob_html}{POSTAMBLE}"
		)
	raise HTTPException(status_code=404, detail="Unknown object type")


def git_search(project: str, request: Request) -> JSONResponse:
	qp = request.query_params
	patterns = _split_query_list(qp.getlist("patterns"))
	if not patterns:
		raise HTTPException(status_code=400, detail="patterns is required (repeat ?patterns= or use comma-separated)")

	project_root = _project_worktree_root(project)
	paths = _validated_search_paths(project_root, _split_query_list(qp.getlist("paths")))

	globs = _split_query_list(qp.getlist("globs"))
	heading = _parse_query_bool(qp.get("heading"))
	multiline = _parse_query_bool(qp.get("multiline"))
	sort = _parse_search_sort(qp.get("sort"))

	max_count_raw = qp.get("max_count")
	max_count: int = 25
	if max_count_raw is not None and max_count_raw.strip() != "":
		try:
			max_count = int(max_count_raw)
		except ValueError as e:
			raise HTTPException(status_code=400, detail="max_count must be an integer") from e
		if max_count < 1:
			raise HTTPException(status_code=400, detail="max_count must be at least 1")

	try:
		results = rg_search(
			patterns=patterns,
			paths=paths,
			globs=(globs or None),
			heading=heading,
			sort=sort,
			max_count=max_count,
			multiline=multiline,
		)
	except Exception as e:
		raise HTTPException(status_code=500, detail=f"Search failed: {e}") from e

	root_str = str(project_root.resolve())
	rel_paths: list[str] = []
	for p in paths:
		abs_p = str(Path(p).resolve())
		rel = os.path.relpath(abs_p, root_str)
		rel_paths.append("" if rel == "." else rel.replace(os.sep, "/"))

	return JSONResponse({
		"project": project,
		"project_root": root_str,
		"patterns": patterns,
		"paths": rel_paths,
		"globs": globs,
		"heading": heading,
		"sort": (qp.get("sort") or None),
		"max_count": max_count,
		"multiline": multiline,
		"results": results,
	})


def git_search_page(project: str) -> HTMLResponse:
	"""Per-project search UI page. Calls /project/{project}?a=search via fetch."""
	project_enc = quote(project, safe="/")
	pre = PREAMBLE.render(
		title=f"Search - {project}",
		site_name=settings.SITE_NAME,
	)
	body = env.get_template("search.html").render(
		project=project,
		project_enc=project_enc,
		project_url=f"/project/{project_enc}",
		search_api_url=f"/project/{project_enc}",
	)
	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")


def git_summary(project: str, extra_rows: list[list[str]] | None = None) -> HTMLResponse:
	"""Project summary page. Port of git_summary."""
	descr = git_get_project_description(project) or ""
	owner = git_get_project_owner(project) or ""
	initial_ref_state = summary_ref_state(project, "HEAD")
	project_enc = quote(project, safe="/")

	try:
		board_refs = git_get_references(project, "refs/tags/boards")
		has_boards = len(board_refs) > 0
	except Exception:
		has_boards = False
	if has_boards:
		board_value = f'<a href="/project/{project_enc}/board/">project board</a>'
	else:
		grey_style = ' style="color: #999; cursor: not-allowed;"' if settings.AUTH else ""
		proj_q = quote(project, safe="")
		board_value = f'<a href="/board/create?project={proj_q}"{grey_style}>create board</a>'

	rows: list[list[str]] = [
		["Owner", jinja_escape(owner) or ""],
		[
			(
				'<label for="summary-ref-select">Ref</label> '
				'<select id="summary-ref-select">'
				'<option value="HEAD" selected>HEAD</option>'
				"</select>"
			),
			(
				f'<a id="summary-ref-commit-link" href="{initial_ref_state["commit_href"]}">'
				f"{initial_ref_state['display']}</a>: "
				f'<a id="summary-ref-browse-link" href="{initial_ref_state["browse_href"]}">browse</a> - '
				f'<a id="summary-ref-log-link" href="{initial_ref_state["log_href"]}">log</a> - '
				f'<a id="summary-ref-shortlog-link" href="{initial_ref_state["shortlog_href"]}">shortlog</a>'
			),
		],
		["Branches", f"<a href='/project/{project}?a=heads'>view branches</a>"],
		["Tags", f"<a href='/project/{project}?a=tags'>view tags</a>"],
		["Remotes", f"<a href='/project/{project}?a=remotes'>view remotes</a>"],
		["Board", board_value],
		["Search", f"<a href='/project/{project_enc}/search'>search files</a>"],
	]
	if extra_rows:
		rows.extend(extra_rows)
	table = env.get_template("table.html").render(
		cols=["Field", "Value"],
		rows=rows,
	)
	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - {project}", site_name=settings.SITE_NAME)
	body_parts = [f"{pre}<h1>{jinja_escape(project)}</h1>"]
	if descr:
		body_parts.append(f"<p>{jinja_escape(descr)}</p>")
	body_parts.append(table)
	body_parts.append(f'<div id="summary-readme-container" data-project="{jinja_escape(project)}">')
	body_parts.append(f"{initial_ref_state['readme_html']}</div>")
	body_parts.append('<script src="/static/summary-ref-switcher.js"></script>')

	body_parts.append(POSTAMBLE)
	return HTMLResponse("".join(body_parts))


def git_remotes(project: str) -> HTMLResponse:
	"""Remotes page: list configured remotes (name, url, push_url)."""
	remotes = git_get_remotes_info(project)
	if not remotes:
		pre = PREAMBLE.render(title=f"Remotes - {project}", site_name=settings.SITE_NAME)
		return HTMLResponse(f"{pre}<h1>Remotes</h1><p>No remotes configured.</p>{POSTAMBLE}")
	rows = []
	for r in remotes:
		name = jinja_escape(r["name"])
		url = jinja_escape(r["url"] or "—")
		push_url = jinja_escape(r["push_url"] or "—")
		rows.append([name, url, push_url])
	table = env.get_template("table.html").render(
		cols=["Name", "URL", "Push URL"],
		rows=rows,
	)
	pre = PREAMBLE.render(title=f"Remotes - {project}", site_name=settings.SITE_NAME)
	return HTMLResponse(f"{pre}<h1>Remotes</h1>{table}{POSTAMBLE}")


def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -> Response:
	"""Return blob content when f points to a file. Raw = bytes; else HTML with escaped content."""
	if not f:
		raise HTTPException(status_code=400, detail="File path (f) required")
	if not is_valid_pathname(f):
		raise HTTPException(status_code=400, detail="Invalid path")
	result = get_blob_at_ref_path(project, h, f)
	if not result:
		raise HTTPException(status_code=404, detail="File not found")
	blob, _ = result
	data = blob.data
	if raw:
		media_type, _ = mimetypes.guess_type(f.split("/")[-1])
		if media_type is None:
			try:
				data.decode("utf-8")
				media_type = "text/plain; charset=utf-8"
			except UnicodeDecodeError:
				media_type = "application/octet-stream"
		return Response(content=data, media_type=media_type)
	text = to_utf8(data) or ""
	body = jinja_escape(sanitize(text) or "") or ""
	lines = text.split("\n")
	num_lines = max(1, len(lines))
	line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
	ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
	lang = BLOB_LANG.get(ext, "")
	lang_attr = f" language-{lang}" if lang else ""
	blob_html = (
		f'<div class="blob-view">'
		f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
		f'<pre class="blob-content"><code class="hljs{lang_attr}">{body}</code></pre>'
		f"</div>"
	)
	blob_script = '<script src="/static/blob-view.js"></script>'
	pre = PREAMBLE.render(title=f"{f} - {project}", site_name=settings.SITE_NAME)
	return HTMLResponse(f"{pre}{blob_html}{blob_script}{POSTAMBLE}")


def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
	"""Tree page: list files and directories; directories link to tree with f=path."""
	if f is not None and not is_valid_pathname(f):
		raise HTTPException(status_code=400, detail="Invalid path")
	result = get_tree_at_ref_path(project, h, f)
	if not result:
		raise HTTPException(status_code=404, detail="Tree or path not found")
	tree, ref_oid = result
	base = f"/project/{project}"
	breadcrumbs = [f'<a href="{base}">{jinja_escape(project)}</a>']
	if f:
		parts = f.strip("/").split("/")
		for i, seg in enumerate(parts):
			prefix = "/".join(parts[: i + 1])
			breadcrumbs.append(f' / <a href="{_tree_url(project, ref_oid, prefix)}">{jinja_escape(seg)}</a>')
	breadcrumb_html = "".join(breadcrumbs)
	entries = [(obj.name, obj.type_str, str(obj.id)) for obj in tree]
	dirs = sorted((n, t, o) for n, t, o in entries if t == "tree")
	blobs = sorted((n, t, o) for n, t, o in entries if t == "blob")
	rows = []
	for name, _, _ in dirs:
		sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
		link = _tree_url(project, ref_oid, sub_path)
		rows.append([f'<a href="{link}">{jinja_escape(name)}/</a>', "tree"])
	for name, _, _ in blobs:
		sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
		link = _tree_url(project, ref_oid, sub_path, a="blob")
		rows.append([f'<a href="{link}">{jinja_escape(name)}</a>', "blob"])
	title_path = f" / {f}" if f else ""
	pre = PREAMBLE.render(
		title=f"{jinja_escape(project)}{jinja_escape(title_path)} - Tree",
		site_name=settings.SITE_NAME,
	)
	table = env.get_template("table.html").render(cols=["Name", "Type"], rows=rows)
	body_parts = [f"{pre}<p class='breadcrumb'>{breadcrumb_html}</p><h1>Tree{jinja_escape(title_path)}</h1>{table}"]
	readme = get_readme_at_ref_path(project, ref_oid, f or "")
	if readme:
		readme_filename, readme_content = readme
		body_parts.append(_render_readme_card(project, ref_oid, readme_filename, readme_content, f or ""))
	body_parts.append(POSTAMBLE)
	return HTMLResponse("".join(body_parts))


def git_log(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
	"""Commit log page. Port of git_log."""
	skip = (page - 1) * pagecount
	commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
	if not commits:
		raise HTTPException(status_code=404, detail="No commits found")
	has_next = len(commits) > pagecount
	if has_next:
		commits = commits[:pagecount]
	rows = []
	for commit in commits:
		rows.append(_format_commit_table_row(project, commit, short=False))
	ref_display = h[:7] if h else "HEAD"
	title = f"Log - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
	table = env.get_template("table.html").render(
		cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"], rows=rows
	)
	pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
	return HTMLResponse(f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}")


def git_shortlog(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
	"""Shortlog page. Port of git_shortlog."""
	skip = (page - 1) * pagecount
	commits = get_commit_history(project, ref=h, max_count=pagecount + 1, skip=skip)
	if not commits:
		raise HTTPException(status_code=404, detail="No commits found")
	has_next = len(commits) > pagecount
	if has_next:
		commits = commits[:pagecount]
	rows = []
	for commit in commits:
		rows.append(_format_commit_table_row(project, commit, short=True))
	ref_display = h[:7] if h else "HEAD"
	title = f"Shortlog - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
	table = env.get_template("table.html").render(cols=["Commit", "Subject", "Author", "Age", "Diff"], rows=rows)
	pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
	return HTMLResponse(f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}")


def git_history(
	project: str,
	h: str | None,
	f: str | None,
	request: Request,
	page: int,
	pagecount: int,
) -> HTMLResponse:
	"""History page for a file or directory. Port of git_history."""
	if not f:
		raise HTTPException(status_code=400, detail="File path (f) required for history")
	if not is_valid_pathname(f):
		raise HTTPException(status_code=400, detail="Invalid path")
	skip = (page - 1) * pagecount
	commits = get_commit_history(project, ref=h, path=f, max_count=pagecount + 1, skip=skip)
	if not commits:
		raise HTTPException(status_code=404, detail="No history found for this path")
	has_next = len(commits) > pagecount
	if has_next:
		commits = commits[:pagecount]
	rows = []
	for commit in commits:
		rows.append(_format_commit_table_row(project, commit, short=False))
	ref_display = h[:7] if h else "HEAD"
	title = f"History - {jinja_escape(f)} - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
	table = env.get_template("table.html").render(
		cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"], rows=rows
	)
	pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
	return HTMLResponse(
		f"{pre}<h1>{title}</h1>"
		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
		f"{table}{pagination_html}{POSTAMBLE}"
	)


def git_heads(project: str) -> HTMLResponse:
	"""Heads (branches) list page. Port of git_heads."""
	heads_list = git_get_heads_list(project)
	if not heads_list:
		raise HTTPException(status_code=404, detail="No heads found")
	rows = []
	for name, _ref, oid in heads_list:
		oid_short = oid[:7] if oid else ""
		commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
		tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
		rows.append([
			f'<a href="{commit_link}">{jinja_escape(name)}</a>',
			f'<a href="{commit_link}">{jinja_escape(oid_short)}</a>',
			f'<a href="{tree_link}">tree</a>',
		])
	title = f"Heads - {jinja_escape(project)}"
	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
	table = env.get_template("table.html").render(
		cols=["Head", "Commit", ""],
		rows=rows,
	)
	return HTMLResponse(
		f"{pre}<h1>{title}</h1>"
		+ f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>{table}"
		+ POSTAMBLE
	)


def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLResponse:
	"""Tags list page. Port of git_tags."""
	tags_list = git_get_tags_list(project)
	if not tags_list:
		raise HTTPException(status_code=404, detail="No tags found")
	total = len(tags_list)
	total_pages = (total + pagecount - 1) // pagecount if pagecount else 1
	if page > total_pages and total > 0:
		raise HTTPException(status_code=404, detail="Page does not exist")
	skip = (page - 1) * pagecount
	tags_page = tags_list[skip : skip + pagecount]
	try:
		repo = open_repo(project)
	except Exception as e:
		raise HTTPException(status_code=404, detail="Repository not found") from e
	rows = []
	for name, _ref, oid in tags_page:
		tag_link = f"/project/{project}?a=tag&h={quote(oid, safe='')}"
		target_oid = oid
		target_type = "commit"
		try:
			obj = repo.revparse_single(oid)
			if isinstance(obj, pygit2.Tag):
				target_oid = str(obj.target)
				target_type = obj.type_str
		except (KeyError, pygit2.GitError):
			pass
		target_short = target_oid[:7] if target_oid else ""
		if target_type == "commit":
			target_link = f"/project/{project}?a=commit&h={quote(target_oid, safe='')}"
		elif target_type == "tree":
			target_link = f"/project/{project}?a=tree&h={quote(target_oid, safe='')}"
		else:
			target_link = None
		obj_cell = (
			f'<a href="{target_link}">{jinja_escape(target_short)}</a>' if target_link else jinja_escape(target_short)
		)
		rows.append([
			f'<a href="{tag_link}">{jinja_escape(name)}</a>',
			obj_cell,
		])
	title = f"Tags - {jinja_escape(project)}"
	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
	table = env.get_template("table.html").render(
		cols=["Tag", "Object"],
		rows=rows,
	)
	pagination_html = _render_pagination(
		request, page, pagecount, page > 1, page < total_pages, total_pages=total_pages
	)
	return HTMLResponse(
		f"{pre}<h1>{title}</h1>"
		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
		f"{table}{pagination_html}{POSTAMBLE}"
	)


def git_tag(project: str, h: str | None) -> HTMLResponse:
	"""Single tag view. Port of git_tag."""
	if not h:
		raise HTTPException(status_code=400, detail="Tag ref or hash (h) required")
	if not is_valid_ref_format(h):
		raise HTTPException(status_code=400, detail="Invalid ref or hash")
	try:
		repo = open_repo(project)
		obj = repo.revparse_single(h)
	except (KeyError, pygit2.GitError, OSError) as e:
		raise HTTPException(status_code=404, detail="Tag or object not found") from e
	if not isinstance(obj, pygit2.Tag):
		raise HTTPException(status_code=404, detail="Not a tag object")
	tag_oid = str(obj.id)
	tag_data = parse_tag(project, tag_oid)
	if not tag_data:
		raise HTTPException(status_code=404, detail="Tag not found")
	target_oid = tag_data.get("object", "")
	target_type = tag_data.get("type", "commit")
	tagger = tag_data.get("tagger", "")
	tagger_epoch = tag_data.get("tagger_epoch")
	tagger_tz = tag_data.get("tagger_tz")
	message = (tag_data.get("message") or "").strip()
	tag_name = None
	for name, _ref, oid in git_get_tags_list(project):
		if oid == tag_oid:
			tag_name = name
			break
	if tag_name is None:
		tag_name = tag_oid[:7]
	target_short = target_oid[:7] if target_oid else ""
	if target_type == "commit":
		object_link = (
			f'<a href="/project/{project}?a=commit&h={quote(target_oid, safe="")}">{jinja_escape(target_short)}</a>'
		)
	elif target_type == "tree":
		object_link = (
			f'<a href="/project/{project}?a=tree&h={quote(target_oid, safe="")}">{jinja_escape(target_short)}</a>'
		)
	else:
		object_link = jinja_escape(target_short) or ""
	table_rows = [
		["Tag", jinja_escape(tag_name)],
		["Object", f"{object_link} ({jinja_escape(target_type)})"],
		["Tagger", jinja_escape(tagger)],
		["Date", jinja_escape(_format_date(tagger_epoch, tagger_tz))],
	]
	if message:
		table_rows.append(["Message", f"<pre class='tag-message'>{jinja_escape(message)}</pre>"])
	table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
	title = f"Tag {jinja_escape(tag_name)} - {jinja_escape(project)}"
	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
	return HTMLResponse(
		f"{pre}<h1>Tag {jinja_escape(tag_name)}</h1>"
		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
		f"{table}{POSTAMBLE}"
	)


def git_commit(project: str, h: str | None) -> HTMLResponse:
	"""Single commit view. Port of git_commit (git show)."""
	if not h:
		raise HTTPException(status_code=400, detail="Commit hash (h) required")
	if not is_valid_ref_format(h):
		raise HTTPException(status_code=400, detail="Invalid ref or hash")
	try:
		repo = open_repo(project)
		obj = repo.revparse_single(h)
	except (KeyError, pygit2.GitError, OSError) as e:
		raise HTTPException(status_code=404, detail="Commit not found") from e
	if not isinstance(obj, pygit2.Commit):
		raise HTTPException(status_code=404, detail="Not a commit object")
	commit = obj
	oid = str(commit.id)
	oid_short = oid[:7]
	data = parse_commit(project, oid)
	author = data.get("author", "")
	author_email = data.get("author_email", "")
	author_date = _format_date(data.get("author_epoch"), data.get("author_tz"))
	tree_oid = data.get("tree", "")
	message = (data.get("body") or "").strip()
	commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
	tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
	diff_link = f"/project/{project}?a=commitdiff&h={quote(oid, safe='')}"
	patch_link = f"/project/{project}?a=patch&h={quote(oid, safe='')}"
	table_rows = [
		["commit", f'<a href="{commit_link}">{jinja_escape(oid)}</a>'],
		["Author", f"{jinja_escape(author)} &lt;{jinja_escape(author_email)}&gt;"],
		["Date", jinja_escape(author_date)],
		["tree", f'<a href="{tree_link}">{jinja_escape(tree_oid[:7])}</a>'],
	]
	for i, parent_id in enumerate(commit.parent_ids):
		parent_oid = str(parent_id)
		parent_short = parent_oid[:7]
		parent_link = f"/project/{project}?a=commit&h={quote(parent_oid, safe='')}"
		label = "parent" if len(commit.parent_ids) == 1 else f"parent ({i + 1})"
		table_rows.append([label, f'<a href="{parent_link}">{jinja_escape(parent_short)}</a>'])
	if message:
		table_rows.append(["", f"<pre class='commit-message'>{jinja_escape(message)}</pre>"])
	table_rows.append(["", f'<a href="{diff_link}">diff</a> · <a href="{patch_link}">patch</a>'])
	table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
	title = f"Commit {jinja_escape(oid_short)} - {jinja_escape(project)}"
	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
	return HTMLResponse(
		f"{pre}<h1>Commit {jinja_escape(oid_short)}</h1>"
		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
		f"{table}{POSTAMBLE}"
	)


def git_commitdiff(project: str, h: str | None) -> HTMLResponse:
	"""Commit diff page: unified diff rendered with diff2html (script id diff2html-script)."""
	diff_text, oid_short = _commit_unified_diff_or_raise(project, h or "")
	diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
	title = f"Commit diff {oid_short} - {project}"
	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
	body = env.get_template("commitdiff.html").render(
		project=project,
		oid_short=oid_short,
		diff_b64=diff_b64,
		commit_link=f"/project/{project}?a=commit&h={quote(h or '', safe='')}",
	)
	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")


def git_patch(project: str, h: str | None) -> PlainTextResponse:
	"""Single-commit patch (plain text unified diff)."""
	if not h:
		raise HTTPException(status_code=400, detail="Commit hash (h) required")
	body, oid_short = _commit_unified_diff_or_raise(project, h)
	filename = f"{project}-{oid_short}.patch"
	return PlainTextResponse(
		body,
		media_type="text/x-diff; charset=utf-8",
		headers={"Content-Disposition": f'inline; filename="{filename}"'},
	)


def git_patches(project: str, h: str | None, hb: str | None) -> PlainTextResponse:
	"""Multi-commit patches (plain text). Range hb..h if hb given, else single commit."""
	if not h:
		raise HTTPException(status_code=400, detail="Commit hash (h) required")
	if hb:
		oids = get_commits_in_range(project, h, hb)
		if not oids:
			raise HTTPException(status_code=404, detail="No commits in range")
		parts = []
		for oid in oids:
			try:
				diff, _ = get_commit_unified_diff(project, oid)
				if diff:
					parts.append(diff)
			except (KeyError, pygit2.GitError, OSError, ValueError):
				pass
		body = "\n".join(parts)
		filename = f"{project}-{h[:7]}-patches.patch"
	else:
		body, oid_short = _commit_unified_diff_or_raise(project, h)
		filename = f"{project}-{oid_short}.patch"
	return PlainTextResponse(
		body,
		media_type="text/x-diff; charset=utf-8",
		headers={"Content-Disposition": f'inline; filename="{filename}"'},
	)


def git_blobdiff(
	project: str,
	h: str | None,
	hb: str | None,
	f: str | None,
	fp: str | None,
) -> HTMLResponse:
	"""Blob diff page: diff between two blob versions, rendered with diff2html."""
	if not h or not hb:
		raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
	if not f:
		raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
	if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
		raise HTTPException(status_code=400, detail="Invalid ref or hash")
	path_new = f or ""
	path_old = fp if fp is not None else path_new
	if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
		raise HTTPException(status_code=400, detail="Invalid path")
	diff_text = get_blob_unified_diff(project, hb, path_old, h, path_new)
	if diff_text is None:
		raise HTTPException(status_code=404, detail="Blob not found")
	diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
	blob_link = f"/project/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}"
	pre = PREAMBLE.render(
		title=f"Blob diff - {jinja_escape(path_new)} - {jinja_escape(project)}",
		site_name=settings.SITE_NAME,
	)
	body = env.get_template("blobdiff.html").render(
		project=project,
		path_new=path_new,
		diff_b64=diff_b64,
		blob_link=blob_link,
	)
	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")


def git_blobpatch(
	project: str,
	h: str | None,
	hb: str | None,
	f: str | None,
	fp: str | None,
) -> PlainTextResponse:
	"""Blob diff as plain unified diff."""
	if not h or not hb:
		raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
	if not f:
		raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
	if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
		raise HTTPException(status_code=400, detail="Invalid ref or hash")
	path_new = f or ""
	path_old = fp if fp is not None else path_new
	if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
		raise HTTPException(status_code=400, detail="Invalid path")
	body = get_blob_unified_diff(project, hb, path_old, h, path_new)
	if body is None:
		raise HTTPException(status_code=404, detail="Blob not found")
	filename = f"{path_new.split('/')[-1] or 'blob'}.patch"
	return PlainTextResponse(
		body,
		media_type="text/x-diff; charset=utf-8",
		headers={"Content-Disposition": f'inline; filename="{filename}"'},
	)