"""
Git helpers via pygit2: open repo, rev_parse, config, refs, ls_tree, cat_file, etc.
Ported from gitweb/gitweb.perl (git_cmd, git_get_type, git_parse_project_config, config_to_bool,
config_to_int, git_get_project_config, git_get_file_or_project_config, git_get_project_description,
git_get_references, git_get_heads_list, git_get_tags_list, git_get_remotes_info, parse_commit,
parse_tag, etc.).
"""

from __future__ import annotations

import os
import re
from contextlib import suppress
from typing import Any

import pygit2

from pygitweb.config import settings
from pygitweb.formatting import to_utf8

# Common README filenames to look for (order matters: prefer README.md)
README_CANDIDATES = ["README.md", "README", "readme.md", "readme", "Readme.md"]


def _repo_path(project: str) -> str:
	return os.path.join(settings.PROJECTROOT, project)


def open_repo(project: str):
	"""Open pygit2.Repository for project. Replaces git_cmd + --git-dir."""
	path = _repo_path(project)
	return pygit2.Repository(path)


def git_get_type(project: str, ref: str) -> str | None:
	"""Object type: commit, tree, blob, tag. Port of git_get_type (pygit2 obj.type)."""
	try:
		repo = open_repo(project)
		obj = repo.revparse_single(ref)
		return obj.type_str if obj else None
	except (KeyError, pygit2.GitError, OSError):
		return None


def hash_set_multi(d: dict[str, Any], key: str, value: Any) -> None:
	"""Store multi-value: first value direct, rest in list. Port of hash_set_multi."""
	if key not in d:
		d[key] = value
	elif not isinstance(d[key], list):
		d[key] = [d[key], value]
	else:
		d[key].append(value)


def git_parse_project_config(project: str, section_regexp: str | None = None) -> dict[str, Any]:
	"""All config key/values; optionally filter by section. Port of git_parse_project_config."""
	try:
		repo = open_repo(project)
		cfg = repo.config
		result: dict[str, Any] = {}
		for entry in cfg:
			key = entry.name
			if section_regexp and not re.search(rf"^(?:{section_regexp})\.", key):
				continue
			value = entry.value
			hash_set_multi(result, key, value)
		return result
	except (pygit2.GitError, OSError):
		return {}


def config_to_bool(val: str | None) -> bool:
	"""Config value to bool: true/yes/1. Port of config_to_bool."""
	if val is None:
		return True
	val = (val or "").strip()
	if re.match(r"^\d+$", val):
		return int(val) != 0
	return val.lower() in ("true", "yes")


def config_to_int(val: str | None) -> int | str:
	"""Config value to int; k/m/g suffix. Port of config_to_int."""
	if val is None:
		return 0
	val = (val or "").strip()
	m = re.match(r"^([0-9]*)([kmg])$", val, re.I)
	if m:
		num, unit = m.group(1), m.group(2).lower()
		mult = {"k": 1024, "m": 1048576, "g": 1073741824}.get(unit, 1)
		return int(num or 0) * mult
	return val


# Per-repo cache for gitweb config (git_parse_project_config result)
_config_cache: dict[str, tuple[str, dict[str, Any]]] = {}


def git_get_project_config(
	project: str,
	key: str,
	config_type: str | None = None,
) -> int | str | list[str] | bool | None:
	"""Single config value; gitweb.* section. Port of git_get_project_config."""
	key = key.lower().replace("_", "")
	if key.startswith("gitweb."):
		key = key[7:]
	if re.search(r"\W", key):
		return None
	full_key = f"gitweb.{key}"
	git_dir = _repo_path(project)
	cache_key = git_dir
	if cache_key not in _config_cache or _config_cache[cache_key][0] != os.path.join(git_dir, "config"):
		cfg = git_parse_project_config(project, "gitweb")
		_config_cache[cache_key] = (os.path.join(git_dir, "config"), cfg)
	_, cfg = _config_cache[cache_key]
	raw = cfg.get(full_key)
	if raw is None:
		return None
	if config_type == "bool" or config_type == "--bool":
		return config_to_bool(raw[0] if isinstance(raw, list) else raw)
	if config_type == "int" or config_type == "--int":
		return config_to_int(raw[0] if isinstance(raw, list) else raw)
	if isinstance(raw, list):
		return raw[0] if len(raw) == 1 else raw
	return raw


