# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
# SPDX-License-Identifier: LGPL-3.0-or-later
#
# This file is part of IFC URL.
#
# IFC URL is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IFC URL is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL.  If not, see <http://www.gnu.org/licenses/>.

"""Full working-tree checkouts for ifc:// URLs.

The bare cache clone in :mod:`ifcurl.git` is read-only by design.  When a
tool (Bonsai, ifcviewer) needs an actual file path it can open and later
commit changes to, this module provides a writable checkout alongside the
bare clone.

Layout inside the per-URL cache directory::

    ~/.cache/ifcurl/<url-hash>/
        repo.git/      ← bare clone (fetch/read layer, managed by git.py)
        checkout/      ← full working tree (this module)

The checkout is cloned locally from the bare repo — no extra network
traffic — and placed at the requested commit as a **detached HEAD**.  A
detached HEAD forces the user to create a named branch before committing,
which is the intended workflow: edit in the checkout, branch, push to a
local working repo (see :mod:`ifcurl.discover` and the origin-remapping
issue ifcurl-oef).

Viewer artefacts (``*.ifcview``, ``*.rdbview``, etc.) are added to
``.git/info/exclude`` so they are never accidentally staged.
"""

from __future__ import annotations

import logging
from pathlib import Path

_logger = logging.getLogger(__name__)

_VIEWER_EXCLUDES = [
    "# ifcurl: viewer artefacts — do not commit",
    "*.ifcview",
    "*.rdbview",
    "*.rdb",
    "*.sqlite",
    "*.sqlite-wal",
    "*.sqlite-shm",
]

try:
    import git
    import git.exc

    _HAS_GITPYTHON = True
except ImportError:
    _HAS_GITPYTHON = False

from ifcurl.git import _cache_dir_for, fetch_ifc
from ifcurl.url import IfcUrl


def get_checkout(ifc_url: IfcUrl, token: str | None = None) -> Path:
    """Return the path to the IFC file within a full working-tree checkout.

    Ensures the bare cache clone is up to date, then creates or updates a
    working checkout at ``<cache_dir>/checkout/``.  The checkout is placed
    at the resolved commit as a detached HEAD so the user must create a
    branch before committing any edits.

    Viewer artefact patterns are written to ``.git/info/exclude`` on first
    creation so they are locally ignored without modifying the repo's own
    ``.gitignore``.

    :param ifc_url: A parsed :class:`IfcUrl` with ``transport != 'local'``
        and a ``path`` parameter set.
    :param token: Optional bearer token for HTTPS authentication.
    :raises ImportError: If GitPython is not installed.
    :raises ValueError: For local-transport URLs (no checkout needed) or
        when ``ifc_url.path`` is unset.
    """
    if not _HAS_GITPYTHON:
        raise ImportError(
            "GitPython is not installed.  Install with: pip install gitpython"
        )
    if ifc_url.transport == "local":
        raise ValueError(
            "get_checkout is not applicable to local-transport URLs; "
            "access the file directly from the working tree."
        )
    if ifc_url.path is None:
        raise ValueError("URL has no 'path' parameter — cannot locate IFC file")

    hexsha, _, _ = fetch_ifc(ifc_url, token=token)
    cache_dir = _cache_dir_for(ifc_url.git_remote_url())
    checkout_dir = _ensure_checkout(cache_dir, hexsha)
    _ensure_viewer_excludes(checkout_dir)
    return checkout_dir / ifc_url.path


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _ensure_checkout(cache_dir: Path, hexsha: str) -> Path:
    """Create or update the working checkout to *hexsha*; return its path.

    Clones from the bare cache if the checkout does not yet exist.  Then
    checks out *hexsha* as a detached HEAD.  If *hexsha* is absent from the
    checkout's local objects, fetches from origin (the bare cache, a local
    path — no network) before retrying.
    """
    checkout_dir = cache_dir / "checkout"
    bare_dir = cache_dir / "repo.git"

    if not checkout_dir.exists():
        _logger.debug("Creating checkout at %s", checkout_dir)
        git.Repo.clone_from(str(bare_dir), str(checkout_dir))

    repo = git.Repo(str(checkout_dir))

    # Fast path: already detached at the right commit.
    try:
        if repo.head.is_detached and repo.head.commit.hexsha == hexsha:
            return checkout_dir
    except Exception:
        pass

    try:
        repo.git.checkout("--detach", hexsha)
    except git.exc.GitCommandError:
        # Commit may not be in the checkout yet (bare cache received it after
        # the checkout was cloned).  Fetch from origin (bare cache, local path
        # — no network).  Use default refspec so git maps heads/* to
        # remotes/origin/*, avoiding the "refusing to fetch into checked-out
        # branch" error when a branch is currently active in the checkout.
        _logger.debug("Commit %s absent from checkout; fetching from bare cache", hexsha[:8])
        repo.git.fetch("origin")
        repo.git.checkout("--detach", hexsha)

    return checkout_dir


def _ensure_viewer_excludes(checkout_dir: Path) -> None:
    """Append missing viewer-artefact patterns to ``.git/info/exclude``.

    Uses the local exclude file rather than ``.gitignore`` so the patterns
    are never committed and do not conflict with the project's own ignore
    rules.
    """
    exclude_file = checkout_dir / ".git" / "info" / "exclude"
    exclude_file.parent.mkdir(parents=True, exist_ok=True)

    existing = exclude_file.read_text() if exclude_file.exists() else ""
    missing = [line for line in _VIEWER_EXCLUDES if line not in existing]
    if not missing:
        return

    with open(exclude_file, "a") as f:
        if existing and not existing.endswith("\n"):
            f.write("\n")
        for line in missing:
            f.write(line + "\n")
