#!/usr/bin/env -S uv run --script
# [MISE] dir="{{cwd}}"
# [MISE] description="Recommended release entrypoint after toolchain setup: mirror assets from your fork to upstream"
# [MISE] quiet=true
# [MISE] raw=true
# [USAGE] about "Automate the fork-backed release flow for plexos2duckdb"
# /// script
# requires-python = ">=3.14"
# dependencies = [
#   "cyclopts>=4.10.2",
#   "rich>=14.1.0",
# ]
# ///

import dataclasses
import datetime as dt
import json
import os
import pathlib
import re
import shlex
import subprocess
import time
import typing
import urllib.parse

import cyclopts
import rich.console

DEFAULT_UPSTREAM_REPO = "epri-dev/plexos2duckdb"
DEFAULT_WORKFLOW = "cd.yml"
DEFAULT_DOWNLOAD_DIR_TEMPLATE = "dist/releases/{tag}"
RUN_LOOKUP_TIMEOUT_SECONDS = 90
RELEASE_ASSET_TIMEOUT_SECONDS = 120
POLL_INTERVAL_SECONDS = 3

APP_HELP = """\
Automate the fork-backed release flow for plexos2duckdb.

Recommended order for someone new to the project:
  0. mise run plexos2duckdb:ensure-rust-toolchain
  1. Preferred: mise run plexos2duckdb:release-via-fork -- mirror TAG

Manual release order if you need to inspect each stage:
  1. mise run plexos2duckdb:release-sync-fork -- --fork-repo OWNER/REPO
  2. mise run plexos2duckdb:release-build-on-fork -- TAG --fork-repo OWNER/REPO
  3. mise run plexos2duckdb:release-download-fork-assets -- TAG --fork-repo OWNER/REPO
  4. mise run plexos2duckdb:release-upload-upstream-assets -- TAG --fork-repo OWNER/REPO

Environment variables:
  PLEXOS2DUCKDB_FORK_REPO            Fork repo in OWNER/REPO format.
  PLEXOS2DUCKDB_UPSTREAM_REPO        Upstream repo. Default: epri-dev/plexos2duckdb
  PLEXOS2DUCKDB_RELEASE_BRANCH       Branch to sync and tag. Default: local origin HEAD or main.
  PLEXOS2DUCKDB_RELEASE_WORKFLOW     Workflow file name. Default: cd.yml
  PLEXOS2DUCKDB_RELEASE_WORKFLOW_REF Ref containing the workflow file. Default: release branch.
  PLEXOS2DUCKDB_RELEASE_DOWNLOAD_DIR Download dir template. Default: dist/releases/{tag}

Examples:
  mise run plexos2duckdb:release-via-fork -- mirror v0.1.0-beta.8 --fork-repo YOURUSER/plexos2duckdb
  mise run plexos2duckdb:release-via-fork -- build-on-fork v0.1.0-beta.8 --fork-repo YOURUSER/plexos2duckdb
  mise run plexos2duckdb:release-via-fork -- upload-upstream-assets v0.1.0-beta.8 --fork-repo YOURUSER/plexos2duckdb
"""

app = cyclopts.App(help=APP_HELP)
stdout_console = rich.console.Console()
stderr_console = rich.console.Console(stderr=True)

