from collections.abc import Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum, auto
import errno
import hashlib
import logging
import os
from pathlib import Path
import re
import subprocess
import sys
import time
from typing import TYPE_CHECKING
import urllib.parse

import esphome.config_validation as cv
from esphome.core import CORE, EsphomeError, TimePeriodSeconds
from esphome.helpers import add_git_ceiling_directory, rmtree, write_file

if TYPE_CHECKING:
    from filelock import FileLock

_LOGGER = logging.getLogger(__name__)

# Special value to indicate never refresh
NEVER_REFRESH = TimePeriodSeconds(seconds=-1)

# revert() runs on an already-failing path; bound its wait for the cache
# entry lock so that recovery cannot hang forever behind another process.
_REVERT_LOCK_TIMEOUT_SECONDS = 60

# When a complete cache entry already exists, a caller does not wait forever
# behind another process's stalled clone or update (git sets no network
# timeouts): after this bound it uses the existing clone without refreshing
# it. With no complete entry there is nothing to fall back to, so the wait
# is unbounded.
_COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS = 60

# Written inside .git only while the entry is a complete, quiescent
# checkout: after every clone step (clone, ref fetch, reset, submodule init)
# has finished, and removed for the duration of a refresh's rewrite
# (stash/fetch/reset). A directory without it is an interrupted clone or
# update (e.g. the process was killed mid-clone) and must be re-cloned;
# without this check such a directory would be trusted forever when the
# caller uses NEVER_REFRESH, and the bounded-wait fallback would hand a
# mid-rewrite tree to a timed-out peer. Lives in .git so
# stash/reset/checkout can never touch it and it does not pollute the
# worktree.
_CLONE_COMPLETE_MARKER = "esphome_clone_complete"

# Environment variables that scope git to a specific repository. Git hooks and
# some CI wrappers export these; if they leak into the git commands run here,
# git binds to the caller's repository instead of the one being managed. The
# effects range from loud (`git clone` producing a bare-style directory with
# no working tree) to silent (an ambient GIT_INDEX_FILE makes
# `git submodule update --init` exit 0 without initializing anything).
_GIT_REPO_SCOPING_ENV = frozenset(
    {
        "GIT_DIR",
        "GIT_WORK_TREE",
        "GIT_INDEX_FILE",
        "GIT_OBJECT_DIRECTORY",
        "GIT_ALTERNATE_OBJECT_DIRECTORIES",
        "GIT_COMMON_DIR",
        "GIT_NAMESPACE",
    }
)


class GitException(cv.Invalid):
    """Base exception for git-related errors."""


class GitNotInstalledError(GitException):
    """Exception raised when git is not installed on the system."""


class GitCommandError(GitException):
    """Exception raised when a git command fails."""


class GitRepositoryError(GitException):
    """Exception raised when a git repository is in an invalid state."""


def _redact_url_credentials(text: str) -> str:
    """Mask userinfo in any URLs embedded in ``text``.

    Users can put credentials directly in a git URL, and log output is
    routinely pasted into public issues.
    """
    return re.sub(r"://[^/@\s]+@", "://***@", text)


