#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2017-2019 Joe Rickerby and contributors
# SPDX-License-Identifier: BSD-2-Clause

"""Bump the version number in all the right places."""

from __future__ import annotations

import os
import subprocess
import sys
import time
import urllib.parse
from pathlib import Path

import cyclopts
from packaging.version import InvalidVersion, Version

try:
    from github import Auth, Github, GithubException
except ImportError:
    Auth = None  # type: ignore
    Github = None  # type: ignore
    GithubException = Exception  # type: ignore

import ocrmypdf

config = [
    # file path, version find/replace format
    ("src/ocrmypdf/_version.py", '__version__ = "{}"'),
    ("pyproject.toml", 'version = "{}"'),
]

RED = "\u001b[31m"
GREEN = "\u001b[32m"
YELLOW = "\u001b[33m"
OFF = "\u001b[0m"

REPO_NAME = "ocrmypdf/OCRmyPDF"


def validate_release_notes(new_version: str) -> bool:
    """Check that the version appears in the release notes.

    Returns True if the version is found, False otherwise.
    """
    version_obj = Version(new_version)
    major = version_obj.major
    release_notes_path = Path(f"docs/releasenotes/version{major:02d}.md")

    if not release_notes_path.exists():
        print(f"{RED}error:{OFF} Release notes file not found: {release_notes_path}")
        return False

    content = release_notes_path.read_text(encoding="utf8")
    version_header = f"## v{new_version}"

    if version_header not in content:
        print(
            f"{RED}error:{OFF} Version v{new_version} not found in {release_notes_path}"
        )
        print(f"       Expected to find: {version_header}")
        return False

    print(f"{GREEN}Found v{new_version} in {release_notes_path}{OFF}")
    return True


def get_github_client():
    """Get an authenticated GitHub client."""
    if Github is None or Auth is None:
        print(f"{RED}error:{OFF} PyGithub is not installed")
        print("       Install with: pip install PyGithub")
        return None

    # Try GITHUB_TOKEN env var first
    token = os.environ.get("GITHUB_TOKEN")

    # Fall back to gh CLI
    if not token:
        try:
            result = subprocess.run(
                ["gh", "auth", "token"],
                capture_output=True,
                encoding="utf8",
                check=True,
            )
            token = result.stdout.strip()
        except (FileNotFoundError, subprocess.CalledProcessError):
            print(f"{RED}error:{OFF} No GitHub authentication found")
            print("       Set GITHUB_TOKEN env var or run: gh auth login")
            return None

    try:
        return Github(auth=Auth.Token(token))
    except GithubException as e:
        print(f"{RED}error:{OFF} Failed to authenticate with GitHub: {e}")
        return None


def wait_for_ci_completion(commit_sha: str, timeout_minutes: int = 30) -> bool:
    """Wait for CI to complete on the given commit.

    Returns True if CI passed, False otherwise.
    """
    gh = get_github_client()
    if gh is None:
        return False

    try:
        repo = gh.get_repo(REPO_NAME)
    except GithubException as e:
        print(f"{RED}error:{OFF} Failed to access repository: {e}")
        return False

    workflow_name = "Test and deploy"
    start_time = time.time()
    timeout_seconds = timeout_minutes * 60
    poll_interval = 30  # seconds

    print(f"Waiting for CI workflow '{workflow_name}' on commit {commit_sha[:8]}...")

    # First, wait for the workflow run to appear
    run = None
    while time.time() - start_time < timeout_seconds:
        try:
            runs = repo.get_workflow_runs(head_sha=commit_sha)
            for r in runs:
                if r.name == workflow_name:
                    run = r
                    break
            if run:
                break
        except GithubException as e:
            print(f"{YELLOW}Warning:{OFF} Error checking workflow runs: {e}")

        elapsed = int(time.time() - start_time)
        print(f"  Waiting for workflow to start... ({elapsed}s)")
        time.sleep(poll_interval)

    if not run:
        print(
            f"{RED}error:{OFF} Workflow run not found within {timeout_minutes} minutes"
        )
        return False

    print(f"  Found workflow run #{run.run_number} (ID: {run.id})")

    # Now wait for the workflow to complete
    while time.time() - start_time < timeout_seconds:
        try:
            run = repo.get_workflow_run(run.id)  # Refresh the run
        except GithubException as e:
            print(f"{YELLOW}Warning:{OFF} Error refreshing workflow run: {e}")
            time.sleep(poll_interval)
            continue

        status = run.status
        conclusion = run.conclusion

        elapsed = int(time.time() - start_time)
        if status == "completed":
            if conclusion == "success":
                print(f"{GREEN}CI passed!{OFF} (took {elapsed}s)")
                return True
            else:
                print(f"{RED}CI failed!{OFF} Conclusion: {conclusion}")
                print(f"  View details: {run.html_url}")
                return False
        else:
            print(f"  Status: {status} ({elapsed}s elapsed)")
            time.sleep(poll_interval)

    print(f"{RED}error:{OFF} CI did not complete within {timeout_minutes} minutes")
    return False


def push_and_wait_for_ci(branch: str) -> bool:
    """Push to remote and wait for CI tests to pass."""
    print("Pushing to GitHub...")

    push_result = subprocess.run(
        ["git", "push", "origin", branch],
        capture_output=True,
        encoding="utf8",
    )

    if push_result.returncode != 0:
        print(f"{RED}error:{OFF} Failed to push: {push_result.stderr}")
        return False

    # Get the commit SHA we just pushed
    sha_result = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        capture_output=True,
        encoding="utf8",
        check=True,
    )
    commit_sha = sha_result.stdout.strip()

    print(f"Pushed commit {commit_sha[:8]}")

    return wait_for_ci_completion(commit_sha)