ForkRepoOption = typing.Annotated[
    str | None,
    cyclopts.Parameter(
        name=("--fork-repo",),
        help="Fork repo in OWNER/REPO format. Required unless PLEXOS2DUCKDB_FORK_REPO is set.",
    ),
]
UpstreamRepoOption = typing.Annotated[
    str | None,
    cyclopts.Parameter(
        name=("--upstream-repo",),
        help="Upstream repo in OWNER/REPO format.",
    ),
]
BranchOption = typing.Annotated[
    str | None,
    cyclopts.Parameter(
        name=("--branch",),
        help="Branch to sync from upstream and target when creating releases.",
    ),
]
WorkflowOption = typing.Annotated[
    str | None,
    cyclopts.Parameter(
        name=("--workflow",),
        help="Workflow file name to dispatch on the fork.",
    ),
]
WorkflowRefOption = typing.Annotated[
    str | None,
    cyclopts.Parameter(
        name=("--workflow-ref",),
        help="Branch or tag containing the workflow file. Defaults to --branch.",
    ),
]
DownloadDirOption = typing.Annotated[
    str | None,
    cyclopts.Parameter(
        name=("--download-dir",),
        help=(
            "Directory or template. "
            "Defaults to dist/releases/{tag} or $PLEXOS2DUCKDB_RELEASE_DOWNLOAD_DIR."
        ),
    ),
]
TagArgument = typing.Annotated[
    str,
    cyclopts.Parameter(
        help="Release tag to build and mirror, for example v0.1.0-beta.8.",
    ),
]
AllowExistingForkAssetsOption = typing.Annotated[
    bool,
    cyclopts.Parameter(
        name=("--allow-existing-fork-assets",),
        help="Reuse an existing fork release with assets instead of dispatching the workflow again.",
    ),
]
ForceSyncOption = typing.Annotated[
    bool,
    cyclopts.Parameter(
        name=("--force-sync",),
        help="Allow gh repo sync to hard-reset the fork branch to upstream.",
    ),
]
SkipSyncOption = typing.Annotated[
    bool,
    cyclopts.Parameter(
        name=("--skip-sync",),
        help="Skip syncing the fork branch before dispatching the workflow.",
    ),
]
ClobberOption = typing.Annotated[
    bool,
    cyclopts.Parameter(
        name=("--clobber",),
        help="Overwrite matching assets instead of skipping existing ones.",
    ),
]
ReleaseJson = dict[str, typing.Any]


@dataclasses.dataclass(frozen=True)
class ReleaseArguments:
    fork_repo: str | None
    upstream_repo: str
    branch: str
    workflow: str
    workflow_ref: str | None
    tag: str | None = None
    download_dir: str | None = None
    allow_existing_fork_assets: bool = False
    force_sync: bool = False
    skip_sync: bool = False
    clobber: bool = False


class CommandError(RuntimeError):
    """Raised when a required external command fails."""


def show_message(message: str) -> None:
    stdout_console.print(message, markup=False)


def show_command(cmd: list[str]) -> None:
    show_message(f"+ {' '.join(shlex.quote(part) for part in cmd)}")


def main() -> int:
    try:
        app()
    except CommandError as exc:
        stderr_console.print(f"[red]Error:[/red] {exc}")
        return 1
    except Exception as exc:
        stderr_console.print(
            f"[red]Unexpected error:[/red] {exc.__class__.__name__}: {exc}"
        )
        return 1

    return 0


def resolve_fork_repo(value: str | None) -> str | None:
    return value or os.environ.get("PLEXOS2DUCKDB_FORK_REPO")


def resolve_upstream_repo(value: str | None) -> str:
    return (
        value or os.environ.get("PLEXOS2DUCKDB_UPSTREAM_REPO") or infer_upstream_repo()
    )


def resolve_branch(value: str | None) -> str:
    return (
        value
        or os.environ.get("PLEXOS2DUCKDB_RELEASE_BRANCH")
        or infer_default_branch()
    )


def resolve_workflow(value: str | None) -> str:
    return value or os.environ.get("PLEXOS2DUCKDB_RELEASE_WORKFLOW") or DEFAULT_WORKFLOW


def resolve_workflow_ref(value: str | None) -> str | None:
    return value or os.environ.get("PLEXOS2DUCKDB_RELEASE_WORKFLOW_REF")


def build_release_arguments(
    *,
    fork_repo: str | None,
    upstream_repo: str | None,
    branch: str | None,
    workflow: str | None = None,
    workflow_ref: str | None = None,
    tag: str | None = None,
    download_dir: str | None = None,
    allow_existing_fork_assets: bool = False,
    force_sync: bool = False,
    skip_sync: bool = False,
    clobber: bool = False,
) -> ReleaseArguments:
    return ReleaseArguments(
        fork_repo=resolve_fork_repo(fork_repo),
        upstream_repo=resolve_upstream_repo(upstream_repo),
        branch=resolve_branch(branch),
        workflow=resolve_workflow(workflow),
        workflow_ref=resolve_workflow_ref(workflow_ref),
        tag=tag,
        download_dir=download_dir,
        allow_existing_fork_assets=allow_existing_fork_assets,
        force_sync=force_sync,
        skip_sync=skip_sync,
        clobber=clobber,
    )