def run_git_command(
    cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None
) -> str:
    """Run a git command and return its stdout.

    The repository-scoping environment variables in ``_GIT_REPO_SCOPING_ENV``
    are always stripped. ``git_dir`` additionally pins GIT_DIR/GIT_WORK_TREE
    to that repository and runs the command there; ``cwd`` alone runs the
    command in that directory with GIT_CEILING_DIRECTORIES capping repository
    discovery at its parent.
    """
    # Every invocation starts from an environment with the repository-scoping
    # variables stripped (see _GIT_REPO_SCOPING_ENV) so a git hook or CI
    # wrapper invoking ESPHome can never redirect these commands to its own
    # repository or index.
    #
    # ``git_dir`` then re-adds GIT_DIR and GIT_WORK_TREE pointing at the
    # managed repository. This prevents git from walking up the directory
    # tree to find parent repositories when the target repo's .git directory
    # is corrupt. Without this, commands like 'git stash' could accidentally
    # operate on parent repositories (e.g., the main ESPHome repo) instead of
    # failing, causing data loss.
    #
    # ``cwd`` (without ``git_dir``) runs the command in that directory
    # without GIT_DIR/GIT_WORK_TREE. The ``git submodule`` porcelain needs
    # this: on some installations (e.g. Windows setups where a shim hands
    # git untranslated paths) it refuses to run when GIT_DIR/GIT_WORK_TREE
    # are set, failing with "cannot be used without a working tree".
    # GIT_CEILING_DIRECTORIES (which git only honors as an absolute path)
    # keeps the parent-repo-walk protection instead: if the repo's .git is
    # missing or corrupt, git fails rather than discovering an enclosing
    # repository.
    env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_SCOPING_ENV}
    if git_dir is not None:
        env["GIT_DIR"] = str(Path(git_dir) / ".git")
        env["GIT_WORK_TREE"] = str(git_dir)
        cwd = git_dir
    elif cwd is not None:
        add_git_ceiling_directory(env, Path(cwd).absolute().parent)

    _LOGGER.debug(
        "Running git command: %s (cwd=%s, isolated=%s)",
        _redact_url_credentials(" ".join(cmd)),
        cwd,
        git_dir is not None,
    )

    try:
        ret = subprocess.run(
            cmd,
            cwd=cwd,
            capture_output=True,
            check=False,
            close_fds=False,
            env=env,
        )
    except FileNotFoundError as err:
        raise GitNotInstalledError(
            "git is not installed. See "
            "https://git-scm.com/book/en/v2/Getting-Started-Installing-Git "
            "for installation instructions."
        ) from err

    if ret.returncode != 0:
        if ret.stderr:
            err_str = ret.stderr.decode("utf-8")
            lines = [x.strip() for x in err_str.splitlines()]
            if lines[-1].startswith("fatal:"):
                raise GitCommandError(lines[-1][len("fatal: ") :])
            raise GitCommandError(err_str)
        raise GitCommandError(
            f"git exited with code {ret.returncode}: "
            f"{_redact_url_credentials(' '.join(cmd))}"
        )

    return ret.stdout.decode("utf-8").strip()


def _cache_key(url: str, ref: str | None) -> str:
    """Cache key identifying one repository checkout.

    The lock path and the entry directory both hash this, keeping them in
    agreement. (micro_wake_word still rebuilds the format by hand to locate
    manifests; fold it in here if the format ever changes.)
    """
    return f"{url}@{ref}"


def _compute_destination_path(key: str, domain: str) -> Path:
    base_dir = Path(CORE.data_dir) / domain
    h = hashlib.new("sha256")
    h.update(key.encode())
    return base_dir / h.hexdigest()[:8]


def _repo_entry_dir(key: str, domain: str, subpath: Path | None) -> Path:
    """Worktree directory of one cache entry: the hash dir plus optional subpath."""
    repo_dir = _compute_destination_path(key, domain)
    if subpath:
        repo_dir = repo_dir / subpath
    return repo_dir


def _repo_lock_path(key: str, domain: str) -> Path:
    """Path of the lock file serializing all work on one cache entry.

    Lives next to the hash directory, never inside it, so the removal of a
    broken or incomplete clone can never delete a lock another process holds.
    """
    repo_dir = _compute_destination_path(key, domain)
    return repo_dir.parent / f"{repo_dir.name}.lock"


class _LockStatus(Enum):
    ACQUIRED = auto()
    # A bounded wait expired while another process held the lock.
    TIMEOUT = auto()
    # The lock could not be taken at all; callers proceed unlocked,
    # matching the behavior before the lock existed.
    UNAVAILABLE = auto()


# Errnos that mean the filesystem genuinely cannot take file locks (NFS
# without a lock daemon, some FUSE mounts). Any other OSError (permissions,
# read-only volume, full disk) is a cache directory problem, which the git
# commands themselves report clearly when it actually matters. EPERM is
# deliberately absent: it usually means a permissions problem, so it takes
# the generic message that names no cause. On Linux ENOTSUP and EOPNOTSUPP
# are the same value; the set folds them.
_NO_LOCK_SUPPORT_ERRNOS = frozenset(
    {errno.ENOLCK, errno.ENOSYS, errno.EOPNOTSUPP, errno.ENOTSUP}
)


