"""
Git helpers via pygit2: open repo, rev_parse, config, refs, ls_tree, cat_file, etc.
Ported from gitweb/gitweb.perl (git_cmd, git_get_head_hash, git_get_hash, git_get_type,
git_parse_project_config, config_to_bool, config_to_int, config_to_multi, git_get_project_config,
git_get_hash_by_path, git_get_path_by_hash, git_get_file_or_project_config,
git_get_project_description, git_get_project_category, git_get_references, git_get_heads_list,
git_get_tags_list, git_get_remotes_list, parse_commit, parse_tag, etc.).
"""
from __future__ import annotations

import os
import re
from pathlib import Path
from typing import Any

import pygit2

# From config
from pygitweb.config import PROJECTROOT, GIT
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(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_head_hash(project: str) -> str | None:
    """HEAD commit OID. Port of git_get_head_hash (pygit2: repo.head.target)."""
    try:
        repo = open_repo(project)
        return str(repo.head.target) if repo.head else None
    except (pygit2.GitError, OSError):
        return None


def git_get_full_hash(project: str, ref: str) -> str | None:
    """Full OID for ref. Port of git_get_full_hash (pygit2 rev_parse)."""
    return git_get_hash(project, ref)


def git_get_short_hash(project: str, ref: str, length: int = 7) -> str | None:
    """Short OID. Port of git_get_short_hash."""
    full = git_get_hash(project, ref)
    return full[:length] if full else None


def git_get_hash(project: str, ref: str) -> str | None:
    """Resolve ref to full OID. Port of git_get_hash (pygit2 revparse_single)."""
    try:
        repo = open_repo(project)
        obj = repo.revparse_single(ref)
        return str(obj.id) if obj else None
    except (KeyError, pygit2.GitError, OSError):
        return None


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


def config_to_multi(val: Any) -> list[Any]:
    """Config value to list. Port of config_to_multi."""
    if isinstance(val, list):
        return val
    return [val] if val is not None else []


# 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,
) -> 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 git_get_hash_by_path(project: str, base: str, path: str, obj_type: str | None = None) -> str | None:
    """OID of path at base (tree-ish). Port of git_get_hash_by_path (pygit2 tree path lookup)."""
    try:
        repo = open_repo(project)
        tree = repo.revparse_single(base).peel(pygit2.Tree)
        path = path.rstrip("/")
        entry = tree / path
        if not entry:
            return None
        if obj_type and entry.type_str != obj_type:
            return None
        return str(entry.id)
    except (KeyError, pygit2.GitError, OSError):
        return None


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_path_by_hash(project: str, base: str, oid_str: str) -> str | None:
    """Path of object with given OID in base tree. Port of git_get_path_by_hash."""
    try:
        repo = open_repo(project)
        tree = repo.revparse_single(base).peel(pygit2.Tree)

        def find_in_tree(t: pygit2.Tree, prefix: str) -> str | None:
            for e in t:
                p = f"{prefix}{e.name}" if prefix else e.name
                if str(e.id) == oid_str:
                    return p
                if e.type_str == "tree":
                    subtree = repo[e.id]
                    if isinstance(subtree, pygit2.Tree):
                        found = find_in_tree(subtree, p + "/")
                        if found:
                            return found
            return None

        return find_in_tree(tree, "")
    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_project_category(project: str) -> str | None:
    """Category file. Port of git_get_project_category."""
    return git_get_file_or_project_config(project, "category")


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_list(project: str) -> list[str]:
    """Remote names. Port of git_get_remotes_list (pygit2 remotes)."""
    try:
        repo = open_repo(project)
        return list(repo.remotes.names())
    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 = []
        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:
            try:
                walker.hide(repo.revparse_single(base).peel(pygit2.Commit).id)
            except (KeyError, pygit2.GitError, ValueError):
                pass
        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)
    if commit.parents:
        diff = repo.diff(commit.parents[0], commit)
    else:
        diff = 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