def require_tag(args: ReleaseArguments) -> str:
    if args.tag:
        return args.tag
    raise CommandError("a release tag is required for this command")


@app.command(name="sync-fork", help="Sync the fork branch from upstream.")
def sync_fork_command(
    fork_repo: ForkRepoOption = None,
    upstream_repo: UpstreamRepoOption = None,
    branch: BranchOption = None,
    force_sync: ForceSyncOption = False,
) -> None:
    cmd_sync_fork(
        build_release_arguments(
            fork_repo=fork_repo,
            upstream_repo=upstream_repo,
            branch=branch,
            force_sync=force_sync,
        )
    )


@app.command(
    name="build-on-fork",
    help="Create or reuse the fork release, dispatch the workflow, and wait for assets.",
)
def build_on_fork_command(
    tag: TagArgument,
    fork_repo: ForkRepoOption = None,
    upstream_repo: UpstreamRepoOption = None,
    branch: BranchOption = None,
    workflow: WorkflowOption = None,
    workflow_ref: WorkflowRefOption = None,
    allow_existing_fork_assets: AllowExistingForkAssetsOption = False,
    force_sync: ForceSyncOption = False,
    skip_sync: SkipSyncOption = False,
) -> None:
    cmd_build_on_fork(
        build_release_arguments(
            tag=tag,
            fork_repo=fork_repo,
            upstream_repo=upstream_repo,
            branch=branch,
            workflow=workflow,
            workflow_ref=workflow_ref,
            allow_existing_fork_assets=allow_existing_fork_assets,
            force_sync=force_sync,
            skip_sync=skip_sync,
        )
    )


@app.command(
    name="download-fork-assets",
    help="Download release assets from the fork release into the local dist directory.",
)
def download_fork_assets_command(
    tag: TagArgument,
    fork_repo: ForkRepoOption = None,
    upstream_repo: UpstreamRepoOption = None,
    branch: BranchOption = None,
    workflow: WorkflowOption = None,
    workflow_ref: WorkflowRefOption = None,
    download_dir: DownloadDirOption = None,
    clobber: ClobberOption = False,
) -> None:
    cmd_download_fork_assets(
        build_release_arguments(
            tag=tag,
            fork_repo=fork_repo,
            upstream_repo=upstream_repo,
            branch=branch,
            workflow=workflow,
            workflow_ref=workflow_ref,
            download_dir=download_dir,
            clobber=clobber,
        )
    )


@app.command(
    name="upload-upstream-assets",
    help="Create the upstream release if needed and upload downloaded assets to it.",
)
def upload_upstream_assets_command(
    tag: TagArgument,
    fork_repo: ForkRepoOption = None,
    upstream_repo: UpstreamRepoOption = None,
    branch: BranchOption = None,
    workflow: WorkflowOption = None,
    workflow_ref: WorkflowRefOption = None,
    download_dir: DownloadDirOption = None,
    clobber: ClobberOption = False,
) -> None:
    cmd_upload_upstream_assets(
        build_release_arguments(
            tag=tag,
            fork_repo=fork_repo,
            upstream_repo=upstream_repo,
            branch=branch,
            workflow=workflow,
            workflow_ref=workflow_ref,
            download_dir=download_dir,
            clobber=clobber,
        )
    )


