"""The thin release entrypoint: claim an attempt, cut the draft, dispatch the gate.

Nothing here builds. The claim is an annotated attempt ref ``rc.<N>-vX.Y.Z``
pushed as exactly that ref. A version is spent only by publication: an attempt
that fails is abandoned with a marker ref, and the next cut is attempt N+1 of
the same version. At most one attempt, of any version, is outstanding.
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path

from scripts.releases.draft_warning import draft_body
from scripts.releases.versioning import (
    SEED, attempt_ref, derive_next_version, marker_ref, next_attempt,
    outstanding_attempts, parse_attempt_ref, parse_marker_ref,
)

WORKFLOW = "stable-release.yml"
# A fetch refspec may hold one ``*``; the parsers filter what the globs over-match.
_ATTEMPT_GLOBS = ("rc.*", "abandoned-rc.*")


class ReleaseRefused(RuntimeError):
    """The release cannot proceed, and nothing was silently skipped."""


def _git(repo: Path, *args: str) -> str:
    return subprocess.check_output(["git", *args], cwd=repo, text=True, encoding="utf-8").strip()


def _claims(repo: Path) -> list[str]:
    """Every local attempt ref and abandon marker ref."""
    listed = _git(repo, "tag", "--list", *_ATTEMPT_GLOBS)
    return [ref for ref in listed.splitlines()
            if parse_attempt_ref(ref) or parse_marker_ref(ref)]


def _claim_commit(repo: Path, tag: str) -> str:
    return _git(repo, "rev-parse", f"{tag}^{{commit}}")


def _refresh_claims(repo: Path, remote: str) -> None:
    _git(
        repo, "fetch", remote,
        "+refs/heads/main:refs/remotes/hermes-release/main",
        *(f"+refs/tags/{glob}:refs/tags/{glob}" for glob in _ATTEMPT_GLOBS),
    )


def _require_remote_main(repo: Path, commit: str) -> None:
    result = subprocess.run(
        ["git", "merge-base", "--is-ancestor", commit, "refs/remotes/hermes-release/main"],
        cwd=repo, capture_output=True,
    )
    if result.returncode != 0:
        raise ReleaseRefused(f"{commit} is not on origin/main")


def _claim_collision(repo: Path, remote: str, tag: str, error: Exception) -> ReleaseRefused:
    subprocess.run(["git", "tag", "--delete", tag], cwd=repo, capture_output=True)
    try:
        _git(repo, "fetch", remote, f"+refs/tags/{tag}:refs/tags/{tag}")
        details = _git(
            repo, "for-each-ref", f"refs/tags/{tag}",
            "--format=%(taggername)|%(taggerdate:iso-strict)|%(*objectname)",
        )
    except subprocess.CalledProcessError:
        return ReleaseRefused(f"claim {tag} could not be pushed: {error}")
    actor, when, commit = details.split("|", 2)
    return ReleaseRefused(f"{tag} was claimed by {actor} at {when} for {commit}")


def _outstanding_attempts(repo: Path, remote: str) -> list[tuple[str, int, str]]:
    """Attempts with no abandon marker whose version has no final tag on ``remote``.

    The predicate is the shared one in ``versioning``; this wrapper only owns
    the remote lookup of the final tags.
    """
    published: dict[str, bool] = {}

    def is_published(version: str) -> bool:
        if version not in published:
            published[version] = bool(_git(repo, "ls-remote", remote, f"refs/tags/v{version}"))
        return published[version]

    return outstanding_attempts(_claims(repo), is_published)


def _outstanding_attempt(repo: Path, remote: str) -> tuple[str, int, str] | None:
    """The one outstanding attempt as ``(version, attempt, ref)``, or None."""
    outstanding = _outstanding_attempts(repo, remote)
    if len(outstanding) > 1:
        refs = ", ".join(ref for *_rest, ref in outstanding)
        raise ReleaseRefused(
            f"more than one outstanding attempt ({refs}) — abandon all but one before releasing")
    return outstanding[0] if outstanding else None


def _attempt_run(execute, repository: str, ref: str, commit: str) -> dict | None:
    """The workflow run for ``ref`` at ``commit``, or None when none is listed."""
    raw = execute([
        "gh", "run", "list", "--repo", repository, "--workflow", WORKFLOW,
        "--branch", ref, "--json", "databaseId,url,headBranch,headSha,status",
    ])
    try:
        rows = json.loads(raw or "[]")
    except json.JSONDecodeError:
        return None
    return next((row for row in rows if isinstance(row, dict) and row.get("headSha") == commit
                 and row.get("url") and row.get("databaseId") is not None), None)


def _refuse_outstanding(outstanding: tuple[str, int, str], *, repo: Path, remote: str,
                        repository: str, execute) -> ReleaseRefused:
    """Print the blocking run and both ways out, then return the refusal to raise."""
    version, _attempt, ref = outstanding
    run = _attempt_run(execute, repository, ref, _claim_commit(repo, ref))
    lines = [f"{ref} is outstanding: it has no abandon marker and v{version} is not published."]
    lines.append(f"Workflow: {run['url']}" if run else f"No workflow run is listed for {ref}.")
    lines += ["If the attempt is unfixable, abandon it:",
              f"    python scripts/release.py abandon --version {version} --remote {remote}"]
    if run:
        lines += ["If the attempt is fixable, rerun its failed jobs:",
                  f"    gh run rerun {run['databaseId']} --failed --repo {repository}"]
    print("\n".join(lines), file=sys.stderr)
    return ReleaseRefused(f"{ref} is outstanding — publish or abandon it first")


def _require_ancestry(repo: Path, commit: str, published_commit: str | None) -> None:
    """A new attempt descends from the published stable head; abandoned attempts do not bind it."""
    if published_commit is None:
        return
    ancestor = subprocess.run(
        ["git", "merge-base", "--is-ancestor", published_commit, commit], cwd=repo, capture_output=True)
    if ancestor.returncode != 0:
        raise ReleaseRefused(
            f"{commit} does not descend from the published stable head {published_commit}")


def _next_claim_epoch(repo: Path) -> int:
    epochs = _git(repo, "for-each-ref", "refs/tags/rc.*", "--format=%(refname:strip=2) %(taggerdate:unix)")
    previous = [int(stamp) for ref, _, stamp in (line.partition(" ") for line in epochs.splitlines())
                if parse_attempt_ref(ref) and stamp.isdigit()]
    return max(int(time.time()), max(previous, default=0) + 1)


# GitHub refuses a release body longer than this with HTTP 422.
GITHUB_BODY_LIMIT = 125_000


def _changelog(repo: Path, repository: str, *, commit: str, tag: str, version: str,
               published: tuple[str, str | None], no_changelog: bool) -> str:
    """The commit changelog from the stable base to the cut commit.

    GitHub's generate-notes lists merged pull requests since the last published
    release, so it is empty on a repository that merges none (a fork).
    """
    from scripts import release as release_script

    published_version, base = published
    receipt = f"v{published_version}"
    if base is None and subprocess.run(
            ["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{receipt}^{{commit}}"],
            cwd=repo, capture_output=True).returncode == 0:
        # Before the first publication the seed version's tag is the base.
        base = receipt
    commits = release_script.get_commits(since_tag=base, until=commit, cwd=repo)
    return release_script.generate_changelog(
        commits, tag, version, repo_url=f"https://github.com/{repository}",
        prev_tag=receipt if base else None, no_changelog=no_changelog,
    )


def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
            execute, autopublish: bool = False, no_changelog: bool = False,
            skip_bundles: bool = False, skip_tests: bool = False,
            published: tuple[str, str | None] = (SEED, None),
            run_wait: float = 0, sleep=time.sleep) -> dict:
    """Claim the next attempt of the derived version, cut its draft, and start the gate.

    ``published`` is the stable channel's ``(version, commit)``; the commit is
    None before the first publication. ``skip_bundles`` and ``skip_tests`` are
    written into the claim, which is the one record every later job reads.
    ``run_wait`` is how many seconds to keep looking for the dispatched run;
    GitHub lists it a moment after the dispatch returns.
    """
    _refresh_claims(repo, remote)
    _require_remote_main(repo, commit)
    outstanding = _outstanding_attempt(repo, remote)
    if outstanding is not None:
        raise _refuse_outstanding(outstanding, repo=repo, remote=remote,
                                  repository=repository, execute=execute)
    published_version, published_commit = published
    _require_ancestry(repo, commit, published_commit)
    version = derive_next_version(published=published_version, bump=bump)
    if _git(repo, "ls-remote", remote, f"refs/tags/v{version}"):
        # A final tag records a started publication. Until its GitHub release is
        # public the published identity still names the previous version.
        raise ReleaseRefused(
            f"v{version} already has a final tag. Its publication is still finishing. "
            "Wait for Stable Release Publication, then cut again.")
    attempt = next_attempt(version, _claims(repo))
    tag = attempt_ref(version, attempt)
    # Built before the claim: a body GitHub refuses would otherwise burn the attempt.
    body = draft_body(version=version, attempt_ref=tag, notes=_changelog(
        repo, repository, commit=commit, tag=tag, version=version, published=published,
        no_changelog=no_changelog,
    ))
    if len(body) > GITHUB_BODY_LIMIT:
        raise ReleaseRefused(
            f"the {tag} draft body is {len(body)} characters; GitHub accepts at most "
            f"{GITHUB_BODY_LIMIT}. Nothing was claimed. Re-run with --no-changelog.")
    claim_epoch = _next_claim_epoch(repo)
    claim = json.dumps({
        "schema": 1,
        "version": version,
        "attempt": attempt,
        "commit": commit,
        "autopublish": autopublish,
        "skipBundles": skip_bundles,
        "skipTests": skip_tests,
        "claimEpoch": claim_epoch,
    }, sort_keys=True, separators=(",", ":"))
    subprocess.check_output(
        ["git", "tag", "-a", tag, commit, "-m", claim], cwd=repo,
        text=True, encoding="utf-8",
        env={**os.environ, "GIT_COMMITTER_DATE": f"@{claim_epoch} +0000"},
    )
    try:
        _git(repo, "push", remote, f"refs/tags/{tag}")
    except subprocess.CalledProcessError as error:
        raise _claim_collision(repo, remote, tag, error) from error
    ref = f"refs/tags/{tag}"
    remote_ref = dict(line.split()[::-1] for line in _git(
        repo, "ls-remote", remote, ref, f"{ref}^{{}}",
    ).splitlines())
    if (remote_ref.get(ref) != _git(repo, "rev-parse", ref)
            or remote_ref.get(f"{ref}^{{}}") != commit):
        raise ReleaseRefused(f"claim {tag} did not persist with exact remote custody")
    # The pre-check and the push are not one atomic step: a concurrent cut of a
    # different version passes the same check. Re-read before anything starts.
    _refresh_claims(repo, remote)
    _outstanding_attempt(repo, remote)
    try:
        # The body carries a warning at both ends, so it is handed over as a file.
        file = tempfile.NamedTemporaryFile(
            "w", suffix=".md", delete=False, encoding="utf-8", newline="\n")
        try:
            file.write(body)
        finally:
            file.close()
        try:
            # gh prints the new release's page. A draft is not reachable
            # through releases/tag/<tag> (GitHub serves it at an untagged-*
            # URL), so this printed page is the only link to it.
            url = (execute([
                "gh", "release", "create", tag, "--repo", repository,
                "--verify-tag", "--draft", "--notes-file", file.name,
                "--title", f"Hermes Agent v{version}",
            ]) or "").strip()
        finally:
            os.unlink(file.name)
        execute([
            "gh", "workflow", "run", WORKFLOW, "--ref", tag, "--repo", repository,
            "--raw-field", f"tag={tag}",
        ])
        run_url = _await_run(execute, repository, tag, wait=run_wait, sleep=sleep)
    except Exception as exc:
        raise ReleaseRefused(f"release {tag} never started: {exc}") from exc
    return {"version": version, "tag": tag, "commit": commit, "url": url, "repository": repository,
            "final_url": f"https://github.com/{repository}/releases/tag/v{version}",
            "run_url": run_url, "autopublish": autopublish,
            "skip_bundles": skip_bundles, "skip_tests": skip_tests}


RUN_POLL_SECONDS = 2
RUN_WAIT_SECONDS = 30


def _await_run(execute, repository: str, tag: str, *, wait: float, sleep) -> str:
    """The dispatched run's URL, polling up to ``wait`` seconds. Empty if it never lists."""
    polls = int(wait // RUN_POLL_SECONDS)
    for poll in range(polls + 1):
        run_url = _dispatched_run(execute([
            "gh", "run", "list", "--repo", repository, "--workflow", WORKFLOW,
            "--branch", tag, "--json", "databaseId,url,headBranch,status",
        ]), tag)
        if run_url or poll == polls:
            return run_url
        sleep(RUN_POLL_SECONDS)
    return ""


def _dispatched_run(raw: str, tag: str) -> str:
    """The run URL for the claim ref. Empty when the list has no such run yet."""
    try:
        rows = json.loads(raw or "[]")
    except json.JSONDecodeError:
        return ""
    matched = [row for row in rows if isinstance(row, dict) and row.get("headBranch") == tag and row.get("url")]
    if len(matched) != 1:
        return ""
    return str(matched[0]["url"])


def _latest_run(raw: str) -> str:
    """The URL of the newest listed run. Empty when the list names none."""
    try:
        rows = json.loads(raw or "[]")
    except json.JSONDecodeError:
        return ""
    for row in rows:
        if isinstance(row, dict) and row.get("url"):
            return str(row["url"])
    return ""


def _release_view(tag: str, repository: str, inspect) -> dict | None:
    try:
        return json.loads(inspect([
            "gh", "release", "view", tag, "--repo", repository,
            "--json", "tagName,isDraft,isPrerelease",
        ]))
    except ReleaseRefused as exc:
        if "not found" in str(exc).lower():
            return None
        raise


def _outstanding_ref(repo: Path, version: str) -> str:
    """The attempt ref whose draft the publication pass must find."""
    attempt = next_attempt(version, _claims(repo)) - 1
    if attempt < 1:
        raise ReleaseRefused(f"stable {version} has no claimed attempt to publish")
    return attempt_ref(version, attempt)


def _preflight_publish(version: str, repository: str, inspect, head_version,
                       repo: Path | None, remote: str | None) -> None:
    requested = tuple(map(int, version.split(".")))
    head = head_version()
    if head and tuple(map(int, head.split("."))) >= requested:
        raise ReleaseRefused(f"stable {version} is burned or superseded by {head}")
    if repo is None or remote is None:
        raise ValueError("preflight needs the release repository to find the attempt ref")
    # The draft lives on the attempt ref, never on the final tag and never on
    # the old v{version}-rc shape.
    _refresh_claims(repo, remote)
    attempt_ref = _outstanding_ref(repo, version)
    rows = [(tag, row) for tag in (f"v{version}", attempt_ref)
            if (row := _release_view(tag, repository, inspect)) is not None]
    if len(rows) != 1 or rows[0][0] != attempt_ref or rows[0][1].get("isDraft") is not True:
        raise ReleaseRefused(f"stable {version} is burned or has no release draft")


def publish(version: str, *, repository: str, dispatch, inspect=None, head_version=None,
            repo: Path | None = None, remote: str | None = None) -> dict:
    """Request ordered publication through the one production sequencer."""
    tag = f"v{version}"
    from hermes_cli.update_channel import STABLE_TAG_RE

    if not STABLE_TAG_RE.fullmatch(tag):
        raise ReleaseRefused(f"{version} is not a stable version")
    if inspect is not None and head_version is not None:
        _preflight_publish(version, repository, inspect, head_version, repo, remote)
    dispatch([
        "gh", "workflow", "run", "stable-release-publication.yml",
        "--repo", repository, "--raw-field", f"version={version}",
    ])
    return {"requested": tag, "version": version, "repository": repository}


def abandon(version: str, *, repo: Path, remote: str, repository: str, delete, inspect=None) -> dict:
    """Clear the outstanding attempt of ``version``. The attempt ref stays; the marker is the record.

    The draft goes first: a cleared attempt with a live draft could still be
    published by hand, while a draftless outstanding attempt is just abandoned
    again. This reads every outstanding attempt, not the one-attempt view, so
    it still clears one when a concurrent cut left two.
    """
    _refresh_claims(repo, remote)
    matching = [found for found in _outstanding_attempts(repo, remote) if found[0] == version]
    if len(matching) != 1:
        raise ReleaseRefused(f"stable {version} has no outstanding attempt to abandon")
    _version, attempt, tag = matching[0]
    if inspect is not None:
        draft = _release_view(tag, repository, inspect)
        if draft is not None:
            if draft.get("isDraft") is not True:
                raise ReleaseRefused(f"{tag} is published and cannot be abandoned")
            delete(["gh", "release", "delete", tag, "--repo", repository, "--yes"])
    marker = marker_ref(version, attempt)
    message = json.dumps({"schema": 1, "version": version, "attempt": attempt, "attemptRef": tag},
                         sort_keys=True, separators=(",", ":"))
    _git(repo, "tag", "-a", marker, f"{tag}^{{commit}}", "-m", message)
    try:
        _git(repo, "push", remote, f"refs/tags/{marker}")
    except subprocess.CalledProcessError as error:
        raise _claim_collision(repo, remote, marker, error) from error
    return {"version": version, "tag": tag, "marker": marker, "repository": repository}


def next_steps(result: dict, *, bold: bool = False) -> str:
    """Say what was attempted, where its CI runs, and the one command that publishes it.

    ``bold`` wraps the skipped-tests warning in ANSI bold; the caller decides
    from the terminal, so piped output stays plain.
    """
    version, tag = result["version"], result["tag"]
    actions_url = f"https://github.com/{result['repository']}/actions/workflows/{WORKFLOW}"
    lines = [
        f"Attempting release {tag} for v{version}.",
        "Release CI: " + (result.get("run_url")
                          or f"the run is not listed yet. Find {tag} at {actions_url}"),
    ]
    if result["skip_bundles"]:
        lines.append("Bundles are skipped. Only the tag, the GitHub release and the Docker image "
                     "ship; the desktop, Termux and Store channels stay on the previous release.")
    if result["skip_tests"]:
        warning = "Tests are skipped, since you passed --skip-tests"
        lines.append(f"\033[1m{warning}\033[0m" if bold else warning)
    lines.append(f"Draft release: {result['url']}")
    if result["autopublish"]:
        lines.append("Edit the notes in the draft release. Autopublish is on: "
                     f"when Release CI is green, v{version} publishes itself.")
    else:
        lines.append("Edit the notes in the draft release. "
                     f"When Release CI is green, to publish v{version}, run:")
        lines.append(f"    python scripts/release.py publish --version {version} --remote origin")
    return "\n".join(lines)


def cmd_release(args) -> None:
    """The ``release`` subcommand: claim, draft, dispatch."""
    from scripts import release as release_script

    repo = release_script.REPO_ROOT
    remote = release_script.resolve_push_remote(args.remote)
    repository = release_script.remote_github_repo(remote)
    if not repository:
        raise SystemExit(f"release: remote {remote!r} does not point at a GitHub repository")
    commit = _git(repo, "rev-parse", "--verify", f"{args.commit}^{{commit}}")

    def execute(command: list[str]) -> None:
        completed = subprocess.run(command, cwd=repo, capture_output=True, text=True, encoding="utf-8")
        if completed.returncode != 0:
            raise RuntimeError(completed.stderr.strip() or "release command failed")
        return completed.stdout

    from scripts.releases.versioning import published_stable_identity
    result = release(
        commit, bump=args.bump, repo=repo, remote=remote, repository=repository,
        execute=execute, autopublish=args.autopublish, no_changelog=args.no_changelog,
        skip_bundles=args.skip_bundles, skip_tests=args.skip_tests,
        published=published_stable_identity(repository), run_wait=RUN_WAIT_SECONDS,
    )
    print(next_steps(result, bold=sys.stdout.isatty() and "NO_COLOR" not in os.environ))


def _command_repository(args) -> tuple[Path, str, str]:
    from scripts import release as release_script

    repo = release_script.REPO_ROOT
    remote = release_script.resolve_push_remote(args.remote)
    repository = release_script.remote_github_repo(remote)
    if not repository:
        raise SystemExit(f"release: remote {remote!r} does not point at a GitHub repository")
    return repo, remote, repository


def _execute(repo: Path, command: list[str]) -> None:
    completed = subprocess.run(command, cwd=repo, capture_output=True, text=True, encoding="utf-8")
    if completed.returncode != 0:
        raise ReleaseRefused(completed.stderr.strip() or "release command failed")


def _inspect(repo: Path, command: list[str]) -> str:
    completed = subprocess.run(command, cwd=repo, capture_output=True, text=True, encoding="utf-8")
    if completed.returncode != 0:
        raise ReleaseRefused(completed.stderr.strip() or "release inspection failed")
    return completed.stdout


def publish_steps(result: dict) -> str:
    """Say that publication was requested, and where to watch it."""
    version = result["version"]
    lines = [
        f"Requested publication of v{version}.",
        f"Workflow: {result['run_url']}" if result.get("run_url") else "Workflow: the run is not listed yet. Open the Actions tab.",
        "Wait for that workflow to finish. It publishes the draft and moves the stable channel.",
        f"The release page is https://github.com/{result['repository']}/releases/tag/v{version}.",
    ]
    return "\n".join(lines)


def abandon_steps(result: dict) -> str:
    """Say which attempt is cleared and which attempt the next cut takes."""
    version, tag = result["version"], result["tag"]
    _version, attempt = parse_attempt_ref(tag)
    return "\n".join([
        f"Cleared {tag}. The marker {result['marker']} records it.",
        f"v{version} is not spent. The next cut is rc.{attempt + 1}-v{version}.",
    ])


def cmd_publish(args) -> None:
    repo, remote, repository = _command_repository(args)
    from scripts.releases.versioning import published_stable_version
    result = publish(args.version, repository=repository,
                     dispatch=lambda command: _execute(repo, command),
                     inspect=lambda command: _inspect(repo, command),
                     head_version=lambda: published_stable_version(repository),
                     repo=repo, remote=remote)
    listed = _inspect(repo, [
        "gh", "run", "list", "--repo", repository, "--workflow", "stable-release-publication.yml",
        "--json", "databaseId,url,headBranch,status", "--limit", "1",
    ])
    print(publish_steps({**result, "run_url": _latest_run(listed)}))


def cmd_abandon(args) -> None:
    repo, remote, repository = _command_repository(args)
    result = abandon(args.version, repo=repo, remote=remote, repository=repository,
                     delete=lambda command: _execute(repo, command),
                     inspect=lambda command: _inspect(repo, command))
    print(abandon_steps(result))
