"""Git identity helpers for the dashboard user manager."""

from __future__ import annotations

from collections import deque
import configparser
from pathlib import Path
from typing import Any

import shutil
import subprocess
import time
import zlib

import structlog

from proxym.dashboard.users._models import User


logger = structlog.get_logger()


class GitUserIdentityMixin:
    """Git identity and repository discovery helpers for UserManager."""

    def _candidate_git_repo_paths(self) -> list[Path]:
        """Return nearby directories that may contain the repository."""
        source_root = Path(__file__).resolve().parents[3]
        return [
            Path.cwd(),
            Path.cwd().parent,
            source_root,
            source_root.parent,
            Path("/app"),
        ]

    def _is_existing_directory(self, path: Path | None) -> bool:
        """Return True when a path points to an existing directory."""
        return path is not None and path.exists() and path.is_dir()

    def _resolve_git_root_candidate(self, candidate: Path) -> Path | None:
        """Resolve a single repository root candidate."""
        expanded = candidate.expanduser()
        if not self._is_existing_directory(expanded):
            return None

        if not self._git_binary_available():
            return self._repo_root_from_candidate(expanded)

        try:
            result = subprocess.run(
                ["git", "rev-parse", "--show-toplevel"],
                cwd=expanded,
                capture_output=True,
                text=True,
                timeout=3,
                stderr=subprocess.DEVNULL,
            )
        except Exception:
            return self._repo_root_from_candidate(expanded)

        if result.returncode != 0:
            return self._repo_root_from_candidate(expanded)

        root = Path(result.stdout.strip())
        if self._is_existing_directory(root):
            return root
        return self._repo_root_from_candidate(expanded)

    def _read_git_identity_from_cli(self) -> tuple[str, str]:
        """Read git name/email using the git CLI."""
        try:
            name = subprocess.check_output(
                ["git", "config", "user.name"],
                text=True,
                stderr=subprocess.DEVNULL,
            ).strip()
            email = subprocess.check_output(
                ["git", "config", "user.email"],
                text=True,
                stderr=subprocess.DEVNULL,
            ).strip()
        except Exception:
            return "", ""
        return name, email

    def _read_git_identity(self) -> tuple[str, str]:
        """Read the active git identity from CLI or repository config."""
        if self._git_binary_available():
            identity = self._read_git_identity_from_cli()
            if identity != ("", ""):
                return identity
        return self._read_git_identity_from_repo()

    def _read_git_identity_from_repo(self) -> tuple[str, str]:
        """Read git identity directly from repository config files."""
        repo_root = self._resolve_git_repo_root()
        if repo_root is None:
            return "", ""

        git_dir = self._resolve_git_dir(repo_root)
        if git_dir is None:
            return "", ""

        return self._read_git_config_identity(git_dir)

    def _upsert_git_identity(self, git_name: str, git_email: str) -> User | None:
        """Create or refresh the user derived from the active git identity."""
        if not git_name and not git_email:
            return None

        existing = self.find_by_email(git_email)
        if existing:
            existing.git_configured = True
            if git_name and not existing.name:
                existing.name = git_name
            existing.last_seen_at = time.time()
            self._save()
            return existing

        user = User(
            name=git_name,
            email=git_email,
            username=git_email.split("@")[0] if git_email else "",
            git_configured=True,
            is_default=True,  # First git-discovered user is default
            last_seen_at=time.time(),
        )
        self.create_user(user)
        return user

    def _read_git_ref_from_file(self, git_dir: Path, ref_name: str) -> str | None:
        """Read a loose ref file if it exists."""
        ref_path = git_dir / ref_name
        if not ref_path.exists() or not ref_path.is_file():
            return None

        try:
            content = ref_path.read_text(encoding="utf-8").strip()
        except Exception:
            return None

        return self._resolve_git_ref_content(git_dir, content)

    def _resolve_git_ref_content(self, git_dir: Path, content: str) -> str | None:
        """Resolve loose ref contents to a commit hash."""
        if content.startswith("ref:"):
            nested_ref = content.split(":", 1)[1].strip()
            return self._read_git_ref(git_dir, nested_ref)
        if self._is_git_sha(content):
            return content.lower()
        return None

    def _parse_packed_git_ref_line(self, line: str, ref_name: str) -> str | None:
        """Parse a packed-ref entry and return its commit hash when matched."""
        line = line.strip()
        if not line or line.startswith("#") or line.startswith("^"):
            return None

        try:
            sha, packed_ref = line.split(" ", 1)
        except ValueError:
            return None

        if packed_ref != ref_name or not self._is_git_sha(sha):
            return None
        return sha.lower()

    def _read_packed_git_ref(self, git_dir: Path, ref_name: str) -> str | None:
        """Resolve a ref from packed-refs when no loose ref exists."""
        packed_refs = git_dir / "packed-refs"
        if not packed_refs.exists():
            return None

        try:
            for line in packed_refs.read_text(encoding="utf-8").splitlines():
                resolved = self._parse_packed_git_ref_line(line, ref_name)
                if resolved is not None:
                    return resolved
        except Exception:
            return None

        return None

    def _append_git_sha(self, target: list[str], seen: set[str], sha: str | None) -> None:
        """Append a unique valid SHA to the target list."""
        if not sha:
            return

        normalized = sha.lower()
        if not self._is_git_sha(normalized) or normalized in seen:
            return

        seen.add(normalized)
        target.append(normalized)

    def _parse_packed_ref_name(self, line: str) -> str | None:
        """Extract the ref name from a packed-ref line."""
        line = line.strip()
        if not line or line.startswith("#") or line.startswith("^"):
            return None

        try:
            _sha, ref_name = line.split(" ", 1)
        except ValueError:
            return None

        return ref_name

    def _iter_git_ref_names(self, git_dir: Path) -> list[str]:
        """Collect candidate ref names from loose and packed refs."""
        ref_names = ["HEAD"]

        refs_root = git_dir / "refs"
        if refs_root.exists():
            for ref_file in refs_root.rglob("*"):
                if ref_file.is_file():
                    ref_names.append(ref_file.relative_to(git_dir).as_posix())

        packed_refs = git_dir / "packed-refs"
        if packed_refs.exists():
            try:
                for line in packed_refs.read_text(encoding="utf-8").splitlines():
                    packed_ref = self._parse_packed_ref_name(line)
                    if packed_ref is not None:
                        ref_names.append(packed_ref)
            except Exception:
                pass

        return ref_names

    def _find_git_commit_author(self, body: bytes) -> tuple[str, str] | None:
        """Extract the author tuple from a commit body."""
        for line in body.splitlines():
            if line.startswith(b"author "):
                return self._parse_git_identity_line(line)
            if not line:
                break
        return None

    def _commit_author_from_object(self, obj_type: str, body: bytes) -> tuple[str, str] | None:
        """Return the author for commit objects only."""
        if obj_type != "commit":
            return None
        return self._find_git_commit_author(body)

    def _remember_git_author(
        self,
        authors: list[tuple[str, str]],
        seen_authors: set[str],
        author: tuple[str, str],
    ) -> None:
        """Add a discovered author once."""
        key = f"{author[0]}|{author[1]}".lower()
        if key in seen_authors:
            return

        seen_authors.add(key)
        authors.append(author)

    def _enqueue_git_object_children(self, obj_type: str, body: bytes, queue: deque[str]) -> None:
        """Queue parent objects for traversal."""
        if obj_type == "commit":
            queue.extend(self._parse_git_commit_parents(body))
            return

        if obj_type == "tag":
            target = self._parse_git_tag_target(body)
            if target is not None:
                queue.append(target)

    def _sync_from_git_log_with_cli(self, cwd: Path) -> list[tuple[str, str]] | None:
        """Try discovering authors through git log."""
        if not self._git_binary_available():
            return None

        try:
            result = subprocess.check_output(
                ["git", "log", "--format=%an|%ae", "--all"],
                text=True,
                cwd=cwd,
                stderr=subprocess.DEVNULL,
            )
        except Exception:
            logger.warning("git_log_failed_falling_back", cwd=str(cwd))
            return None

        return self._parse_git_log_authors(result)

    def _git_binary_available(self) -> bool:
        """Return True when the git CLI is available on PATH."""
        return shutil.which("git") is not None

    def _repo_root_from_candidate(self, candidate: Path) -> Path | None:
        """Resolve a repository root from a path without shelling out to git."""
        if candidate.name == ".git" and candidate.exists() and candidate.is_dir():
            root = candidate.parent
            return root if root.exists() and root.is_dir() else None

        git_entry = candidate / ".git"
        if git_entry.is_dir() or git_entry.is_file():
            return candidate

        parent_git = candidate.parent / ".git"
        if parent_git.is_dir() or parent_git.is_file():
            return candidate.parent

        return None

    def _resolve_git_dir(self, repo_root: Path) -> Path | None:
        """Return the underlying git directory for a repository root."""
        if repo_root.name == ".git" and repo_root.exists() and repo_root.is_dir():
            return repo_root

        git_entry = repo_root / ".git"
        if git_entry.is_dir():
            return git_entry

        if git_entry.is_file():
            try:
                content = git_entry.read_text(encoding="utf-8").strip()
            except Exception:
                return None

            if content.startswith("gitdir:"):
                gitdir = content.split(":", 1)[1].strip()
                git_path = Path(gitdir)
                if not git_path.is_absolute():
                    git_path = (repo_root / git_path).resolve()
                if git_path.exists() and git_path.is_dir():
                    return git_path

        return None

    def _read_git_config_identity(self, git_dir: Path) -> tuple[str, str]:
        """Read user.name and user.email from a git config file."""
        config_path = git_dir / "config"
        if not config_path.exists():
            return "", ""

        parser = configparser.ConfigParser(interpolation=None)
        try:
            parser.read(config_path, encoding="utf-8")
        except Exception:
            return "", ""

        name = parser.get("user", "name", fallback="").strip()
        email = parser.get("user", "email", fallback="").strip()
        return name, email

    def _is_git_sha(self, value: str) -> bool:
        """Check whether a string looks like a git SHA-1."""
        return len(value) == 40 and all(c in "0123456789abcdefABCDEF" for c in value)

    def _read_git_ref(self, git_dir: Path, ref_name: str) -> str | None:
        """Resolve a git ref to a commit hash without invoking git."""
        loose_ref = self._read_git_ref_from_file(git_dir, ref_name)
        if loose_ref is not None:
            return loose_ref
        return self._read_packed_git_ref(git_dir, ref_name)

    def _iter_git_starting_shas(self, git_dir: Path) -> list[str]:
        """Collect hashes that can act as graph entry points."""
        starting: list[str] = []
        seen: set[str] = set()

        for ref_name in self._iter_git_ref_names(git_dir):
            self._append_git_sha(starting, seen, self._read_git_ref(git_dir, ref_name))

        return starting

    def _read_loose_git_object(self, git_dir: Path, sha: str) -> tuple[str, bytes] | None:
        """Read a loose git object from .git/objects."""
        obj_path = git_dir / "objects" / sha[:2] / sha[2:]
        if not obj_path.exists():
            return None

        try:
            raw = zlib.decompress(obj_path.read_bytes())
            header, body = raw.split(b"\0", 1)
            obj_type, _size = header.split(b" ", 1)
            return obj_type.decode("utf-8", "replace"), body
        except Exception:
            return None

    def _read_pack_index(self, idx_path: Path) -> dict[str, int]:
        """Read a pack index and return SHA -> pack offset mapping."""
        try:
            data = idx_path.read_bytes()
        except Exception:
            return {}

        if len(data) < 8 or data[:4] != b"\xfftOc":
            return {}

        version = int.from_bytes(data[4:8], "big")
        if version != 2:
            return {}

        offset = 8
        fanout = [
            int.from_bytes(data[offset + i * 4 : offset + (i + 1) * 4], "big")
            for i in range(256)
        ]
        offset += 256 * 4
        count = fanout[-1]

        object_ids = [
            data[offset + i * 20 : offset + (i + 1) * 20].hex()
            for i in range(count)
        ]
        offset += count * 20

        offset += count * 4  # CRC32 table

        offsets_32 = [
            int.from_bytes(data[offset + i * 4 : offset + (i + 1) * 4], "big")
            for i in range(count)
        ]
        offset += count * 4

        large_offset_indexes = [i for i, value in enumerate(offsets_32) if value & 0x80000000]
        large_offsets: list[int] = []
        for i, _index in enumerate(large_offset_indexes):
            start = offset + i * 8
            large_offsets.append(int.from_bytes(data[start : start + 8], "big"))

        mapping: dict[str, int] = {}
        large_iter = iter(large_offsets)
        for sha, value in zip(object_ids, offsets_32):
            if value & 0x80000000:
                mapping[sha] = next(large_iter, 0)
            else:
                mapping[sha] = value
        return mapping

    def _apply_pack_delta(self, base: bytes, delta: bytes) -> bytes:
        """Apply a Git pack delta to a base object body."""
        def read_varint(buf: bytes, idx: int) -> tuple[int, int]:
            result = 0
            shift = 0
            while True:
                byte = buf[idx]
                idx += 1
                result |= (byte & 0x7F) << shift
                if not (byte & 0x80):
                    return result, idx
                shift += 7

        idx = 0
        _base_size, idx = read_varint(delta, idx)
        result_size, idx = read_varint(delta, idx)

        out = bytearray()
        while idx < len(delta):
            cmd = delta[idx]
            idx += 1
            if cmd & 0x80:
                copy_offset = 0
                copy_size = 0

                if cmd & 0x01:
                    copy_offset |= delta[idx]
                    idx += 1
                if cmd & 0x02:
                    copy_offset |= delta[idx] << 8
                    idx += 1
                if cmd & 0x04:
                    copy_offset |= delta[idx] << 16
                    idx += 1
                if cmd & 0x08:
                    copy_offset |= delta[idx] << 24
                    idx += 1

                if cmd & 0x10:
                    copy_size |= delta[idx]
                    idx += 1
                if cmd & 0x20:
                    copy_size |= delta[idx] << 8
                    idx += 1
                if cmd & 0x40:
                    copy_size |= delta[idx] << 16
                    idx += 1
                if copy_size == 0:
                    copy_size = 0x10000

                out.extend(base[copy_offset : copy_offset + copy_size])
            elif cmd:
                out.extend(delta[idx : idx + cmd])
                idx += cmd

        result = bytes(out)
        if len(result) != result_size:
            raise ValueError("pack delta size mismatch")
        return result

    def _read_pack_object(self, pack_path: Path, offset: int) -> tuple[str, bytes] | None:
        """Read an object from a pack file at the given offset."""
        try:
            with pack_path.open("rb") as f:
                f.seek(offset)
                first = f.read(1)
                if not first:
                    return None

                first_byte = first[0]
                obj_type_num = (first_byte >> 4) & 0x7
                size = first_byte & 0x0F
                shift = 4
                while first_byte & 0x80:
                    next_byte = f.read(1)
                    if not next_byte:
                        return None
                    first_byte = next_byte[0]
                    size |= (first_byte & 0x7F) << shift
                    shift += 7

                if obj_type_num in (6, 7):
                    return None

                type_map = {1: "commit", 2: "tree", 3: "blob", 4: "tag"}
                obj_type = type_map.get(obj_type_num)
                if obj_type is None:
                    return None

                decompressor = zlib.decompressobj()
                chunks: list[bytes] = []
                while True:
                    chunk = f.read(8192)
                    if not chunk:
                        break
                    data = decompressor.decompress(chunk)
                    if data:
                        chunks.append(data)
                    if decompressor.eof:
                        break

                body = b"".join(chunks) + decompressor.flush()
                if len(body) != size:
                    # Some pack objects may be compressed in a single chunk, but if the
                    # size differs we keep the parsed body anyway and let callers decide.
                    pass
                return obj_type, body
        except Exception:
            return None

    def _read_git_object(self, git_dir: Path, sha: str) -> tuple[str, bytes] | None:
        """Read a git object from loose storage or from packfiles."""
        loose = self._read_loose_git_object(git_dir, sha)
        if loose is not None:
            return loose

        pack_dir = git_dir / "objects" / "pack"
        if not pack_dir.exists():
            return None

        for idx_path in pack_dir.glob("*.idx"):
            offsets = self._read_pack_index(idx_path)
            offset = offsets.get(sha.lower())
            if offset is None:
                continue
            pack_path = idx_path.with_suffix(".pack")
            if not pack_path.exists():
                continue
            obj = self._read_pack_object(pack_path, offset)
            if obj is not None:
                return obj

        return None

    def _parse_git_identity_line(self, line: bytes) -> tuple[str, str] | None:
        """Parse an author/committer line from a git commit object."""
        payload = line[7:].decode("utf-8", "replace").strip()
        email_start = payload.rfind(" <")
        email_end = payload.rfind(">")
        if email_start == -1 or email_end == -1 or email_end < email_start:
            return None

        name = payload[:email_start].strip()
        email = payload[email_start + 2 : email_end].strip()
        if not email:
            return None
        return name, email

    def _parse_git_commit_parents(self, body: bytes) -> list[str]:
        """Extract commit parent hashes from a commit body."""
        parents: list[str] = []
        for line in body.splitlines():
            if not line:
                break
            if line.startswith(b"parent "):
                parent = line[7:].decode("utf-8", "replace").strip()
                if self._is_git_sha(parent):
                    parents.append(parent.lower())
        return parents

    def _parse_git_tag_target(self, body: bytes) -> str | None:
        """Extract the target hash from an annotated tag object."""
        for line in body.splitlines():
            if not line:
                break
            if line.startswith(b"object "):
                target = line[7:].decode("utf-8", "replace").strip()
                if self._is_git_sha(target):
                    return target.lower()
        return None

    def _collect_git_authors_from_objects(self, git_dir: Path) -> list[tuple[str, str]]:
        """Discover authors by walking reachable commit objects directly."""
        starting_shas = self._iter_git_starting_shas(git_dir)
        if not starting_shas:
            logger.warning("git_refs_not_found", git_dir=str(git_dir))
            return []

        authors: list[tuple[str, str]] = []
        seen_authors: set[str] = set()
        seen_commits: set[str] = set()
        queue = deque(starting_shas)

        while queue:
            sha = queue.popleft().lower()
            if sha in seen_commits or not self._is_git_sha(sha):
                continue

            seen_commits.add(sha)
            obj = self._read_git_object(git_dir, sha)
            if obj is None:
                continue

            obj_type, body = obj
            author = self._commit_author_from_object(obj_type, body)
            if author is not None:
                self._remember_git_author(authors, seen_authors, author)

            self._enqueue_git_object_children(obj_type, body, queue)

        return authors