def _acquire_repo_lock(
    lock: "FileLock",
    safe_key: str,
    timeout: float,
    wait_message: str = "Waiting for another process to finish updating %s",
) -> _LockStatus:
    """Acquire ``lock``, logging ``wait_message`` when a wait actually begins.

    ``timeout`` of -1 waits forever; a positive value bounds the wait and
    can yield ``TIMEOUT``.
    """
    from filelock import Timeout

    try:
        try:
            lock.acquire(blocking=False)
        except Timeout:
            # Waiting on another process's clone or update can take
            # minutes; say so instead of appearing hung.
            _LOGGER.info(wait_message, safe_key)
            lock.acquire(timeout=timeout)
    except Timeout:
        return _LockStatus.TIMEOUT
    except OSError as err:
        if err.errno in _NO_LOCK_SUPPORT_ERRNOS:
            _LOGGER.warning(
                "The filesystem does not support locking the cache entry for "
                "%s (%s), continuing without a lock",
                safe_key,
                err,
            )
        else:
            # Not a locking problem (permissions, read-only volume, full
            # disk). Still continue unlocked: a pre-seeded read-only cache
            # with refresh disabled only reads and must keep working, and
            # in every other case the git commands fail with the real error.
            _LOGGER.warning(
                "Could not take the cache entry lock for %s (%s), "
                "continuing without a lock",
                safe_key,
                err,
            )
        return _LockStatus.UNAVAILABLE
    return _LockStatus.ACQUIRED


@contextmanager
def _repo_cache_lock(
    key: str, domain: str, repo_dir: Path
) -> Iterator[tuple[bool, "FileLock | None"]]:
    """Hold the cache entry lock for ``key`` over the with block.

    Yields ``(use_existing, lock)``. ``use_existing`` is True when the lock
    could not be acquired within the bounded wait but ``repo_dir`` is a
    complete cache entry; the caller should use it as-is and do nothing
    else. Otherwise ``lock`` is the held lock, released when the block
    exits, or ``None`` when the lock could not be taken at all and the
    caller proceeds unlocked.
    """
    # Lazy import: keeps filelock off the CLI startup import path.
    from filelock import FileLock

    safe_key = _redact_url_credentials(key)
    # acquire() creates the lock file's directory itself; git clone later
    # creates the hash directory next to it. fallback_to_soft would silently
    # downgrade ENOSYS to a SoftFileLock, whose stale existence marker from
    # another host on a shared cache could hang the unbounded wait forever;
    # routing it through the OSError handler runs unlocked instead.
    lock: FileLock | None = FileLock(
        str(_repo_lock_path(key, domain)), fallback_to_soft=False
    )
    status = _acquire_repo_lock(lock, safe_key, _COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS)
    if status is _LockStatus.TIMEOUT:
        if _clone_complete_marker_path(repo_dir).is_file():
            # Mutual exclusion matters most while no complete entry exists
            # (initial clone, recovery re-clone); with one on disk, reading
            # it beats hanging behind a stalled holder.
            _LOGGER.warning(
                "Timed out waiting for another process updating %s, proceeding "
                "with the existing clone, which that process may still be "
                "changing",
                safe_key,
            )
            yield True, None
            return
        # Nothing to fall back to; the holder is producing the clone this
        # caller needs.
        status = _acquire_repo_lock(
            lock,
            safe_key,
            timeout=-1,
            wait_message="Still waiting for the clone of %s, "
            "there is no existing clone to fall back on",
        )
    if status is not _LockStatus.ACQUIRED:
        lock = None
    try:
        yield False, lock
    finally:
        if lock is not None:
            lock.release()


def _clone_complete_marker_path(repo_dir: Path) -> Path:
    return repo_dir / ".git" / _CLONE_COMPLETE_MARKER


def _clear_clone_complete_marker(repo_dir: Path) -> None:
    """Best-effort removal of the completion marker.

    If the unlink fails (e.g. a file lock on Windows), the marker stays and
    the entry keeps its previous trust level; every consumer of the marker
    tolerates that.
    """
    try:
        _clone_complete_marker_path(repo_dir).unlink(missing_ok=True)
    except OSError as err:
        _LOGGER.debug("Could not delete clone completion marker: %s", err)


def _write_clone_complete_marker(
    repo_dir: Path, key: str, hash_dir_name: str, safe_key: str
) -> None:
    """Mark the entry as a complete, quiescent checkout.

    The key and hash dir name are recorded purely to make cache debugging
    easier. The marker is only a validity signal, so a failed write must not
    fail an otherwise complete clone or update: the only cost is a re-clone
    on the next run.
    """
    try:
        write_file(
            _clone_complete_marker_path(repo_dir),
            f"key={key}\nhash={hash_dir_name}\n",
        )
    except EsphomeError as err:
        _LOGGER.warning(
            "Could not write clone completion marker for %s: %s", safe_key, err
        )