@app.command(
    name="mirror",
    help="Sync the fork, build release assets on it, download them, and upload them upstream.",
)
def mirror_command(
    tag: TagArgument,
    fork_repo: ForkRepoOption = None,
    upstream_repo: UpstreamRepoOption = None,
    branch: BranchOption = None,
    workflow: WorkflowOption = None,
    workflow_ref: WorkflowRefOption = None,
    download_dir: DownloadDirOption = None,
    allow_existing_fork_assets: AllowExistingForkAssetsOption = False,
    force_sync: ForceSyncOption = False,
    clobber: ClobberOption = False,
) -> None:
    cmd_mirror(
        build_release_arguments(
            tag=tag,
            fork_repo=fork_repo,
            upstream_repo=upstream_repo,
            branch=branch,
            workflow=workflow,
            workflow_ref=workflow_ref,
            download_dir=download_dir,
            allow_existing_fork_assets=allow_existing_fork_assets,
            force_sync=force_sync,
            clobber=clobber,
        )
    )


def cmd_sync_fork(args: ReleaseArguments) -> None:
    ensure_gh_authentication()
    fork_repo = validate_fork_repo(args.fork_repo)
    sync_fork_branch(
        fork_repo=fork_repo,
        upstream_repo=args.upstream_repo,
        branch=args.branch,
        force_sync=args.force_sync,
    )


def cmd_build_on_fork(args: ReleaseArguments) -> None:
    ensure_gh_authentication()
    validate_fork_repo(args.fork_repo)
    build_on_fork(args, sync_first=not args.skip_sync)


def cmd_download_fork_assets(args: ReleaseArguments) -> None:
    ensure_gh_authentication()
    validate_fork_repo(args.fork_repo)
    download_fork_assets(args)


def cmd_upload_upstream_assets(args: ReleaseArguments) -> None:
    ensure_gh_authentication()
    validate_fork_repo(args.fork_repo)
    upload_upstream_assets(args)


def cmd_mirror(args: ReleaseArguments) -> None:
    ensure_gh_authentication()
    validate_fork_repo(args.fork_repo)
    build_on_fork(args, sync_first=True)
    download_fork_assets(args)
    upload_upstream_assets(args)


def validate_fork_repo(fork_repo: str | None) -> str:
    if fork_repo:
        return fork_repo
    raise CommandError(
        "the fork repo is required; pass --fork-repo OWNER/REPO or set "
        "PLEXOS2DUCKDB_FORK_REPO"
    )


def infer_upstream_repo() -> str:
    origin_url = run_command(
        ["git", "remote", "get-url", "origin"],
        capture=True,
        check=False,
        announce=False,
    )
    if origin_url.returncode != 0:
        return DEFAULT_UPSTREAM_REPO

    origin_value = (origin_url.stdout or "").strip()
    match = re.search(
        r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/.]+?)(?:\.git)?$",
        origin_value,
    )
    if match:
        return f"{match.group('owner')}/{match.group('repo')}"
    return DEFAULT_UPSTREAM_REPO


def get_origin_url() -> str | None:
    result = run_command(
        ["git", "remote", "get-url", "origin"],
        capture=True,
        check=False,
        announce=False,
    )
    if result.returncode != 0:
        return None

    origin_value = (result.stdout or "").strip()
    if not origin_value:
        return None
    return origin_value


def infer_repo_git_url(*, repo: str, fallback_url: str) -> str:
    if "://" not in fallback_url:
        suffix = ".git" if fallback_url.endswith(".git") else ""
        user_host, _, _ = fallback_url.partition(":")
        return f"{user_host}:{repo}{suffix}"

    parsed = urllib.parse.urlparse(fallback_url)
    if parsed.scheme and parsed.netloc:
        suffix = ".git" if parsed.path.endswith(".git") else ""
        return f"{parsed.scheme}://{parsed.netloc}/{repo}{suffix}"

    return f"git@github.com:{repo}.git"


