"""Users — git-based user management for the dashboard.

Enables tracking users who work with the system, with git integration
for automatic user discovery from commit history.
"""

from __future__ import annotations

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

import configparser
import shutil
import subprocess
import time
import uuid
import zlib

import structlog
from pydantic import BaseModel, Field

from proxym.storage import SQLiteNamespaceStore


logger = structlog.get_logger()


# ── Models ────────────────────────────────────────────────────

class UserRole(str):
    """User roles in the system."""
    ADMIN = "admin"
    DEVELOPER = "developer"
    VIEWER = "viewer"


class User(BaseModel):
    """A user in the system, identified by git configuration."""
    id: str = Field(default_factory=lambda: f"USR-{uuid.uuid4().hex[:6].upper()}")
    name: str = ""  # Git user.name
    email: str = ""  # Git user.email
    username: str = ""  # GitHub/GitLab username or system username
    role: str = "developer"
    is_default: bool = False  # Is this the default/delegated user?
    is_active: bool = True
    git_configured: bool = False  # Was this user auto-discovered from git?
    # Metadata
    created_at: float = Field(default_factory=time.time)
    updated_at: float = Field(default_factory=time.time)
    last_seen_at: float = 0
    # Project associations
    projects: list[str] = Field(default_factory=list)  # Project IDs this user works on
    # Stats
    tickets_assigned: int = 0
    tickets_completed: int = 0

    def summary(self) -> dict[str, Any]:
        return {
            "id": self.id,
            "name": self.name,
            "email": self.email,
            "username": self.username,
            "role": self.role,
            "is_default": self.is_default,
            "is_active": self.is_active,
            "git_configured": self.git_configured,
            "created_at": self.created_at,
            "updated_at": self.updated_at,
            "last_seen_at": self.last_seen_at,
            "projects": self.projects,
            "tickets_assigned": self.tickets_assigned,
            "tickets_completed": self.tickets_completed,
        }


# ── Manager ───────────────────────────────────────────────────