def get_tree_at_ref_path(project: str, ref: str | None, path: str | None) -> tuple[pygit2.Tree, str] | None:
	"""
	Resolve the tree at ref (commit or tree) and optional path.
	Returns (tree, ref_oid) for listing, or None if not found.
	ref_oid is the resolved OID to use in URLs (same revision).
	"""
	try:
		repo = open_repo(project)
		base_ref = ref or (str(repo.head.target) if repo.head else None)
		if not base_ref:
			return None
		obj = repo.revparse_single(base_ref)
		ref_oid = str(obj.id)
		base_tree = obj.peel(pygit2.Tree)
		if not path or not path.strip("/"):
			return (base_tree, ref_oid)
		path_clean = path.strip("/")
		entry = base_tree / path_clean
		if not entry or entry.type_str != "tree":
			return None
		return (repo[entry.id].peel(pygit2.Tree), ref_oid)
	except (KeyError, pygit2.GitError, OSError):
		return None


def get_blob_at_ref_path(project: str, ref: str | None, path: str | None) -> tuple[pygit2.Blob, str] | None:
	"""
	Resolve the blob at ref (commit or tree) and path.
	Returns (blob, ref_oid) or None if not found or not a blob.
	"""
	if not path or not path.strip("/"):
		return None
	try:
		repo = open_repo(project)
		base_ref = ref or (str(repo.head.target) if repo.head else None)
		if not base_ref:
			return None
		obj = repo.revparse_single(base_ref)
		ref_oid = str(obj.id)
		base_tree = obj.peel(pygit2.Tree)
		path_clean = path.strip("/")
		entry = base_tree / path_clean
		if not entry or entry.type_str != "blob":
			return None
		return (repo[entry.id].peel(pygit2.Blob), ref_oid)
	except (KeyError, pygit2.GitError, OSError):
		return None


def git_get_file_or_project_config(project: str, name: str) -> str | None:
	"""Value from $GIT_DIR/name file or gitweb.name config. Port of git_get_file_or_project_config."""
	path = os.path.join(_repo_path(project), name)
	if os.path.isfile(path):
		try:
			with open(path) as f:
				return f.read().strip()
		except OSError:
			pass
	val = git_get_project_config(project, name)
	return val[0] if isinstance(val, list) else (val if isinstance(val, str) else None)


def git_get_project_description(project: str) -> str | None:
	"""Content of description file or config. Port of git_get_project_description."""
	return git_get_file_or_project_config(project, "description")


def git_get_references(project: str, ref_prefix: str = "refs/heads") -> list[tuple[str, str]]:
	"""List (ref_name, oid) for prefix. Port of git_get_references (pygit2 references)."""
	try:
		repo = open_repo(project)
		prefix = ref_prefix + "/"
		return [
			(ref_name, str(repo.references[ref_name].resolve().target))
			for ref_name in repo.references
			if ref_name.startswith(prefix)
		]
	except (pygit2.GitError, OSError):
		return []


def git_get_heads_list(project: str) -> list[tuple[str, str, str]]:
	"""List (name, ref, oid) for heads. Port of git_get_heads_list."""
	refs = git_get_references(project, "refs/heads")
	return [(ref.replace("refs/heads/", ""), ref, oid) for ref, oid in refs]


def _tag_timestamp(repo: pygit2.Repository, oid: str) -> int:
	"""Return tagger time for an annotated tag, or committer time for a lightweight tag (commit)."""
	try:
		obj = repo.revparse_single(oid)
		if isinstance(obj, pygit2.Tag) and obj.tagger:
			return obj.tagger.time
		if isinstance(obj, pygit2.Commit):
			return obj.committer.time
	except (KeyError, pygit2.GitError):
		pass
	return 0