def _remove_repo_dir(repo_dir: Path) -> None:
    """Remove a repo directory, deleting the completion marker first.

    Marker-first ordering guarantees an interrupted removal can never leave a
    marker behind next to a partially deleted worktree. The unlink is best
    effort: if it fails, rmtree below still gets the chance to remove the
    directory, marker included.
    """
    _clear_clone_complete_marker(repo_dir)
    if repo_dir.is_dir():
        rmtree(repo_dir)


def update_submodules(repo_dir: Path, key: str) -> None:
    """Initialize/update every submodule the repository declares, recursively,
    matching how PlatformIO clones libraries.

    Most repositories declare no submodules, so this does nothing when there
    is no ``.gitmodules`` file. Which submodules get populated is git's own
    policy (``update = none``, ``submodule.active``, sparse checkouts);
    git's exit code is the error signal.

    Runs with plain ``cwd`` rather than ``git_dir`` isolation, which the
    ``git submodule`` porcelain does not tolerate (see ``run_git_command``).
    """
    if not (repo_dir / ".gitmodules").is_file():
        return
    _LOGGER.info("Updating submodules for %s", _redact_url_credentials(key))
    run_git_command(
        ["git", "submodule", "update", "--init", "--recursive", "--depth=1"],
        cwd=repo_dir,
    )


def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None:
    """Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub.

    On Windows, when ``core.symlinks=false`` (the default unless the user has
    SeCreateSymbolicLinkPrivilege — i.e. Developer Mode or running elevated),
    git materializes files with tree mode ``120000`` as plain text files
    whose content is the literal symlink target path. Opening such a file
    yields the target path string instead of the target's content.

    If ``file_path`` is one of those stubs, return the resolved target Path
    inside ``repo_dir``. Otherwise return ``None`` and the caller should use
    ``file_path`` as-is.

    Designed to be called *only* when normal access has already produced an
    unexpected result (e.g. YAML parsed as a top-level scalar), so the
    per-file ``git ls-files`` subprocess cost is paid only on the failure
    path. Returns ``None`` on any error or check failure — it's purely a
    best-effort recovery, never raises.
    """
    # On non-Windows, git creates real symlinks; ordinary file access already
    # transparently follows them.
    if sys.platform != "win32":
        return None
    if file_path.is_symlink():
        return None
    if not file_path.is_file():
        return None

    try:
        rel = file_path.relative_to(repo_dir)
    except ValueError:
        return None

    try:
        # ``git ls-files -s <path>`` prints "<mode> <sha> <stage>\t<path>"
        # for that single entry, or empty if untracked.
        out = run_git_command(
            ["git", "ls-files", "-s", "--", rel.as_posix()],
            git_dir=repo_dir,
        )
    except GitException:
        return None

    parts = out.split()
    if not parts or parts[0] != "120000":
        return None

    # Stubs are short ASCII relative paths. Decode defensively, and only
    # strip the trailing newline git's checkout may append — preserving any
    # whitespace that could be part of a valid target name.
    try:
        raw = file_path.read_bytes()
    except OSError:
        return None
    try:
        target_str = raw.decode("utf-8").rstrip("\r\n")
    except UnicodeDecodeError:
        return None

    # ``Path()`` and ``Path.resolve()`` can raise on malformed inputs (e.g.
    # embedded NUL bytes from a hostile symlink blob, paths too long for the
    # OS, or temporary I/O errors). Catch broadly — this helper is purely a
    # best-effort recovery and must never raise.
    try:
        target_path = (file_path.parent / target_str).resolve()
        repo_root_resolved = repo_dir.resolve()
    except (OSError, ValueError, RuntimeError):
        return None

    # ``Path.resolve()`` follows ``..``; re-verify containment afterwards.
    try:
        target_path.relative_to(repo_root_resolved)
    except ValueError:
        _LOGGER.warning(
            "Refusing to follow symlink %s -> %s (escapes repository)",
            file_path,
            target_str,
        )
        return None

    if not target_path.is_file():
        return None

    return target_path


