"""
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


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)
        commit_or_tree = repo.revparse_single(base)
        if hasattr(commit_or_tree, "tree"):
            tree = commit_or_tree.tree
        else:
            tree = commit_or_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.tree if hasattr(obj, "tree") else obj
        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
        subtree = repo[entry.id]
        if not isinstance(subtree, pygit2.Tree):
            return None
        return (subtree, 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.tree if hasattr(obj, "tree") else obj
        path_clean = path.strip("/")
        entry = base_tree / path_clean
        if not entry or entry.type_str != "blob":
            return None
        blob = repo[entry.id]
        if not isinstance(blob, pygit2.Blob):
            return None
        return (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)
        commit_or_tree = repo.revparse_single(base)
        tree = commit_or_tree.tree if hasattr(commit_or_tree, "tree") else commit_or_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)
        refs = []
        for ref_name in repo.references:
            if ref_name.startswith(ref_prefix + "/"):
                r = repo.references[ref_name]
                target = r.target if hasattr(r, "target") else r.resolve()
                refs.append((ref_name, str(target)))
        return refs
    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 git_get_tags_list(project: str) -> list[tuple[str, str, str]]:
    """List (name, ref, oid) for tags. Port of git_get_tags_list."""
    refs = git_get_references(project, "refs/tags")
    return [(ref.replace("refs/tags/", ""), ref, oid) for ref, oid in refs]


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)
    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,
) -> 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.
    """
    try:
        repo = open_repo(project)
        start_oid = None
        if ref:
            obj = repo.revparse_single(ref)
            if isinstance(obj, pygit2.Commit):
                start_oid = obj.id
            elif hasattr(obj, "target"):
                # Tag or other object with target
                target = repo[obj.target]
                if isinstance(target, pygit2.Commit):
                    start_oid = target.id
        else:
            # Default to HEAD
            if repo.head:
                start_oid = repo.head.target
        
        if not start_oid:
            return []
        
        commits = []
        walker = repo.walk(start_oid, pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL)
        
        if path:
            # Filter by path: only commits that touched this path
            path_clean = path.strip("/")
            for commit in walker:
                if len(commits) >= max_count:
                    break
                # Check if this commit touched the path
                try:
                    # Get tree for this commit
                    tree = commit.tree
                    # Check if path exists in this commit's tree
                    entry = None
                    try:
                        entry = tree / path_clean if path_clean else None
                    except (KeyError, AttributeError):
                        pass
                    
                    # Check if path was changed in this commit (compare with parent)
                    path_changed = False
                    if commit.parents:
                        parent = commit.parents[0]
                        try:
                            parent_tree = parent.tree
                            parent_entry = None
                            try:
                                parent_entry = parent_tree / path_clean if path_clean else None
                            except (KeyError, AttributeError):
                                pass
                            
                            # Path changed if it exists in one but not the other, or OIDs differ
                            if (parent_entry is None) != (entry is None):
                                path_changed = True
                            elif parent_entry is not None and entry is not None:
                                if str(parent_entry.id) != str(entry.id):
                                    path_changed = True
                        except (KeyError, AttributeError):
                            # If we can't compare, assume it changed if entry exists
                            path_changed = entry is not None
                    else:
                        # Root commit: include if path exists
                        path_changed = entry is not None
                    
                    if path_changed or entry:
                        commit_data = parse_commit(project, str(commit.id))
                        commit_data["oid"] = str(commit.id)
                        commits.append(commit_data)
                except (KeyError, AttributeError):
                    # Skip commits we can't process
                    pass
        else:
            # No path filter, get all commits
            for commit in walker:
                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 []