use std::{env, path::Path};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tempfile::tempdir;

use crate::{
    analysis::{self, ReleaseAnalysis},
    changelog,
    config::{Config, GitHubConfig},
    git::{GitRepository, run_git},
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoRef {
    pub owner: String,
    pub name: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReleasePrPlan {
    pub version: String,
    pub branch: String,
    pub base: String,
    pub title: String,
    pub body: String,
    pub labels: Vec<String>,
    pub release_notes: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReleaseTagPlan {
    pub version: String,
    pub tag_name: String,
    pub title: String,
    pub target: String,
    pub release_notes: String,
    pub label: String,
}

pub fn build_release_pr_plan(config: &Config, analysis: &ReleaseAnalysis) -> Result<ReleasePrPlan> {
    let release_label = release_label(analysis)?;
    let version = analysis
        .next_version
        .as_ref()
        .map(ToString::to_string)
        .unwrap_or_else(|| release_label.clone());
    let title = if config.monorepo.enabled {
        monorepo_pr_title(config, analysis)?
    } else {
        config
            .release
            .pr_title
            .replace("{version}", &format!("v{version}"))
    };
    let branch = format!(
        "{}/{}",
        config.github.release_branch_prefix.trim_end_matches('/'),
        release_branch_suffix(analysis)?
    );
    let date = today_utc();
    let release_notes = changelog::render_release_notes(&release_label, &date, &analysis.changelog);
    let body = format!(
        "## Release summary\n\n{release_notes}\n\n## Maintainer checklist\n- [ ] Review version bump\n- [ ] Review changelog\n- [ ] Merge to cut the release"
    );

    Ok(ReleasePrPlan {
        version,
        branch,
        base: config.release.branch.clone(),
        title,
        body,
        labels: vec![config.github.pending_label.clone()],
        release_notes,
    })
}

pub fn build_release_tag_plan(
    config: &Config,
    repo: &GitRepository,
    analysis: &ReleaseAnalysis,
) -> Result<ReleaseTagPlan> {
    let release_label = release_label(analysis)?;
    let version = analysis
        .next_version
        .as_ref()
        .map(ToString::to_string)
        .unwrap_or_else(|| release_label.clone());
    let tag_name = if config.monorepo.enabled {
        format!(
            "{}{}",
            config.release.tag_prefix,
            sanitize_label(&release_label)
        )
    } else {
        format!("{}{}", config.release.tag_prefix, version)
    };
    Ok(ReleaseTagPlan {
        version,
        title: format!("Release {tag_name}"),
        target: repo.current_branch()?,
        release_notes: changelog::render_release_notes(
            &tag_name,
            &today_utc(),
            &analysis.changelog,
        ),
        tag_name,
        label: config.github.tagged_label.clone(),
    })
}

pub fn detect_repo(repo: &GitRepository, github: &GitHubConfig) -> Result<RepoRef> {
    if let (Some(owner), Some(name)) = (&github.owner, &github.repo) {
        return Ok(RepoRef {
            owner: owner.clone(),
            name: name.clone(),
        });
    }

    let remote = repo
        .remote_url("origin")?
        .context("unable to detect GitHub repo: set [github].owner and [github].repo or add an origin remote")?;
    parse_remote_url(&remote).context("failed to parse GitHub remote")
}

pub fn execute_release_pr(
    repo: &GitRepository,
    config: &Config,
    analysis: &ReleaseAnalysis,
) -> Result<()> {
    let plan = build_release_pr_plan(config, analysis)?;
    let repo_ref = detect_repo(repo, &config.github)?;
    let token = env::var(&config.github.token_env)
        .with_context(|| format!("missing GitHub token in {}", config.github.token_env))?;
    let client = GitHubClient::new(&config.github.api_base, &token, repo_ref)?;

    let clone_dir = tempdir().context("failed to create temporary workspace")?;
    let clone_path = clone_dir.path().join("repo");
    let origin_url = repo
        .remote_url("origin")?
        .context("origin remote is required for release PR flow")?;

    run_git(
        clone_dir.path(),
        vec![
            "clone".into(),
            repo.path().as_os_str().to_owned(),
            clone_path.as_os_str().to_owned(),
        ],
    )?;
    run_git(
        &clone_path,
        ["remote", "set-url", "origin", origin_url.as_str()],
    )?;
    run_git(&clone_path, ["fetch", "origin", plan.base.as_str()])?;
    run_git(
        &clone_path,
        [
            "checkout",
            "-B",
            plan.branch.as_str(),
            format!("origin/{}", plan.base).as_str(),
        ],
    )?;

    analysis::update_version_files(
        &clone_path,
        &config.version_files,
        analysis.next_version.as_ref().unwrap(),
    )?;
    changelog::prepend_release_notes(
        &clone_path.join(&config.release.changelog_file),
        &plan.release_notes,
    )?;

    run_git(&clone_path, ["add", "."])?;
    let diff = run_git(&clone_path, ["status", "--short"])?;
    if diff.trim().is_empty() {
        bail!("release PR would not change any files");
    }

    run_git(
        &clone_path,
        [
            "-c",
            "user.name=pyrls",
            "-c",
            "user.email=pyrls@users.noreply.github.com",
            "commit",
            "-m",
            plan.title.as_str(),
        ],
    )?;
    run_git(
        &clone_path,
        [
            "push",
            "--force-with-lease",
            "origin",
            format!("HEAD:{}", plan.branch).as_str(),
        ],
    )?;

    let pr = match client.find_open_pr(&plan.branch, &plan.base)? {
        Some(existing) => client.update_pr(existing.number, &plan.title, &plan.body)?,
        None => client.create_pr(&plan.title, &plan.branch, &plan.base, &plan.body)?,
    };

    for label in &plan.labels {
        client.ensure_label(label)?;
    }
    client.add_labels(pr.number, &plan.labels)?;

    println!("Release PR ready: #{} {}", pr.number, plan.title);
    println!("Branch: {}", plan.branch);
    Ok(())
}

pub fn execute_monorepo_release_pr(
    repo: &GitRepository,
    config: &Config,
    analysis: &ReleaseAnalysis,
) -> Result<()> {
    let selected = analysis.package_plan.selected_packages();
    if selected.is_empty() {
        bail!("no releasable packages found in monorepo");
    }

    if config.monorepo.release_mode == "unified" {
        execute_monorepo_unified_pr(repo, config, analysis, &selected)?;
    } else {
        for package in &selected {
            let package_analysis = single_package_analysis(analysis, package);
            execute_monorepo_per_package_pr(repo, config, &package_analysis, package)?;
        }
    }

    Ok(())
}

fn execute_monorepo_unified_pr(
    repo: &GitRepository,
    config: &Config,
    analysis: &ReleaseAnalysis,
    selected: &[&analysis::PackageReleaseAnalysis],
) -> Result<()> {
    let plan = build_release_pr_plan(config, analysis)?;
    let repo_ref = detect_repo(repo, &config.github)?;
    let token = env::var(&config.github.token_env)
        .with_context(|| format!("missing GitHub token in {}", config.github.token_env))?;
    let client = GitHubClient::new(&config.github.api_base, &token, repo_ref)?;

    let clone_dir = tempdir().context("failed to create temporary workspace")?;
    let clone_path = clone_dir.path().join("repo");
    let origin_url = repo
        .remote_url("origin")?
        .context("origin remote is required for release PR flow")?;

    run_git(
        clone_dir.path(),
        vec![
            "clone".into(),
            repo.path().as_os_str().to_owned(),
            clone_path.as_os_str().to_owned(),
        ],
    )?;
    run_git(
        &clone_path,
        ["remote", "set-url", "origin", origin_url.as_str()],
    )?;
    run_git(&clone_path, ["fetch", "origin", plan.base.as_str()])?;
    run_git(
        &clone_path,
        [
            "checkout",
            "-B",
            plan.branch.as_str(),
            format!("origin/{}", plan.base).as_str(),
        ],
    )?;

    for package in selected {
        let next_version = package
            .next_version
            .as_ref()
            .context("selected package has no next version")?;
        analysis::update_version_files(&clone_path, &package.version_files, next_version)?;
    }
    changelog::prepend_release_notes(
        &clone_path.join(&config.release.changelog_file),
        &plan.release_notes,
    )?;

    run_git(&clone_path, ["add", "."])?;
    let diff = run_git(&clone_path, ["status", "--short"])?;
    if diff.trim().is_empty() {
        bail!("release PR would not change any files");
    }

    run_git(
        &clone_path,
        [
            "-c",
            "user.name=pyrls",
            "-c",
            "user.email=pyrls@users.noreply.github.com",
            "commit",
            "-m",
            plan.title.as_str(),
        ],
    )?;
    run_git(
        &clone_path,
        [
            "push",
            "--force-with-lease",
            "origin",
            format!("HEAD:{}", plan.branch).as_str(),
        ],
    )?;

    let pr = match client.find_open_pr(&plan.branch, &plan.base)? {
        Some(existing) => client.update_pr(existing.number, &plan.title, &plan.body)?,
        None => client.create_pr(&plan.title, &plan.branch, &plan.base, &plan.body)?,
    };

    for label in &plan.labels {
        client.ensure_label(label)?;
    }
    client.add_labels(pr.number, &plan.labels)?;

    println!("Release PR ready: #{} {}", pr.number, plan.title);
    println!("Branch: {}", plan.branch);
    Ok(())
}

fn execute_monorepo_per_package_pr(
    repo: &GitRepository,
    config: &Config,
    package_analysis: &ReleaseAnalysis,
    package: &analysis::PackageReleaseAnalysis,
) -> Result<()> {
    let plan = build_release_pr_plan(config, package_analysis)?;
    let repo_ref = detect_repo(repo, &config.github)?;
    let token = env::var(&config.github.token_env)
        .with_context(|| format!("missing GitHub token in {}", config.github.token_env))?;
    let client = GitHubClient::new(&config.github.api_base, &token, repo_ref)?;

    let clone_dir = tempdir().context("failed to create temporary workspace")?;
    let clone_path = clone_dir.path().join("repo");
    let origin_url = repo
        .remote_url("origin")?
        .context("origin remote is required for release PR flow")?;

    run_git(
        clone_dir.path(),
        vec![
            "clone".into(),
            repo.path().as_os_str().to_owned(),
            clone_path.as_os_str().to_owned(),
        ],
    )?;
    run_git(
        &clone_path,
        ["remote", "set-url", "origin", origin_url.as_str()],
    )?;
    run_git(&clone_path, ["fetch", "origin", plan.base.as_str()])?;
    run_git(
        &clone_path,
        [
            "checkout",
            "-B",
            plan.branch.as_str(),
            format!("origin/{}", plan.base).as_str(),
        ],
    )?;

    let next_version = package
        .next_version
        .as_ref()
        .context("selected package has no next version")?;
    analysis::update_version_files(&clone_path, &package.version_files, next_version)?;

    let changelog_path = if package.root == "." {
        config.release.changelog_file.clone()
    } else {
        format!("{}/{}", package.root, config.release.changelog_file)
    };
    changelog::prepend_release_notes(&clone_path.join(&changelog_path), &plan.release_notes)?;

    run_git(&clone_path, ["add", "."])?;
    let diff = run_git(&clone_path, ["status", "--short"])?;
    if diff.trim().is_empty() {
        println!(
            "Skipping {} — release PR would not change any files",
            package.name
        );
        return Ok(());
    }

    run_git(
        &clone_path,
        [
            "-c",
            "user.name=pyrls",
            "-c",
            "user.email=pyrls@users.noreply.github.com",
            "commit",
            "-m",
            plan.title.as_str(),
        ],
    )?;
    run_git(
        &clone_path,
        [
            "push",
            "--force-with-lease",
            "origin",
            format!("HEAD:{}", plan.branch).as_str(),
        ],
    )?;

    let pr = match client.find_open_pr(&plan.branch, &plan.base)? {
        Some(existing) => client.update_pr(existing.number, &plan.title, &plan.body)?,
        None => client.create_pr(&plan.title, &plan.branch, &plan.base, &plan.body)?,
    };

    for label in &plan.labels {
        client.ensure_label(label)?;
    }
    client.add_labels(pr.number, &plan.labels)?;

    println!(
        "Release PR ready for {}: #{} {}",
        package.name, pr.number, plan.title
    );
    println!("Branch: {}", plan.branch);
    Ok(())
}

pub fn execute_release_tag(
    repo: &GitRepository,
    config: &Config,
    analysis: &ReleaseAnalysis,
) -> Result<()> {
    let plan = build_release_tag_plan(config, repo, analysis)?;
    let repo_ref = detect_repo(repo, &config.github)?;
    let token = env::var(&config.github.token_env)
        .with_context(|| format!("missing GitHub token in {}", config.github.token_env))?;
    let client = GitHubClient::new(&config.github.api_base, &token, repo_ref)?;

    run_git(
        repo.path(),
        [
            "tag",
            "-a",
            plan.tag_name.as_str(),
            "-m",
            plan.title.as_str(),
        ],
    )?;
    run_git(repo.path(), ["push", "origin", plan.tag_name.as_str()])?;

    match client.find_release_by_tag(&plan.tag_name)? {
        Some(existing) => {
            client.update_release(existing.id, &plan.title, &plan.release_notes)?;
        }
        None => {
            client.create_release(
                &plan.tag_name,
                &plan.title,
                &plan.release_notes,
                &config.release.branch,
            )?;
        }
    }

    println!("Release tagged: {}", plan.tag_name);
    Ok(())
}

pub fn execute_monorepo_release_tag(
    repo: &GitRepository,
    config: &Config,
    analysis: &ReleaseAnalysis,
) -> Result<()> {
    let selected = analysis.package_plan.selected_packages();
    if selected.is_empty() {
        bail!("no releasable packages found in monorepo");
    }

    let repo_ref = detect_repo(repo, &config.github)?;
    let token = env::var(&config.github.token_env)
        .with_context(|| format!("missing GitHub token in {}", config.github.token_env))?;
    let client = GitHubClient::new(&config.github.api_base, &token, repo_ref)?;

    for package in &selected {
        let package_analysis = single_package_analysis(analysis, package);
        let plan = build_release_tag_plan(config, repo, &package_analysis)?;

        run_git(
            repo.path(),
            [
                "tag",
                "-a",
                plan.tag_name.as_str(),
                "-m",
                plan.title.as_str(),
            ],
        )?;
        run_git(repo.path(), ["push", "origin", plan.tag_name.as_str()])?;

        match client.find_release_by_tag(&plan.tag_name)? {
            Some(existing) => {
                client.update_release(existing.id, &plan.title, &plan.release_notes)?;
            }
            None => {
                client.create_release(
                    &plan.tag_name,
                    &plan.title,
                    &plan.release_notes,
                    &config.release.branch,
                )?;
            }
        }

        println!("Release tagged for {}: {}", package.name, plan.tag_name);
    }

    Ok(())
}

fn single_package_analysis(
    analysis: &ReleaseAnalysis,
    package: &analysis::PackageReleaseAnalysis,
) -> ReleaseAnalysis {
    ReleaseAnalysis {
        current_version: package.current_version.clone(),
        next_version: package.next_version.clone(),
        bump: package.bump,
        commits: package.commits.clone(),
        changelog: package.changelog.clone(),
        package_plan: analysis::PackagePlan {
            release_mode: "single".to_string(),
            discovery_source: analysis.package_plan.discovery_source.clone(),
            packages: vec![analysis::PackageReleaseAnalysis {
                name: package.name.clone(),
                root: package.root.clone(),
                current_version: package.current_version.clone(),
                next_version: package.next_version.clone(),
                bump: package.bump,
                changelog: package.changelog.clone(),
                version_files: package.version_files.clone(),
                commits: package.commits.clone(),
                changed_paths: package.changed_paths.clone(),
                selected: true,
                selection_reason: package.selection_reason.clone(),
            }],
        },
    }
}

pub fn print_release_pr_dry_run(
    repo: &GitRepository,
    config: &Config,
    analysis: &ReleaseAnalysis,
) -> Result<()> {
    let repo_ref = detect_repo(repo, &config.github)?;
    let plan = build_release_pr_plan(config, analysis)?;
    if config.monorepo.enabled {
        let selected = selected_package_summaries(analysis);
        println!(
            "Would create or update {} release PR set covering: {}",
            analysis.package_plan.release_mode,
            selected.join(", ")
        );
    }
    println!(
        "Would push release branch `{}` from `{}`",
        plan.branch, plan.base
    );
    println!("Would update version files to {}", plan.version);
    println!("Would prepend {} with:", config.release.changelog_file);
    println!("{}", indent_block(&plan.release_notes, "  "));
    println!(
        "Would create or update PR `{}` in {}/{}",
        plan.title, repo_ref.owner, repo_ref.name
    );
    println!("Would apply labels: {}", plan.labels.join(", "));
    Ok(())
}

pub fn print_release_tag_dry_run(
    repo: &GitRepository,
    config: &Config,
    analysis: &ReleaseAnalysis,
) -> Result<()> {
    let repo_ref = detect_repo(repo, &config.github)?;
    let plan = build_release_tag_plan(config, repo, analysis)?;
    if config.monorepo.enabled {
        println!(
            "Would tag selected package set for {} mode: {}",
            analysis.package_plan.release_mode,
            selected_package_summaries(analysis).join(", ")
        );
    }
    println!(
        "Would create and push tag `{}` to {}/{}",
        plan.tag_name, repo_ref.owner, repo_ref.name
    );
    println!("Would create or update GitHub Release `{}`", plan.title);
    println!("{}", indent_block(&plan.release_notes, "  "));
    Ok(())
}

fn indent_block(value: &str, prefix: &str) -> String {
    value
        .lines()
        .map(|line| format!("{prefix}{line}"))
        .collect::<Vec<_>>()
        .join("\n")
}

fn selected_package_summaries(analysis: &ReleaseAnalysis) -> Vec<String> {
    analysis
        .package_plan
        .selected_packages()
        .into_iter()
        .map(|package| {
            format!(
                "{} {}",
                package.name,
                package
                    .next_version
                    .as_ref()
                    .map(ToString::to_string)
                    .unwrap_or_else(|| "unchanged".to_string())
            )
        })
        .collect()
}

fn release_label(analysis: &ReleaseAnalysis) -> Result<String> {
    if analysis.package_plan.release_mode == "single" {
        return Ok(analysis
            .next_version
            .as_ref()
            .context("no release is pending from the current commit set")?
            .to_string());
    }

    let selected = selected_package_summaries(analysis);
    if selected.is_empty() {
        bail!("no releasable package set is pending from the current commit set");
    }
    Ok(selected.join(", "))
}

fn release_branch_suffix(analysis: &ReleaseAnalysis) -> Result<String> {
    if analysis.package_plan.release_mode == "single" {
        return Ok(format!(
            "v{}",
            analysis
                .next_version
                .as_ref()
                .context("no release is pending from the current commit set")?
        ));
    }

    let selected = analysis.package_plan.selected_packages();
    if selected.is_empty() {
        bail!("no releasable package set is pending from the current commit set");
    }

    if analysis.package_plan.release_mode == "unified" {
        return Ok(format!(
            "monorepo/{}",
            sanitize_label(&selected_package_summaries(analysis).join("-"))
        ));
    }

    Ok(format!("per-package/{}", selected.len()))
}

fn monorepo_pr_title(config: &Config, analysis: &ReleaseAnalysis) -> Result<String> {
    let selected = selected_package_summaries(analysis);
    if selected.is_empty() {
        bail!("no releasable package set is pending from the current commit set");
    }

    if analysis.package_plan.release_mode == "unified" {
        return Ok(format!("chore(release): {}", selected.join(", ")));
    }

    Ok(format!(
        "{} package release set",
        config
            .release
            .pr_title
            .replace("{version}", &format!("{} packages", selected.len()))
    ))
}

fn sanitize_label(value: &str) -> String {
    value
        .chars()
        .map(|ch| match ch {
            'a'..='z' | 'A'..='Z' | '0'..='9' => ch.to_ascii_lowercase(),
            _ => '-',
        })
        .collect::<String>()
        .trim_matches('-')
        .to_string()
}

fn today_utc() -> String {
    run_git(Path::new("."), ["show", "-s", "--format=%cs", "HEAD"])
        .unwrap_or_else(|_| "1970-01-01".to_string())
}

pub fn parse_remote_url(value: &str) -> Option<RepoRef> {
    let trimmed = value.trim().trim_end_matches(".git");
    let cleaned = trimmed
        .strip_prefix("git@github.com:")
        .or_else(|| trimmed.strip_prefix("ssh://git@github.com/"))
        .or_else(|| trimmed.strip_prefix("https://github.com/"))
        .or_else(|| trimmed.strip_prefix("http://github.com/"))?;
    let mut parts = cleaned.split('/');
    let owner = parts.next()?.to_string();
    let name = parts.next()?.to_string();
    Some(RepoRef { owner, name })
}

#[derive(Debug, Deserialize)]
pub struct PullRequest {
    pub number: u64,
}

#[derive(Debug, Deserialize)]
pub struct Release {
    pub id: u64,
}

pub struct GitHubClient {
    api_base: String,
    token: String,
    repo: RepoRef,
}

impl GitHubClient {
    pub fn new(api_base: &str, token: &str, repo: RepoRef) -> Result<Self> {
        Ok(Self {
            api_base: api_base.trim_end_matches('/').to_string(),
            token: token.to_string(),
            repo,
        })
    }

    pub fn find_open_pr(&self, head_branch: &str, base_branch: &str) -> Result<Option<PullRequest>> {
        let url = format!(
            "{}/repos/{}/{}/pulls?state=open&head={}:{}&base={}",
            self.api_base,
            self.repo.owner,
            self.repo.name,
            self.repo.owner,
            head_branch,
            base_branch
        );
        let prs: Vec<PullRequest> = self.get(&url)?;
        Ok(prs.into_iter().next())
    }

    pub fn create_pr(&self, title: &str, head: &str, base: &str, body: &str) -> Result<PullRequest> {
        self.post(
            &format!(
                "{}/repos/{}/{}/pulls",
                self.api_base, self.repo.owner, self.repo.name
            ),
            &json!({ "title": title, "head": head, "base": base, "body": body }),
        )
    }

    pub fn update_pr(&self, number: u64, title: &str, body: &str) -> Result<PullRequest> {
        self.patch(
            &format!(
                "{}/repos/{}/{}/pulls/{}",
                self.api_base, self.repo.owner, self.repo.name, number
            ),
            &json!({ "title": title, "body": body }),
        )
    }

    pub fn ensure_label(&self, name: &str) -> Result<()> {
        let url = format!(
            "{}/repos/{}/{}/labels/{}",
            self.api_base, self.repo.owner, self.repo.name, name
        );
        if self.get_raw(&url).is_ok() {
            return Ok(());
        }

        let _: serde_json::Value = self.post(
            &format!(
                "{}/repos/{}/{}/labels",
                self.api_base, self.repo.owner, self.repo.name
            ),
            &json!({ "name": name, "color": "ededed", "description": "Managed by pyrls" }),
        )?;
        Ok(())
    }

    pub fn add_labels(&self, number: u64, labels: &[String]) -> Result<()> {
        let _: serde_json::Value = self.post(
            &format!(
                "{}/repos/{}/{}/issues/{}/labels",
                self.api_base, self.repo.owner, self.repo.name, number
            ),
            &json!({ "labels": labels }),
        )?;
        Ok(())
    }

    pub fn find_release_by_tag(&self, tag: &str) -> Result<Option<Release>> {
        let url = format!(
            "{}/repos/{}/{}/releases/tags/{}",
            self.api_base, self.repo.owner, self.repo.name, tag
        );
        match self.get_raw(&url) {
            Ok(response) => Ok(Some(parse_json(response)?)),
            Err(_) => Ok(None),
        }
    }

    pub fn create_release(&self, tag: &str, name: &str, body: &str, target: &str) -> Result<Release> {
        self.post(
            &format!(
                "{}/repos/{}/{}/releases",
                self.api_base, self.repo.owner, self.repo.name
            ),
            &json!({
                "tag_name": tag,
                "target_commitish": target,
                "name": name,
                "body": body,
                "generate_release_notes": false
            }),
        )
    }

    pub fn update_release(&self, release_id: u64, name: &str, body: &str) -> Result<Release> {
        self.patch(
            &format!(
                "{}/repos/{}/{}/releases/{}",
                self.api_base, self.repo.owner, self.repo.name, release_id
            ),
            &json!({ "name": name, "body": body }),
        )
    }

    fn get<T: for<'de> Deserialize<'de>>(&self, url: &str) -> Result<T> {
        parse_json(self.get_raw(url)?)
    }

    fn get_raw(&self, url: &str) -> Result<String> {
        let response = ureq::get(url)
            .set("Authorization", &format!("Bearer {}", self.token))
            .set("Accept", "application/vnd.github+json")
            .set("User-Agent", "pyrls")
            .call();
        read_response(response)
    }

    fn post<T: for<'de> Deserialize<'de>, B: Serialize>(&self, url: &str, body: &B) -> Result<T> {
        let response = ureq::post(url)
            .set("Authorization", &format!("Bearer {}", self.token))
            .set("Accept", "application/vnd.github+json")
            .set("User-Agent", "pyrls")
            .send_json(body);
        parse_json(read_response(response)?)
    }

    fn patch<T: for<'de> Deserialize<'de>, B: Serialize>(&self, url: &str, body: &B) -> Result<T> {
        let response = ureq::request("PATCH", url)
            .set("Authorization", &format!("Bearer {}", self.token))
            .set("Accept", "application/vnd.github+json")
            .set("User-Agent", "pyrls")
            .send_json(body);
        parse_json(read_response(response)?)
    }
}

fn read_response(response: std::result::Result<ureq::Response, ureq::Error>) -> Result<String> {
    match response {
        Ok(response) => response.into_string().map_err(Into::into),
        Err(ureq::Error::Status(status, response)) => {
            let body = response.into_string().unwrap_or_default();
            bail!("GitHub API request failed with status {status}: {body}")
        }
        Err(error) => Err(error.into()),
    }
}

fn parse_json<T: for<'de> Deserialize<'de>>(body: String) -> Result<T> {
    serde_json::from_str(&body).with_context(|| format!("failed to parse GitHub response: {body}"))
}

#[cfg(test)]
mod tests {
    use super::{build_release_pr_plan, build_release_tag_plan, parse_remote_url};
    use crate::{
        analysis::{PackagePlan, PackageReleaseAnalysis, ReleaseAnalysis},
        changelog::PendingChangelog,
        config::Config,
        git::GitRepository,
        version::{BumpLevel, Version},
    };
    use std::{collections::BTreeMap, fs};
    use tempfile::tempdir;

    #[test]
    fn parses_common_github_remote_formats() {
        assert_eq!(
            parse_remote_url("git@github.com:acme/pyrls.git"),
            Some(super::RepoRef {
                owner: "acme".into(),
                name: "pyrls".into()
            })
        );
        assert_eq!(
            parse_remote_url("https://github.com/acme/pyrls.git"),
            Some(super::RepoRef {
                owner: "acme".into(),
                name: "pyrls".into()
            })
        );
    }

    #[test]
    fn builds_release_pr_plan() {
        let config: Config = toml::from_str(
            r#"
            [[version_files]]
            path = "pyproject.toml"
            key = "project.version"
            "#,
        )
        .expect("config");
        let analysis = sample_analysis();

        let plan = build_release_pr_plan(&config, &analysis).expect("plan");
        assert_eq!(plan.branch, "pyrls/release/v1.2.0");
        assert!(plan.title.contains("v1.2.0"));
        assert!(plan.body.contains("Release summary"));
    }

    #[test]
    fn builds_release_tag_plan() {
        let dir = tempdir().expect("tempdir");
        fs::write(
            dir.path().join("pyproject.toml"),
            "[project]\nname='demo'\nversion='1.1.0'\n",
        )
        .expect("write");
        run(dir.path(), &["git", "init", "-b", "main"]);
        run(dir.path(), &["git", "config", "user.name", "Pyrls Test"]);
        run(
            dir.path(),
            &["git", "config", "user.email", "pyrls@example.com"],
        );
        run(dir.path(), &["git", "add", "."]);
        run(dir.path(), &["git", "commit", "-m", "feat: initial"]);

        let repo = GitRepository::discover(dir.path()).expect("repo");
        let config: Config = toml::from_str(
            r#"
            [release]
            tag_prefix = "v"

            [[version_files]]
            path = "pyproject.toml"
            key = "project.version"
            "#,
        )
        .expect("config");
        let analysis = sample_analysis();
        let plan = build_release_tag_plan(&config, &repo, &analysis).expect("plan");
        assert_eq!(plan.tag_name, "v1.2.0");
    }

    #[test]
    fn builds_monorepo_release_pr_plan() {
        let config: Config = toml::from_str(
            r#"
            [monorepo]
            enabled = true
            release_mode = "unified"
            packages = ["packages/core", "packages/cli"]
            "#,
        )
        .expect("config");
        let analysis = monorepo_analysis();

        let plan = build_release_pr_plan(&config, &analysis).expect("plan");
        assert!(plan.branch.contains("monorepo"), "{}", plan.branch);
        assert!(plan.title.contains("core 1.2.0"), "{}", plan.title);
        assert!(plan.title.contains("cli 0.5.1"), "{}", plan.title);
    }

    fn sample_analysis() -> ReleaseAnalysis {
        ReleaseAnalysis {
            current_version: Version {
                major: 1,
                minor: 1,
                patch: 0,
                suffix: None,
            },
            next_version: Some(Version {
                major: 1,
                minor: 2,
                patch: 0,
                suffix: None,
            }),
            bump: BumpLevel::Minor,
            commits: Vec::new(),
            changelog: PendingChangelog {
                sections: BTreeMap::from([("Added".to_string(), vec!["search".to_string()])]),
            },
            package_plan: PackagePlan {
                release_mode: "single".to_string(),
                discovery_source: "top-level [[version_files]] configuration".to_string(),
                packages: vec![PackageReleaseAnalysis {
                    name: "demo".to_string(),
                    root: ".".to_string(),
                    current_version: Version {
                        major: 1,
                        minor: 1,
                        patch: 0,
                        suffix: None,
                    },
                    next_version: Some(Version {
                        major: 1,
                        minor: 2,
                        patch: 0,
                        suffix: None,
                    }),
                    bump: BumpLevel::Minor,
                    changelog: PendingChangelog {
                        sections: BTreeMap::from([(
                            "Added".to_string(),
                            vec!["search".to_string()],
                        )]),
                    },
                    version_files: Vec::new(),
                    commits: Vec::new(),
                    changed_paths: Vec::new(),
                    selected: true,
                    selection_reason: "single-package repository".to_string(),
                }],
            },
        }
    }

    fn monorepo_analysis() -> ReleaseAnalysis {
        ReleaseAnalysis {
            current_version: Version {
                major: 1,
                minor: 1,
                patch: 0,
                suffix: None,
            },
            next_version: None,
            bump: BumpLevel::Minor,
            commits: Vec::new(),
            changelog: PendingChangelog {
                sections: BTreeMap::from([(
                    "Added".to_string(),
                    vec!["core: search".to_string(), "cli: status".to_string()],
                )]),
            },
            package_plan: PackagePlan {
                release_mode: "unified".to_string(),
                discovery_source: "auto-discovered package pyproject.toml files".to_string(),
                packages: vec![
                    PackageReleaseAnalysis {
                        name: "core".to_string(),
                        root: "packages/core".to_string(),
                        current_version: Version {
                            major: 1,
                            minor: 1,
                            patch: 0,
                            suffix: None,
                        },
                        next_version: Some(Version {
                            major: 1,
                            minor: 2,
                            patch: 0,
                            suffix: None,
                        }),
                        bump: BumpLevel::Minor,
                        changelog: PendingChangelog {
                            sections: BTreeMap::new(),
                        },
                        version_files: Vec::new(),
                        commits: Vec::new(),
                        changed_paths: vec!["packages/core/src/core.py".to_string()],
                        selected: true,
                        selection_reason: "changed".to_string(),
                    },
                    PackageReleaseAnalysis {
                        name: "cli".to_string(),
                        root: "packages/cli".to_string(),
                        current_version: Version {
                            major: 0,
                            minor: 5,
                            patch: 0,
                            suffix: None,
                        },
                        next_version: Some(Version {
                            major: 0,
                            minor: 5,
                            patch: 1,
                            suffix: None,
                        }),
                        bump: BumpLevel::Patch,
                        changelog: PendingChangelog {
                            sections: BTreeMap::new(),
                        },
                        version_files: Vec::new(),
                        commits: Vec::new(),
                        changed_paths: vec!["packages/cli/src/cli.py".to_string()],
                        selected: true,
                        selection_reason: "changed".to_string(),
                    },
                ],
            },
        }
    }

    fn run(repo_path: &std::path::Path, args: &[&str]) {
        let status = std::process::Command::new(args[0])
            .args(&args[1..])
            .current_dir(repo_path)
            .status()
            .expect("command should run");
        assert!(status.success(), "command failed: {args:?}");
    }
}