def clone_or_update(
    *,
    url: str,
    ref: str | None = None,
    refresh: TimePeriodSeconds | None,
    domain: str,
    username: str | None = None,
    password: str | None = None,
    init_submodules: bool = False,
    subpath: Path | None = None,
) -> tuple[Path, Callable[[], bool] | None]:
    """Clone a repository into the cache, or refresh an existing clone.

    All work runs under a per-cache-entry inter-process file lock, so
    concurrent resolutions of the same repository (two esphome processes, or
    a subprocess plus an in-process load) serialize instead of interleaving.
    Without the lock, ``repo_dir.is_dir()`` is true from the instant
    ``git clone`` creates the directory: a second caller could read a half
    populated worktree, or see the missing completion marker and delete the
    clone in progress out from under the first caller.

    The lock guards mutation of the cache entry only; it is released when
    this function returns, so a caller still reading the worktree can
    overlap a later refresh by another process. That residual window is
    narrow (the refresh interval is re-checked under the lock) and predates
    the lock.

    Locking is best effort: on a filesystem that cannot take file locks a
    warning is logged and the work proceeds unlocked, matching the behavior
    before the lock existed. A complete cache entry also caps the wait: if
    the holder is still busy after a bounded time (e.g. stalled on the
    network), the existing clone is used without refreshing it, so a stuck
    process cannot hang every peer that already has a good entry.
    """
    key = _cache_key(url, ref)
    repo_dir = _repo_entry_dir(key, domain, subpath)
    with _repo_cache_lock(key, domain, repo_dir) as (use_existing, lock):
        if use_existing:
            return repo_dir, None
        return _clone_or_update_locked(
            url=url,
            ref=ref,
            refresh=refresh,
            domain=domain,
            username=username,
            password=password,
            init_submodules=init_submodules,
            subpath=subpath,
            lock=lock,
        )


