"""
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, Literal, TypedDict
from urllib.parse import parse_qsl, 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.hooks_install import HookStatus, bundle_status
from pygitweb.merge_requests import merge_request_tag_response, resolve_merge_request_tag_to_tip
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")

SearchSortQuery = Literal[
	"Path",
	"-Path",
	"LastModified",
	"-LastModified",
	"LastAccessed",
	"-LastAccessed",
	"Created",
	"-Created",
]

SearchFlagQuery = Literal["true", "false"]


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(parse_qsl(request.url.query, keep_blank_values=True))
	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,
	search_patterns: str | None,
	search_paths: str | None,
	search_globs: str | None,
	search_heading: SearchFlagQuery | None,
	search_multiline: SearchFlagQuery | None,
	search_sort: SearchSortQuery | None,
	search_max_count: str | None,
) -> JSONResponse:
	patterns = _split_query_list([search_patterns] if search_patterns is not None else [])
	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([search_paths] if search_paths is not None else []))

	globs = _split_query_list([search_globs] if search_globs is not None else [])
	heading = _parse_query_bool(search_heading)
	multiline = _parse_query_bool(search_multiline)
	sort = _parse_search_sort(search_sort)

	max_count: int = 25
	if search_max_count is not None and search_max_count.strip() != "":
		try:
			max_count = int(search_max_count)
		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": (search_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}")


UPDATE_HOOK_BUNDLE: str = "update"


def _update_hook_cell(project: str, project_enc: str) -> str:
	"""Render the Update Hook row's value: status text + toggle button driven by hook-install.js.

	The "update" bundle wires both server-side (post-receive) and client-side (post-commit) notify
	hooks so long-poll subscribers wake on either pushes or local commits in working clones.
	"""
	tpl = env.get_template("update_hook_cell.html")
	try:
		current: HookStatus = bundle_status(project, UPDATE_HOOK_BUNDLE)
	except (KeyError, OSError):
		return tpl.render(mode="unavailable")
	if current == HookStatus.DIFFERENT:
		return tpl.render(mode="different")
	if current == HookStatus.INSTALLED:
		return tpl.render(
			mode="toggle",
			bundle=UPDATE_HOOK_BUNDLE,
			project_enc=project_enc,
			state_label="installed",
			button_label="Remove Update Hook",
			next_op="remove",
			button_class="btn btn-sm btn-ghost-secondary hook-toggle",
		)
	return tpl.render(
		mode="toggle",
		bundle=UPDATE_HOOK_BUNDLE,
		project_enc=project_enc,
		state_label="not installed",
		button_label="Install Update Hook",
		next_op="add",
		button_class="btn btn-sm btn-primary hook-toggle",
	)


def _merge_request_summary_cell(project: str) -> str:
	heads = git_get_heads_list(project)
	if not heads:
		return '<span class="text-muted">No branches.</span>'
	refs = [h[1] for h in heads]
	branches = [{"short": h[0], "ref": h[1]} for h in heads]
	if "refs/heads/main" in refs:
		ours_default_ref = "refs/heads/main"
	elif "refs/heads/master" in refs:
		ours_default_ref = "refs/heads/master"
	else:
		ours_default_ref = refs[0]
	theirs_default_ref = next((r for r in refs if r != ours_default_ref), refs[0])
	return env.get_template("merge_request_summary_cell.html").render(
		project=project,
		branches=branches,
		theirs_default_ref=theirs_default_ref,
		ours_default_ref=ours_default_ref,
	)


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>"],
		["Merge request", _merge_request_summary_cell(project)],
		["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>"],
		["Update Hook", _update_hook_cell(project, project_enc)],
	]
	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('<script src="/static/hook-install.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")
	mr_pair = resolve_merge_request_tag_to_tip(repo, obj)
	if mr_pair is not None:
		obj, mr = mr_pair
	else:
		mr = None
	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]
	if mr is not None:
		return merge_request_tag_response(
			project=project,
			repo=repo,
			mr=mr,
			tag_name=tag_name,
			tag_oid=tag_oid,
			tagger=tagger,
			tagger_date=_format_date(tagger_epoch, tagger_tz),
		)
	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}"'},
	)