def push_tag(tag: str) -> bool:
    """Push the tag to trigger release workflow."""
    print(f"Pushing tag {tag} to trigger release...")

    result = subprocess.run(
        ["git", "push", "origin", tag],
        capture_output=True,
        encoding="utf8",
    )

    if result.returncode != 0:
        print(f"{RED}error:{OFF} Failed to push tag: {result.stderr}")
        return False

    print(f"{GREEN}Tag {tag} pushed successfully!{OFF}")
    return True


def bump_version() -> None:
    """Bump the version number in all the right places."""
    current_version = ocrmypdf.__version__  # type: ignore
    try:
        commit_date_str = subprocess.run(
            [
                "git",
                "show",
                "--no-patch",
                "--pretty=format:%ci",
                f"v{current_version}^{{commit}}",
            ],
            check=True,
            capture_output=True,
            encoding="utf8",
        ).stdout
        cd_date, cd_time, cd_tz = commit_date_str.split(" ")

        url_opts = urllib.parse.urlencode(
            {"q": f"is:pr merged:>{cd_date}T{cd_time}{cd_tz}"}
        )
        url = f"https://github.com/{REPO_NAME}/pulls?{url_opts}"

        print(f"PRs merged since last release:\n  {url}")
        print()
    except subprocess.CalledProcessError as e:
        print(e)
        print("Failed to get previous version tag information.")
        print("Is the virtual environment active?")
        sys.exit(1)

    git_changes_result = subprocess.run(["git diff-index --quiet HEAD --"], shell=True)
    repo_has_uncommitted_changes = git_changes_result.returncode != 0

    if repo_has_uncommitted_changes:
        print("error: Uncommitted changes detected.")
        sys.exit(1)

    # fmt: off
    print(              'Current version:', current_version)
    new_version = input('    New version: ').strip()
    # fmt: on

    try:
        Version(new_version)
    except InvalidVersion:
        print("error: This version doesn't conform to PEP440")
        print("       https://www.python.org/dev/peps/pep-0440/")
        sys.exit(1)

    # Validate release notes contain this version
    if not validate_release_notes(new_version):
        print()
        print("Please add release notes for this version before proceeding.")
        print(f"Edit: docs/releasenotes/version{Version(new_version).major:02d}.md")
        sys.exit(1)

    actions = []

    for path_pattern, version_pattern in config:
        paths = list(Path().glob(path_pattern))

        if not paths:
            print(f"error: Pattern {path_pattern} didn't match any files")
            sys.exit(1)

        find_pattern = version_pattern.format(current_version)
        replace_pattern = version_pattern.format(new_version)
        found_at_least_one_file_needing_update = False

        for path in paths:
            contents = path.read_text(encoding="utf8")
            if find_pattern in contents:
                found_at_least_one_file_needing_update = True
                actions.append(
                    (
                        path,
                        find_pattern,
                        replace_pattern,
                    )
                )

        if not found_at_least_one_file_needing_update:
            print(
                f'''error: Didn't find any occurrences of "{find_pattern}" '''
                f'''in "{path_pattern}"'''
            )
            sys.exit(1)

    print()
    print("Here's the plan:")
    print()

    for action in actions:
        path, find, replace = action
        print(f"{path}  {RED}{find}{OFF} → {GREEN}{replace}{OFF}")

    print(f"Then commit, and tag as v{new_version}")

    answer = input("Proceed? [y/N] ").strip()

    if answer != "y":
        print("Aborted")
        sys.exit(1)

    for path, find, replace in actions:
        contents = path.read_text(encoding="utf8")
        contents = contents.replace(find, replace)
        path.write_text(contents, encoding="utf8")

    # Format only after every file (including pyproject.toml) reflects the new
    # version. Running `uv run` while pyproject.toml still had the old version
    # would leave its post-bump environment/lockfile resync to happen for the
    # first time during the commit's pre-commit hooks instead of here, which
    # then aborts the commit with a spurious "files were modified by this
    # hook" error.
    for path, _find, _replace in actions:
        if path.suffix == ".py":
            subprocess.run(["uv", "run", "ruff", "format", str(path)], check=True)

    print("Files updated.")
    print()

    while input('Type "done" to continue: ').strip().lower() != "done":
        pass

    subprocess.run(
        [
            "git",
            "commit",
            "--all",
            f"--message=Bump version: v{new_version}",
        ],
        check=True,
    )

    subprocess.run(
        [
            "git",
            "tag",
            "--annotate",
            f"--message=v{new_version}",
            f"v{new_version}",
        ],
        check=True,
    )

    print("Commit and tag created locally.")
    print()

    # Get current branch
    branch_result = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        capture_output=True,
        encoding="utf8",
        check=True,
    )
    branch = branch_result.stdout.strip()

    # Push commit and wait for CI
    if not push_and_wait_for_ci(branch):
        print()
        print(f"{RED}CI failed. The tag was NOT pushed.{OFF}")
        print("Fix the issues, then manually push the tag:")
        print(f"    git push origin v{new_version}")
        sys.exit(1)

    # Push tag to trigger release
    if not push_tag(f"v{new_version}"):
        print(f"{RED}Failed to push tag.{OFF} Push manually:")
        print(f"    git push origin v{new_version}")
        sys.exit(1)

    print()
    print(f"{GREEN}Done! Release workflow has been triggered.{OFF}")
    print()

    release_url = f"https://github.com/{REPO_NAME}/releases/tag/v{new_version}"
    print("Monitor the release at:")
    print(f"    {release_url}")


if __name__ == "__main__":
    os.chdir(Path(__file__).parent.parent.resolve())
    cyclopts.run(bump_version)