def git_get_tags_list(project: str) -> list[tuple[str, str, str]]:
	"""List (name, ref, oid) for tags. Sorted by tag creation time descending (newest first)."""
	try:
		repo = open_repo(project)
		result = []
		for ref in repo.references.iterator(pygit2.GIT_REFERENCES_TAGS):
			oid = str(ref.resolve().target)
			name = ref.name.replace("refs/tags/", "")
			ts = _tag_timestamp(repo, oid)
			result.append((name, ref.name, oid, ts))
		result.sort(key=lambda x: (x[3], x[0]), reverse=True)
		return [(name, ref_name, oid) for name, ref_name, oid, _ in result]
	except (pygit2.GitError, OSError):
		return []


def git_get_remotes_info(project: str) -> list[dict[str, Any]]:
	"""Remote info: name, url, push_url. Uses pygit2 Remote.url and Remote.push_url."""
	try:
		repo = open_repo(project)
		result = []
		for name in repo.remotes.names():
			remote = repo.remotes[name]
			result.append({
				"name": name,
				"url": remote.url or "",
				"push_url": remote.push_url or remote.url or "",
			})
		return result
	except (pygit2.GitError, OSError):
		return []


def parse_commit(project: str, oid: str) -> dict[str, Any]:
	"""Commit metadata dict. Port of parse_commit (pygit2 commit)."""
	try:
		repo = open_repo(project)
		obj = repo.revparse_single(oid)
		if not isinstance(obj, pygit2.Commit):
			return {}
		commit = obj
		return {
			"parent": [str(p) for p in commit.parent_ids],
			"tree": str(commit.tree_id),
			"author": commit.author.name,
			"author_email": commit.author.email,
			"author_epoch": commit.author.time,
			"author_tz": commit.author.offset,
			"committer": commit.committer.name,
			"committer_email": commit.committer.email,
			"committer_epoch": commit.committer.time,
			"committer_tz": commit.committer.offset,
			"subject": commit.message.split("\n")[0] if commit.message else "",
			"body": commit.message or "",
		}
	except (KeyError, pygit2.GitError, OSError):
		return {}


def parse_tag(project: str, oid: str) -> dict[str, Any]:
	"""Tag metadata. Port of parse_tag (pygit2 tag)."""
	try:
		repo = open_repo(project)
		obj = repo.revparse_single(oid)
		if not isinstance(obj, pygit2.Tag):
			return {}
		tag = obj
		return {
			"object": str(tag.target),
			"type": tag.type_str,
			"tagger": tag.tagger.name if tag.tagger else "",
			"tagger_email": tag.tagger.email if tag.tagger else "",
			"tagger_epoch": tag.tagger.time if tag.tagger else 0,
			"tagger_tz": tag.tagger.offset if tag.tagger else 0,
			"message": tag.message or "",
		}
	except (KeyError, pygit2.GitError, OSError):
		return {}


