from __future__ import annotations

import hashlib
import json
import re
import shutil
import subprocess
import threading
import time
from pathlib import Path

from .build_queue import Queue
from .builder import (
    DEPLOYED,
    LATEST,
    Builder,
    write_json,
)
from .catalogs import catalog_translates, known_languages
from .sites import Sites

REFS_TTL_SECONDS = 10
GIT_TIMEOUT_SECONDS = 900
TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")


class Library(Sites, Queue, Builder):
    """Mirror of the documented repository and the site of every version.

    Args:
        repository: git URL of the documented repository.
        data_dir: shared volume holding mirror, queue, states, opens, scratch
            and sites.
        jobs: parallel Sphinx processes per build.
        package_dir: the ``infinito_docs`` package directory.
        snapshot_dir: the deployed working tree, built as version ``deployed``.
        preload: language codes a finished version build queues on its own;
            every other translation is built when a visitor opens it.
    """

    def __init__(
        self, repository, data_dir, jobs, package_dir, snapshot_dir, preload=()
    ):
        data = Path(data_dir)
        self.repository = repository
        self.mirror = data / "repo.git"
        self.sites = data / "sites"
        self.translations = data / "translations"
        self.queue = data / "queue"
        self.states = data / "states"
        self.opened = data / "opened"
        self.scratch = data / "work"
        self.prepared = data / "prepared"
        self.lock_file = data / "builder.lock"
        self.jobs = jobs
        self.preload = frozenset(code for code in preload if code)
        self.package_dir = Path(package_dir)
        self.snapshot = Path(snapshot_dir)
        self._snapshot_ref = ""
        self._snapshot_translates: dict[str, bool] = {}
        self._snapshot_lock = threading.Lock()
        self._tooling_ref = ""
        self._refs = ("", [])
        self._refs_read = 0.0
        self._lock = threading.Lock()
        self._lock_handle = None
        self._in_flight: set[str] = set()

    def _git(self, *args):
        return subprocess.run(
            ["git", "--git-dir", str(self.mirror), *args],
            check=True,
            capture_output=True,
            text=True,
            timeout=GIT_TIMEOUT_SECONDS,
        ).stdout

    def refs(self):
        """Return ``(head, tags)`` of the mirror.

        Returns:
            The default branch's commit, empty before the first fetch, and the
            release tags newest first.
        """
        with self._lock:
            if time.monotonic() - self._refs_read < REFS_TTL_SECONDS:
                return self._refs
        head, tags = "", []
        if (self.mirror / "HEAD").is_file():
            try:
                head = self._git("rev-parse", "HEAD").strip()
                tags = sorted(
                    (t for t in self._git("tag", "--list").split() if TAG.match(t)),
                    key=lambda tag: tuple(
                        int(part) for part in TAG.match(tag).groups()
                    ),
                    reverse=True,
                )
            except subprocess.SubprocessError:
                head, tags = "", []
        with self._lock:
            self._refs, self._refs_read = (head, tags), time.monotonic()
        return head, tags

    def _forget_refs(self):
        with self._lock:
            self._refs_read = 0.0

    def versions(self):
        deployed = [DEPLOYED] if self.snapshot.is_dir() else []
        return [LATEST, *self.refs()[1], *deployed]

    def snapshot_ref(self):
        """Return the content digest of the deployed working tree."""
        if not self._snapshot_ref:
            digest = hashlib.sha256()
            for path in sorted(p for p in self.snapshot.rglob("*") if p.is_file()):
                digest.update(
                    str(path.relative_to(self.snapshot)).encode("utf-8") + b"\0"
                )
                digest.update(path.read_bytes())
            self._snapshot_ref = digest.hexdigest()
        return self._snapshot_ref

    def tooling_ref(self):
        """Return the content digest of the generators and the sphinx config.

        A prepared tree is only reusable while the code that produced it is
        unchanged. The generators ship in the image, not in the documented
        ref, so a new image with the same sources must not reuse the previous
        generated output.
        """
        if not self._tooling_ref:
            digest = hashlib.sha256()
            for path in sorted(p for p in self.package_dir.rglob("*") if p.is_file()):
                if path.suffix == ".pyc":
                    continue
                digest.update(
                    str(path.relative_to(self.package_dir)).encode("utf-8") + b"\0"
                )
                digest.update(path.read_bytes())
            self._tooling_ref = digest.hexdigest()
        return self._tooling_ref

    def _wanted_ref(self, version, head):
        """Return the ref ``version`` should have been built from.

        Args:
            version: ``latest``, ``deployed`` or a release tag.
            head: the mirror's current commit.
        """
        if version == LATEST:
            return head
        if version == DEPLOYED:
            return self.snapshot_ref()
        return version

    def _current(self, version, head):
        return (
            self.servable(version)
            and self.built_ref(version) == self._wanted_ref(version, head)
            and self._language_index_is_current(version)
        )

    def outdated(self, version) -> bool:
        """Whether a built site answers for a snapshot it was not built from.

        ``deployed`` names the working tree a run just staged, so serving an
        earlier build under it is a wrong answer rather than a stale one. A
        released tag cannot move, and ``latest`` is allowed to serve its
        previous build while the next one is still being made.

        Args:
            version: ``latest``, ``deployed`` or a release tag.
        """
        if version != DEPLOYED:
            return False
        head, _ = self.refs()
        return not self._current(version, head)

    def _translation_current(self, version, code, head):
        return self.translation_servable(version, code) and self.translation_ref(
            version, code
        ) == self._wanted_ref(version, head)

    def _state(self, version):
        path = self.states / f"{version}.json"
        try:
            return json.loads(path.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            return {}

    def _save_state(self, version, **state):
        self.states.mkdir(parents=True, exist_ok=True)
        write_json(self.states / f"{version}.json", state)

    def mark_opened(self, version):
        """Stamp ``version`` as opened now, the order the builder prunes tags in.

        Every request below the version calls this, so the directory is only
        created when the first stamp misses it.

        Args:
            version: a listed version; ``latest`` and ``deployed`` are never
                pruned, so they stay unmarked.
        """
        if version in (LATEST, DEPLOYED):
            return
        mark = self.opened / version
        try:
            mark.touch()
        except FileNotFoundError:
            self.opened.mkdir(parents=True, exist_ok=True)
            mark.touch()

    def status(self):
        head, tags = self.refs()
        report = []
        for name in [LATEST, *tags]:
            built = self.servable(name)
            state = self._state(name)
            if self.is_queued(name):
                phase = "building" if state.get("state") == "building" else "queued"
            elif state.get("state") == "failed":
                phase = "failed"
            elif name == LATEST and not head and not built:
                phase = "queued"
                state = {"phase": "fetching"}
            else:
                phase = "ready" if built else "missing"
            progress = {"ready": 100, "building": state.get("progress", 0)}
            report.append(
                {
                    "name": name,
                    "built": built,
                    "state": phase,
                    "progress": progress.get(phase, 0),
                    "phase": "" if phase == "ready" else state.get("phase", ""),
                    "log": state.get("log", []) if phase == "failed" else [],
                }
            )
        return report

    def fetch(self):
        try:
            self._git("rev-parse", "--verify", "HEAD")
        except subprocess.CalledProcessError:
            partial = self.mirror.with_name(f"{self.mirror.name}.partial")
            shutil.rmtree(partial, ignore_errors=True)
            subprocess.run(
                [
                    "git",
                    "clone",
                    "--mirror",
                    "--quiet",
                    self.repository,
                    str(partial),
                ],
                check=True,
                capture_output=True,
                timeout=GIT_TIMEOUT_SECONDS,
            )
            shutil.rmtree(self.mirror, ignore_errors=True)
            partial.replace(self.mirror)
        else:
            self._git("remote", "update", "--prune")
        self._forget_refs()
        self.request(LATEST, background=True)
        self._refresh_stale_sites()

    def translatable(self, version, code):
        """Whether ``code`` may be queued for ``version`` before its site exists.

        Args:
            version: ``latest`` or a release tag.
            code: ISO 639-1 code.

        Returns:
            ``True`` once the built language index lists the code, and for the
            deployed snapshot already before its first build, from the catalog
            on disk. Only a queue admission - ``build_language`` re-checks the
            index and drops the marker when the catalog turns out to be empty.
        """
        if self.translates(version, code):
            return True
        if version != DEPLOYED:
            return False
        return (self.snapshot / "locale" / code / "LC_MESSAGES" / "docs.po").is_file()

    def untranslated(self, version, code):
        """Whether ``code`` is a language ``version`` will never publish a site in.

        Args:
            version: ``latest``, ``deployed`` or a release tag.
            code: first path segment below the version.

        Returns:
            For the deployed snapshot, already before its first build, ``True``
            when ``meta/languages.yml`` lists the code, its ``docs`` catalog
            translates nothing and no translated site of it is on disk: the
            rule the build writes into the language index, read from the same
            files. ``False`` for every other version, whose sources are not on
            disk until a build checks them out.
        """
        if version != DEPLOYED or self.translation_servable(version, code):
            return False
        if code not in known_languages(self.snapshot):
            return False
        with self._snapshot_lock:
            if code not in self._snapshot_translates:
                self._snapshot_translates[code] = catalog_translates(
                    self.snapshot, code
                )
            return not self._snapshot_translates[code]

    def _refresh_stale_sites(self):
        """Queue every already-served version whose site no longer matches its ref.

        A tag is built on demand and then left alone, so nothing would ever
        notice that its language index predates the translated-site split or
        that its sources moved. Only versions that are already servable are
        touched, which keeps an unbuilt tag on demand.
        """
        for version in self.versions():
            if self.servable(version):
                self.request(version, background=True)