def _clone_or_update_locked(
    *,
    url: str,
    ref: str | None,
    refresh: TimePeriodSeconds | None,
    domain: str,
    username: str | None,
    password: str | None,
    init_submodules: bool,
    subpath: Path | None,
    lock: "FileLock | None",
    _recover_broken: bool = True,
) -> tuple[Path, Callable[[], bool] | None]:
    """Body of ``clone_or_update``; the caller holds ``lock``.

    Split out because the broken-repository recovery below re-enters this
    function: re-acquiring the already-held lock would deadlock, since OS
    file locks taken on separate file descriptors conflict even within one
    process. ``lock`` is only re-acquired by the returned ``revert``
    callback, which runs after the wrapper's ``finally`` has released it.
    ``lock`` is ``None`` when the filesystem cannot take file locks and the
    wrapper fell back to running unlocked.
    """
    key = _cache_key(url, ref)
    # The user may have embedded credentials in the URL itself; log this
    # instead of key.
    safe_key = _redact_url_credentials(key)

    # Keep the caller's URL for the recovery re-clone below: rewriting the
    # rewritten URL would double the userinfo, and the recursive call must
    # compute the same cache key as this one.
    original_url = url
    if username is not None and password is not None:
        url = url.replace(
            "://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@"
        )

    hash_dir_name = _compute_destination_path(key, domain).name
    repo_dir = _repo_entry_dir(key, domain, subpath)

    if repo_dir.is_dir() and not _clone_complete_marker_path(repo_dir).is_file():
        # The last clone never finished (killed process, container stop) or
        # predates the marker; either way it cannot be trusted, especially
        # with NEVER_REFRESH where it would otherwise be reused forever.
        _LOGGER.warning(
            "Removing incomplete clone of %s at %s, will re-clone", safe_key, repo_dir
        )
        _remove_repo_dir(repo_dir)

    if not repo_dir.is_dir():
        _LOGGER.info("Cloning %s", safe_key)
        _LOGGER.debug("Location: %s", repo_dir)
        try:
            cmd = ["git", "clone", "--depth=1"]
            cmd += ["--", url, str(repo_dir)]
            run_git_command(cmd)

            if ref is not None:
                # We need to fetch the PR branch first, otherwise git will complain
                # about missing objects
                _LOGGER.info("Fetching %s", ref)
                run_git_command(
                    ["git", "fetch", "--depth=1", "--", "origin", ref],
                    git_dir=repo_dir,
                )
                run_git_command(
                    ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir
                )

            if init_submodules:
                update_submodules(repo_dir, key)

        except GitException:
            # Remove incomplete clone to prevent stale state. Without this,
            # a failed ref fetch leaves a clone on the default branch, and
            # subsequent calls skip the update due to the refresh window.
            _remove_repo_dir(repo_dir)
            raise

        # Every git step succeeded.
        _write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key)

    else:
        if refresh == NEVER_REFRESH or CORE.skip_external_update:
            _LOGGER.debug("Skipping update for %s (refresh disabled)", safe_key)
            return repo_dir, None

        file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD")
        # On first clone, FETCH_HEAD does not exist
        if not file_timestamp.exists():
            file_timestamp = Path(repo_dir / ".git" / "HEAD")
        try:
            age_seconds = time.time() - file_timestamp.stat().st_mtime
        except OSError:
            # A .git with neither FETCH_HEAD nor HEAD is corrupt (e.g. a
            # partially deleted clone). Force the update path so the
            # broken-repository recovery below removes and re-clones it.
            age_seconds = float("inf")
        if refresh is None or age_seconds > refresh.total_seconds:
            # Try to update the repository, recovering from broken state if needed
            old_sha: str | None = None
            try:
                # First verify the repository is valid by checking HEAD
                # Use git_dir parameter to prevent git from walking up to parent repos
                old_sha = run_git_command(
                    ["git", "rev-parse", "HEAD"], git_dir=repo_dir
                )

                _LOGGER.info("Updating %s", safe_key)
                _LOGGER.debug("Location: %s", repo_dir)

                # The entry is about to be rewritten; drop the marker so a
                # timed-out peer's fallback and the incomplete-entry check
                # can tell a quiescent complete entry from one mid-rewrite,
                # and so an update interrupted by a crash re-clones instead
                # of being trusted.
                _clear_clone_complete_marker(repo_dir)

                # Stash local changes (if any)
                # Use git_dir to ensure this only affects the specific repo
                run_git_command(
                    ["git", "stash", "push", "--include-untracked"],
                    git_dir=repo_dir,
                )

                # Fetch from the remote. --depth=1 keeps the clone shallow
                # while still picking up new commits when the remote tip
                # moves: a shallow fetch retrieves the current tip being
                # fetched, whether that's an explicit ref or the remote's
                # default branch, then reset --hard FETCH_HEAD updates the
                # working tree to it.
                cmd = ["git", "fetch", "--depth=1", "--", "origin"]
                if ref is not None:
                    cmd.append(ref)
                run_git_command(cmd, git_dir=repo_dir)

                # Hard reset to FETCH_HEAD (short-lived git ref corresponding to most recent fetch)
                run_git_command(
                    ["git", "reset", "--hard", "FETCH_HEAD"],
                    git_dir=repo_dir,
                )

                # Inside the try so a submodule failure routes through the
                # recovery re-clone below instead of leaving a repo that the
                # refresh window would silently accept on the next run.
                if init_submodules:
                    update_submodules(repo_dir, key)

                # Recorded so revert() can tell whether the checkout is
                # still the one this update produced.
                new_sha = run_git_command(
                    ["git", "rev-parse", "HEAD"], git_dir=repo_dir
                )

                # The rewrite finished; the entry is trustworthy again.
                _write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key)
            except GitException as err:
                # Repository is in a broken state or update failed
                # Only attempt recovery once to prevent infinite recursion
                if not _recover_broken:
                    _LOGGER.error(
                        "Repository %s recovery failed, cannot retry (already attempted once)",
                        safe_key,
                    )
                    raise

                _LOGGER.warning(
                    "Repository %s has issues (%s), attempting recovery",
                    safe_key,
                    err,
                )
                _LOGGER.info("Removing broken repository at %s", repo_dir)
                _remove_repo_dir(repo_dir)
                _LOGGER.info("Successfully removed broken repository, re-cloning...")

                # Re-clone while still holding the lock; going through the
                # public wrapper would try to re-acquire it and deadlock.
                # Set _recover_broken=False to prevent infinite recursion.
                result = _clone_or_update_locked(
                    url=original_url,
                    ref=ref,
                    refresh=refresh,
                    domain=domain,
                    username=username,
                    password=password,
                    init_submodules=init_submodules,
                    subpath=subpath,
                    lock=lock,
                    _recover_broken=False,
                )
                _LOGGER.info("Repository %s successfully recovered", safe_key)
                return result

            def revert() -> bool:
                """Reset the checkout to the pre-update SHA.

                Returns False when the revert did not happen: the cache
                entry lock could not be acquired in time, the checkout
                moved since this update (another process refreshed it), or
                the reset itself failed. A retry cannot reach the
                pre-update content then.
                """
                if lock is None:
                    # The wrapper already warned about the unlockable
                    # filesystem; revert unlocked like everything else.
                    status = _LockStatus.UNAVAILABLE
                else:
                    status = _acquire_repo_lock(
                        lock, safe_key, _REVERT_LOCK_TIMEOUT_SECONDS
                    )
                if status is _LockStatus.TIMEOUT:
                    # revert() only runs on an already-failing path; skip
                    # rather than hang so the original error can surface.
                    _LOGGER.warning(
                        "Could not lock %s to revert to %s, skipping revert; "
                        "the cached checkout keeps the un-reverted content "
                        "until its next refresh",
                        safe_key,
                        old_sha,
                    )
                    return False
                try:
                    # Anything can happen between the wrapper releasing the
                    # lock and revert() re-acquiring it; only undo this
                    # process's own update, never a peer's newer refresh.
                    head = run_git_command(
                        ["git", "rev-parse", "HEAD"], git_dir=repo_dir
                    )
                    if head != new_sha:
                        _LOGGER.warning(
                            "Not reverting %s: the checkout moved since this "
                            "update (another process refreshed it)",
                            safe_key,
                        )
                        return False
                    # Announced only once every skip check has passed, so
                    # the log says exactly one thing per outcome.
                    _LOGGER.info("Reverting changes to %s -> %s", safe_key, old_sha)
                    run_git_command(
                        ["git", "reset", "--hard", old_sha], git_dir=repo_dir
                    )
                except GitException as err:
                    # GitException is a cv.Invalid; letting it escape would
                    # replace the caller's original error with a bare git
                    # message. Report the failed reset like the skip above,
                    # and drop the marker: an entry whose reset fails cannot
                    # be trusted, so the next use re-clones it instead of
                    # the refresh window silently accepting it.
                    _LOGGER.warning(
                        "Could not revert %s to %s (%s), the entry will be "
                        "re-cloned on next use",
                        safe_key,
                        old_sha,
                        err,
                    )
                    _clear_clone_complete_marker(repo_dir)
                    return False
                finally:
                    if status is _LockStatus.ACQUIRED:
                        lock.release()
                return True

            return repo_dir, revert

    return repo_dir, None