def get_commit_history(
	project: str,
	ref: str | None = None,
	path: str | None = None,
	max_count: int = 100,
	skip: int = 0,
) -> list[dict[str, Any]]:
	"""
	Get commit history for a project, optionally filtered by path.
	Returns list of commit dicts with oid and parsed commit data.
	Port of git log functionality.
	skip: number of matching commits to skip (for pagination).
	"""
	try:
		repo = open_repo(project)
		if ref:
			try:
				start_oid = repo.revparse_single(ref).peel(pygit2.Commit).id
			except (KeyError, pygit2.GitError, ValueError):
				start_oid = None
		else:
			start_oid = repo.head.target if repo.head else None
		if not start_oid:
			return []

		commits: list[dict[str, Any]] = []
		skipped = 0
		walker = repo.walk(start_oid, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)

		if path:
			# Filter by path: only commits that touched this path (or a file under it)
			path_clean = path.strip("/")
			path_prefix = path_clean + "/"

			def touched_path(diff: pygit2.Diff) -> bool:
				for delta in diff.deltas:
					old_p, new_p = delta.old_file.path, delta.new_file.path
					if old_p == path_clean or new_p == path_clean:
						return True
					if old_p.startswith(path_prefix) or new_p.startswith(path_prefix):
						return True
				return False

			for commit in walker:
				if len(commits) >= max_count:
					break
				try:
					if commit.parents:
						diff = repo.diff(commit.parents[0], commit)
						if not touched_path(diff):
							continue
					else:
						# Root commit: include if path is root or path exists in tree
						if path_clean:
							try:
								commit.tree / path_clean
							except KeyError:
								continue
					if skipped < skip:
						skipped += 1
						continue
					commit_data = parse_commit(project, str(commit.id))
					commit_data["oid"] = str(commit.id)
					commits.append(commit_data)
				except (KeyError, AttributeError, pygit2.GitError):
					pass
		else:
			# No path filter, get all commits
			for commit in walker:
				if skipped < skip:
					skipped += 1
					continue
				if len(commits) >= max_count:
					break
				commit_data = parse_commit(project, str(commit.id))
				commit_data["oid"] = str(commit.id)
				commits.append(commit_data)

		return commits
	except (KeyError, pygit2.GitError, OSError):
		return []


def get_commits_in_range(project: str, tip: str, base: str | None) -> list[str]:
	"""Return list of commit OIDs from tip back to (but not including) base. Newest first."""
	try:
		repo = open_repo(project)
		tip_commit = repo.revparse_single(tip).peel(pygit2.Commit)
		walker = repo.walk(tip_commit.id, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
		if base:
			with suppress(KeyError, pygit2.GitError, ValueError):
				walker.hide(repo.revparse_single(base).peel(pygit2.Commit).id)
		return [str(c.id) for c in walker]
	except (KeyError, pygit2.GitError, OSError, ValueError):
		return []


def get_blob_unified_diff(
	project: str,
	ref_old: str,
	path_old: str,
	ref_new: str,
	path_new: str,
) -> str | None:
	"""
	Return unified diff between blob at ref_old:path_old and ref_new:path_new using pygit2.
	Returns None if either blob is not found; otherwise returns the diff string (possibly empty).
	"""
	result_old = get_blob_at_ref_path(project, ref_old, path_old)
	result_new = get_blob_at_ref_path(project, ref_new, path_new)
	if not result_old or not result_new:
		return None
	old_blob, _ = result_old
	new_blob, _ = result_new
	patch = old_blob.diff(new_blob, old_as_path=path_old, new_as_path=path_new)
	return patch.text if patch.text else ""


def get_readme_at_ref_path(project: str, ref: str | None, dir_path: str | None) -> tuple[str, str] | None:
	"""
	If a README exists at ref in the given tree (dir_path), return (filename, utf8_content).
	Otherwise None. dir_path is the tree path (e.g. '' for root, 'docs' for docs/).
	"""
	if not ref:
		return None
	for name in README_CANDIDATES:
		path = f"{dir_path.strip('/')}/{name}".strip("/") if dir_path else name
		result = get_blob_at_ref_path(project, ref, path)
		if result:
			blob, _ = result
			text = to_utf8(blob.data) or ""
			return (name, text)
	return None


def get_commit_unified_diff(project: str, h: str) -> tuple[str, str]:
	"""
	Return (unified_diff_text, oid_short) for commit h.
	Raises KeyError, pygit2.GitError, OSError on error (caller should validate ref and map to HTTPException).
	"""
	repo = open_repo(project)
	commit = repo.revparse_single(h).peel(pygit2.Commit)
	diff = repo.diff(commit.parents[0], commit) if commit.parents else commit.tree.diff_to_tree(swap=True)
	body = diff.patch or ""
	oid_short = commit.short_id[:7] if len(commit.short_id) >= 7 else commit.short_id
	return body, oid_short