class UserManager:
    """In-memory user store with SQLite persistence and git integration."""

    _instance: UserManager | None = None

    @classmethod
    def get(cls, persist_path: Path | None = None) -> UserManager:
        """Get singleton instance."""
        if cls._instance is None:
            cls._instance = cls(persist_path)
        return cls._instance

    @classmethod
    def reset(cls) -> None:
        """Reset singleton (mainly for tests)."""
        cls._instance = None

    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

    def __init__(self, persist_path: Path | None = None):
        self.users: dict[str, User] = {}
        self._persist_path = persist_path
        self._store = SQLiteNamespaceStore(self._persist_path) if self._persist_path is not None else None
        if persist_path and persist_path.exists():
            self._load()
        else:
            # Try to auto-discover current git user
            self._discover_git_user()

    def _discover_git_user(self) -> User | None:
        """Discover current user from git config and create if not exists."""
        try:
            git_name, git_email = self._read_git_identity()
            if not git_name and not git_email:
                return None

            return self._upsert_git_identity(git_name, git_email)

        except Exception:
            return None

    def create_user(self, user: User) -> User:
        """Create a new user."""
        # Ensure only one default user
        if user.is_default:
            for u in self.users.values():
                if u.is_default and u.id != user.id:
                    u.is_default = False

        self.users[user.id] = user
        self._save()
        return user

    def get_user(self, user_id: str) -> User | None:
        """Get user by ID."""
        return self.users.get(user_id)

    def find_by_email(self, email: str) -> User | None:
        """Find user by email."""
        for user in self.users.values():
            if user.email.lower() == email.lower():
                return user
        return None

    def find_by_username(self, username: str) -> User | None:
        """Find user by username."""
        for user in self.users.values():
            if user.username.lower() == username.lower():
                return user
        return None

    def list_users(self, active_only: bool = False) -> list[User]:
        """List all users."""
        result = list(self.users.values())
        if active_only:
            result = [u for u in result if u.is_active]
        # Sort by default first, then by name
        result.sort(key=lambda u: (not u.is_default, u.name or u.username or u.id))
        return result

    def get_default_user(self) -> dict[str, Any] | None:
        """Get the default/delegated user."""
        for user in self.users.values():
            if user.is_default and user.is_active:
                return user.summary()
        # Fallback: return first active user
        for user in self.users.values():
            if user.is_active:
                return user.summary()
        return None

    def update_user(self, user_id: str, updates: dict[str, Any]) -> User | None:
        """Update user fields."""
        user = self.users.get(user_id)
        if not user:
            return None

        for key, value in updates.items():
            if key == "is_default" and value is True:
                # Unset default on other users
                for u in self.users.values():
                    if u.is_default and u.id != user_id:
                        u.is_default = False
            if hasattr(user, key):
                setattr(user, key, value)

        user.updated_at = time.time()
        self._save()
        return user

    def delete_user(self, user_id: str) -> bool:
        """Delete a user."""
        if user_id in self.users:
            del self.users[user_id]
            self._save()
            return True
        return False

    def _resolve_git_repo_root(self) -> Path | None:
        """Find a git repository root near this process or source tree."""
        for candidate in self._candidate_git_repo_paths():
            repo_root = self._resolve_git_root_candidate(candidate)
            if repo_root is not None:
                return repo_root

        return None

    def _parse_git_log_authors(self, log_output: str) -> list[tuple[str, str]]:
        """Parse unique author/email pairs from git log output."""
        authors: list[tuple[str, str]] = []
        seen: set[str] = set()
        for line in log_output.splitlines():
            if "|" not in line:
                continue
            name, email = line.split("|", 1)
            key = f"{name}|{email}".lower()
            if key in seen or not email:
                continue
            seen.add(key)
            authors.append((name, email))
        return authors

    def _upsert_git_user(self, name: str, email: str) -> tuple[User, bool]:
        """Create or update a user discovered from git history.

        Returns (user, created_new).
        """
        existing = self.find_by_email(email)
        if existing:
            existing.last_seen_at = time.time()
            if name and not existing.name:
                existing.name = name
            self._save()
            return existing, False

        user = User(
            name=name,
            email=email,
            username=email.split("@")[0],
            git_configured=True,
            last_seen_at=time.time(),
        )
        self.create_user(user)
        return user, True

    def _upsert_git_authors(self, authors: list[tuple[str, str]]) -> list[User]:
        """Create users for newly discovered author tuples."""
        discovered: list[User] = []
        for name, email in authors:
            user, created = self._upsert_git_user(name, email)
            if created:
                discovered.append(user)
        return discovered

    def _sync_git_users_from_repo(self, cwd: Path, git_dir: Path) -> list[User]:
        """Discover users from a repository using CLI when available, otherwise fallback."""
        authors = self._sync_from_git_log_with_cli(cwd)
        if authors is None:
            authors = self._collect_git_authors_from_objects(git_dir)
        return self._upsert_git_authors(authors)

    def sync_from_git_log(self, repo_path: Path | None = None) -> list[User]:
        """Discover users from git commit history."""
        try:
            cwd = repo_path or self._resolve_git_repo_root()

            if not self._is_existing_directory(cwd):
                logger.warning("git_repo_root_not_found", cwd=str(cwd) if cwd else None)
                return []

            git_dir = self._resolve_git_dir(cwd)
            if git_dir is None:
                logger.warning("git_dir_not_found", cwd=str(cwd))
                return []

            discovered = self._sync_git_users_from_repo(cwd, git_dir)
            self._save()
            return discovered

        except Exception:
            return []

    # ── Persistence ───────────────────────────────────────────

    def _save(self):
        if not self._persist_path:
            return
        if self._store is None:
            self._store = SQLiteNamespaceStore(self._persist_path)
        self._store.save_namespace(
            "users",
            {uid: u.model_dump() for uid, u in self.users.items()},
        )

    def _load(self):
        if not self._persist_path or not self._persist_path.exists():
            return
        try:
            data = self._store.load_namespace("users") if self._store else {}
            for uid, udata in data.items():
                self.users[uid] = User(**udata)
        except Exception:
            pass