GIT_DOMAINS = {
    "codeberg": "codeberg.org",
    "github": "github.com",
    "gitlab": "gitlab.com",
}


@dataclass(frozen=True)
class GitFile:
    domain: str
    owner: str
    repo: str
    filename: str
    ref: str = None
    query: str = None

    @property
    def git_url(self) -> str:
        return f"https://{self.domain}/{self.owner}/{self.repo}.git"

    @property
    def raw_url(self) -> str:
        if self.ref is None:
            raise ValueError("URL has no ref")
        if self.domain == "codeberg.org":
            return f"https://codeberg.org/{self.owner}/{self.repo}/raw/commit/{self.ref}/{self.filename}"
        if self.domain == "github.com":
            return f"https://raw.githubusercontent.com/{self.owner}/{self.repo}/{self.ref}/{self.filename}"
        if self.domain == "gitlab.com":
            return f"https://gitlab.com/{self.owner}/{self.repo}/-/raw/{self.ref}/{self.filename}"
        raise NotImplementedError(f"Git domain {self.domain} not supported")

    @classmethod
    def from_shorthand(cls, shorthand):
        """Parse a git shorthand URL into its components."""
        if not isinstance(shorthand, str):
            raise ValueError("Git shorthand must be a string")
        m = re.match(
            r"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>[a-zA-Z0-9\-_.\./]+))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
            shorthand,
        )
        if m is None:
            raise ValueError(
                "URL is not in expected github://username/name/[sub-folder/]file-path.yml[@branch-or-tag] format!"
            )
        if m.group("domain") not in GIT_DOMAINS:
            raise ValueError(f"Unknown git domain {m.group('domain')}")
        return cls(
            domain=GIT_DOMAINS[m.group("domain")],
            owner=m.group("owner"),
            repo=m.group("repo"),
            filename=m.group("filename"),
            ref=m.group("ref"),
            query=m.group("query"),
        )