def infer_default_branch() -> str:
    command = run_command(
        ["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
        capture=True,
        check=False,
        announce=False,
    )
    if command.returncode == 0:
        branch_ref = command.stdout.strip()
        if branch_ref:
            return branch_ref.rsplit("/", maxsplit=1)[-1]
    return "main"


def ensure_gh_authentication() -> None:
    result = run_command(["gh", "auth", "status"], capture=True, check=False)
    if result.returncode == 0:
        return
    raise CommandError(
        "gh authentication is required. Run `gh auth login -h github.com` or "
        "export a valid GH_TOKEN/GITHUB_TOKEN before running release tasks.\n"
        f"{result.stderr.strip() or result.stdout.strip()}"
    )


def sync_fork_branch(
    *, fork_repo: str, upstream_repo: str, branch: str, force_sync: bool
) -> None:
    cmd = [
        "gh",
        "repo",
        "sync",
        fork_repo,
        "--source",
        upstream_repo,
        "--branch",
        branch,
    ]
    if force_sync:
        cmd.append("--force")
    result = run_command(cmd, capture=True, check=False)
    combined_output = combine_output(result)
    if result.returncode == 0:
        if combined_output:
            show_message(combined_output)
        return

    if is_merge_upstream_not_supported(combined_output):
        show_message(
            "GitHub's merge-upstream API is unavailable for this repo; "
            "falling back to git fetch/push sync."
        )
        sync_fork_branch_with_git(
            fork_repo=fork_repo,
            upstream_repo=upstream_repo,
            branch=branch,
            force_sync=force_sync,
        )
        return

    raise CommandError(command_failure_message(cmd, result))


def is_merge_upstream_not_supported(output: str) -> bool:
    lowered = output.lower()
    return "merge-upstream" in lowered and "404" in lowered


def sync_fork_branch_with_git(
    *, fork_repo: str, upstream_repo: str, branch: str, force_sync: bool
) -> None:
    source_ref = f"refs/remotes/origin/{branch}"
    run_command(["git", "fetch", "origin", f"refs/heads/{branch}:{source_ref}"])

    origin_url = get_origin_url() or f"git@github.com:{upstream_repo}.git"
    fork_url = infer_repo_git_url(repo=fork_repo, fallback_url=origin_url)

    cmd = ["git", "push"]
    if force_sync:
        cmd.append("--force")
    cmd.extend([fork_url, f"{source_ref}:refs/heads/{branch}"])
    run_command(cmd)
    show_message(
        f"Synchronized {fork_repo}:{branch} from {upstream_repo}:{branch} via git push."
    )


def build_on_fork(args: ReleaseArguments, *, sync_first: bool) -> ReleaseJson:
    fork_repo = validate_fork_repo(args.fork_repo)
    tag = require_tag(args)
    if sync_first:
        sync_fork_branch(
            fork_repo=fork_repo,
            upstream_repo=args.upstream_repo,
            branch=args.branch,
            force_sync=args.force_sync,
        )

    release = ensure_fork_release(
        fork_repo=fork_repo,
        tag=tag,
        branch=args.branch,
    )

    asset_count = len(release.get("assets", []))
    if asset_count:
        if args.allow_existing_fork_assets:
            show_message(
                f"Fork release {tag} already has {asset_count} asset(s); "
                "reusing them without dispatching the workflow."
            )
            return release
        raise CommandError(
            f"fork release {fork_repo}@{tag} already has {asset_count} asset(s). "
            "Use --allow-existing-fork-assets to reuse them, or clear the fork release assets first."
        )

    run_id = dispatch_workflow(
        fork_repo=fork_repo,
        workflow=args.workflow,
        workflow_ref=args.workflow_ref or args.branch,
        tag=tag,
    )
    watch_workflow_run(fork_repo=fork_repo, run_id=run_id)
    return wait_for_release_assets(repo=fork_repo, tag=tag)


def ensure_fork_release(*, fork_repo: str, tag: str, branch: str) -> ReleaseJson:
    release = get_release(repo=fork_repo, tag=tag)
    if release is not None:
        return release

    create_release(
        repo=fork_repo,
        tag=tag,
        target=branch,
        title=tag,
        notes="",
        prerelease=False,
    )
    release = get_release(repo=fork_repo, tag=tag)
    if release is None:
        raise CommandError(
            f"expected fork release {fork_repo}@{tag} to exist after creation"
        )
    return release


def dispatch_workflow(
    *, fork_repo: str, workflow: str, workflow_ref: str, tag: str
) -> int:
    started_at = dt.datetime.now(dt.UTC)
    result = run_command(
        [
            "gh",
            "workflow",
            "run",
            workflow,
            "--repo",
            fork_repo,
            "--ref",
            workflow_ref,
            "--raw-field",
            f"tag={tag}",
        ],
        capture=True,
    )
    combined_output = "\n".join(
        part for part in (result.stdout.strip(), result.stderr.strip()) if part
    )
    if combined_output:
        show_message(combined_output)

    run_id = parse_run_id_from_text(combined_output)
    if run_id is not None:
        return run_id

    return wait_for_recent_workflow_run(
        fork_repo=fork_repo,
        workflow=workflow,
        workflow_ref=workflow_ref,
        started_at=started_at,
    )


def parse_run_id_from_text(text: str) -> int | None:
    match = re.search(r"/actions/runs/(\d+)", text)
    if match:
        return int(match.group(1))
    return None


def wait_for_recent_workflow_run(
    *,
    fork_repo: str,
    workflow: str,
    workflow_ref: str,
    started_at: dt.datetime,
) -> int:
    started_cutoff = started_at - dt.timedelta(seconds=5)
    deadline = time.monotonic() + RUN_LOOKUP_TIMEOUT_SECONDS

    while time.monotonic() < deadline:
        runs = gh_json(
            [
                "run",
                "list",
                "--repo",
                fork_repo,
                "--workflow",
                workflow,
                "--event",
                "workflow_dispatch",
                "--json",
                "databaseId,createdAt,event,headBranch,status,url,workflowName",
                "--limit",
                "20",
            ]
        )
        for run in runs:
            created_at = parse_github_timestamp(run["createdAt"])
            if created_at < started_cutoff:
                continue
            if run.get("headBranch") != workflow_ref:
                continue
            return int(run["databaseId"])
        time.sleep(POLL_INTERVAL_SECONDS)

    raise CommandError(
        f"unable to find the workflow run for {fork_repo} after dispatching {workflow}"
    )


def watch_workflow_run(*, fork_repo: str, run_id: int) -> None:
    run_command(
        [
            "gh",
            "run",
            "watch",
            str(run_id),
            "--repo",
            fork_repo,
            "--compact",
            "--exit-status",
        ]
    )


def wait_for_release_assets(*, repo: str, tag: str) -> ReleaseJson:
    deadline = time.monotonic() + RELEASE_ASSET_TIMEOUT_SECONDS
    last_release: ReleaseJson | None = None

    while time.monotonic() < deadline:
        last_release = get_release(repo=repo, tag=tag)
        if last_release and last_release.get("assets"):
            asset_count = len(last_release["assets"])
            show_message(f"Release {repo}@{tag} has {asset_count} asset(s).")
            return last_release
        time.sleep(POLL_INTERVAL_SECONDS)

    raise CommandError(
        f"release {repo}@{tag} did not expose assets within "
        f"{RELEASE_ASSET_TIMEOUT_SECONDS} seconds"
    )


def download_fork_assets(args: ReleaseArguments) -> None:
    fork_repo = validate_fork_repo(args.fork_repo)
    tag = require_tag(args)
    release = wait_for_release_assets(repo=fork_repo, tag=tag)
    asset_count = len(release["assets"])
    download_dir = resolve_download_dir(tag, args.download_dir)
    download_dir.mkdir(parents=True, exist_ok=True)

    cmd = [
        "gh",
        "release",
        "download",
        tag,
        "--repo",
        fork_repo,
        "--dir",
        str(download_dir),
    ]
    if args.clobber:
        cmd.append("--clobber")
    else:
        cmd.append("--skip-existing")
    run_command(cmd)
    show_message(
        f"Downloaded {asset_count} asset(s) from {fork_repo}@{tag} into {download_dir}."
    )


def upload_upstream_assets(args: ReleaseArguments) -> None:
    fork_repo = validate_fork_repo(args.fork_repo)
    tag = require_tag(args)
    download_dir = resolve_download_dir(tag, args.download_dir)
    asset_files = (
        sorted(path for path in download_dir.iterdir() if path.is_file())
        if download_dir.is_dir()
        else []
    )
    if not asset_files:
        raise CommandError(
            f"no files were found in {download_dir}; run download-fork-assets first"
        )

    fork_release = wait_for_release_assets(repo=fork_repo, tag=tag)
    ensure_upstream_release(
        upstream_repo=args.upstream_repo,
        tag=tag,
        branch=args.branch,
        fork_release=fork_release,
    )

    cmd = [
        "gh",
        "release",
        "upload",
        tag,
        "--repo",
        args.upstream_repo,
    ]
    if args.clobber:
        cmd.append("--clobber")
    cmd.extend(str(path) for path in asset_files)
    run_command(cmd)
    show_message(
        f"Uploaded {len(asset_files)} asset(s) from {download_dir} to "
        f"{args.upstream_repo}@{tag}."
    )


def ensure_upstream_release(
    *,
    upstream_repo: str,
    tag: str,
    branch: str,
    fork_release: ReleaseJson,
) -> ReleaseJson:
    release = get_release(repo=upstream_repo, tag=tag)
    if release is not None:
        return release

    create_release(
        repo=upstream_repo,
        tag=tag,
        target=branch,
        title=fork_release.get("name") or tag,
        notes=fork_release.get("body") or "",
        prerelease=False,
    )
    release = get_release(repo=upstream_repo, tag=tag)
    if release is None:
        raise CommandError(
            f"expected upstream release {upstream_repo}@{tag} to exist after creation"
        )
    return release


def create_release(
    *,
    repo: str,
    tag: str,
    target: str,
    title: str,
    notes: str,
    prerelease: bool,
) -> None:
    cmd = [
        "gh",
        "release",
        "create",
        tag,
        "--repo",
        repo,
        "--target",
        target,
        "--title",
        title,
        "--notes",
        notes,
    ]
    if prerelease:
        cmd.append("--prerelease")
    run_command(cmd)


def resolve_download_dir(tag: str, override: str | None) -> pathlib.Path:
    template = (
        override
        or os.environ.get("PLEXOS2DUCKDB_RELEASE_DOWNLOAD_DIR")
        or DEFAULT_DOWNLOAD_DIR_TEMPLATE
    )
    return pathlib.Path(template.format(tag=tag)).expanduser()


def get_release(*, repo: str, tag: str) -> ReleaseJson | None:
    cmd = [
        "gh",
        "release",
        "view",
        tag,
        "--repo",
        repo,
        "--json",
        "assets,body,isDraft,isPrerelease,name,targetCommitish,url",
    ]
    result = run_command(cmd, capture=True, check=False)
    if result.returncode != 0:
        message = (result.stderr or result.stdout).strip().lower()
        if "release not found" in message or "http 404" in message:
            return None
        raise CommandError(command_failure_message(cmd, result))
    return json.loads(result.stdout)


def gh_json(args: list[str]) -> typing.Any:
    result = run_command(["gh", *args], capture=True)
    return json.loads(result.stdout)


def parse_github_timestamp(value: str) -> dt.datetime:
    return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))


def run_command(
    cmd: list[str],
    *,
    capture: bool = False,
    check: bool = True,
    announce: bool = True,
) -> subprocess.CompletedProcess[str]:
    if announce:
        show_command(cmd)
    result = subprocess.run(
        cmd,
        check=False,
        text=True,
        capture_output=capture,
    )
    if check and result.returncode != 0:
        raise CommandError(command_failure_message(cmd, result))
    return result


def command_failure_message(
    cmd: list[str], result: subprocess.CompletedProcess[str]
) -> str:
    details = combine_output(result)
    command_text = " ".join(shlex.quote(part) for part in cmd)
    if details:
        return f"command failed with exit code {result.returncode}: {command_text}\n{details}"
    return f"command failed with exit code {result.returncode}: {command_text}"


def combine_output(result: subprocess.CompletedProcess[str]) -> str:
    return "\n".join(
        part.strip() for part in (result.stderr, result.stdout) if part and part.strip()
    )


if __name__ == "__main__":
    raise SystemExit(main())
