use handlebars::Handlebars;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::ser::PrettyFormatter;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, env, fmt, process::Stdio};
use tokio::{fs, io::AsyncWriteExt, process::Command};
use toml_edit::DocumentMut;

const MANIFEST: &str = "https://launchermeta.mojang.com/mc/game/version_manifest.json";
const DOWNLOADS: &str = "https://net-secondary.web.minecraft-services.net/api/v1.0/download/links";
const BEDROCK_LINUX_DOWNLOAD_TYPE: &str = "serverBedrockLinux";
const BEDROCK_URL_PREFIX: &str =
    "https://www.minecraft.net/bedrockdedicatedserver/bin-linux/bedrock-server-";
const BEDROCK_URL_SUFFIX: &str = ".zip";
const MAX_BEDROCK_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
const FABRIC_LOADERS: &str = "https://meta.fabricmc.net/v2/versions/loader";
const FABRIC_INSTALLERS: &str = "https://meta.fabricmc.net/v2/versions/installer";
const GEYSER_LATEST_BUILD: &str =
    "https://download.geysermc.org/v2/projects/geyser/versions/latest/builds/latest";
const MCXBOXBROADCAST_LATEST_RELEASE: &str =
    "https://api.github.com/repos/MCXboxBroadcast/Broadcaster/releases/latest";
const PROJECT_ID: &str = "54750221";
const PROJECT_SLUG: &str = "minecraft-server-rs";
const UPDATED_FILES: &[&str] = &[
    ".tito/packages/minecraft-server",
    "Cargo.lock",
    "README.md",
    "deploy/versions.json",
    "minecraft-server.spec",
    "minecraft-server/Cargo.toml",
    "upstream-versions.json",
];

#[derive(Clone, Copy, Eq, PartialEq)]
enum Operation {
    Update,
    Release,
}

#[derive(Deserialize)]
struct Manifest {
    latest: Latest,
    versions: Vec<MojangVersion>,
}
#[derive(Deserialize)]
struct Latest {
    release: String,
}
#[derive(Deserialize)]
struct MojangVersion {
    id: String,
    url: String,
}
#[derive(Deserialize)]
struct MojangVersionManifest {
    downloads: MojangDownloads,
}
#[derive(Deserialize)]
struct MojangDownloads {
    server: Download,
}
#[derive(Deserialize)]
struct Download {
    url: String,
}
#[derive(Deserialize)]
struct BedrockDownloadResponse {
    result: BedrockDownloadResult,
}
#[derive(Deserialize)]
struct BedrockDownloadResult {
    links: Vec<BedrockDownloadLink>,
}
#[derive(Deserialize)]
struct BedrockDownloadLink {
    #[serde(rename = "downloadType")]
    download_type: String,
    #[serde(rename = "downloadUrl")]
    download_url: String,
}
#[derive(Deserialize)]
struct FabricVersion {
    version: String,
    stable: bool,
}
#[derive(Deserialize)]
struct GeyserBuild {
    version: String,
    build: u64,
    downloads: GeyserDownloads,
}
#[derive(Deserialize)]
struct GeyserDownloads {
    standalone: GeyserDownload,
}
#[derive(Deserialize)]
struct GeyserDownload {
    sha256: String,
}
#[derive(Deserialize)]
struct GithubRelease {
    tag_name: String,
    assets: Vec<GithubAsset>,
}
#[derive(Deserialize)]
struct GithubAsset {
    name: String,
    browser_download_url: String,
    digest: String,
}
#[derive(Serialize, Deserialize)]
struct Config {
    #[serde(rename = "minecraft-server-rs")]
    minecraft_server_rs: String,
}

#[derive(Clone, Serialize, Deserialize, Eq, PartialEq)]
struct UpstreamVersions {
    bedrock: LockedArtifact,
    minecraft: LockedArtifact,
    fabric: FabricArtifact,
    geyser: LockedArtifact,
    mcxboxbroadcast: LockedArtifact,
}

#[derive(Clone, Serialize, Deserialize, Eq, PartialEq)]
struct LockedArtifact {
    version: String,
    url: String,
    sha256: String,
}

#[derive(Clone, Serialize, Deserialize, Eq, PartialEq)]
struct FabricArtifact {
    version: String,
    url: String,
    sha256: String,
    game_version: String,
    loader_version: String,
    installer_version: String,
}

#[tokio::main]
// The release workflow is intentionally linear so its network and repository
// mutations have an explicit execution order.
#[expect(clippy::too_many_lines)]
async fn main() {
    let operation = operation_from_arguments();

    let versions_file_path = "deploy/versions.json";
    let versions_file_content = fs::read_to_string(versions_file_path).await.unwrap();
    let mut config: Config = serde_json::from_str(&versions_file_content).unwrap();

    let manifest: Manifest = reqwest::get(MANIFEST).await.unwrap().json().await.unwrap();

    let bedrock_client = Client::builder()
        .user_agent(format!("minecraft-server/{}", env!("CARGO_PKG_VERSION")))
        .http1_only()
        .connect_timeout(std::time::Duration::from_secs(15))
        .timeout(std::time::Duration::from_mins(5))
        .build()
        .unwrap();
    let bedrock_downloads: BedrockDownloadResponse = bedrock_client
        .get(DOWNLOADS)
        .send()
        .await
        .unwrap()
        .error_for_status()
        .unwrap()
        .json()
        .await
        .unwrap();
    let new_bedrock_url = bedrock_downloads
        .result
        .links
        .into_iter()
        .find(|link| link.download_type == BEDROCK_LINUX_DOWNLOAD_TYPE)
        .expect("Minecraft download metadata should contain a Bedrock Linux archive")
        .download_url;
    let new_bedrock_version = bedrock_version_from_url(&new_bedrock_url);
    let fabric_loaders: Vec<FabricVersion> = reqwest::get(FABRIC_LOADERS)
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    let fabric_installers: Vec<FabricVersion> = reqwest::get(FABRIC_INSTALLERS)
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    let geyser_build: GeyserBuild = reqwest::get(GEYSER_LATEST_BUILD)
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    let github_release: GithubRelease = Client::new()
        .get(MCXBOXBROADCAST_LATEST_RELEASE)
        .header("User-Agent", "minecraft-server-rs-update-checker")
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();

    let new_fabric_loader_version = latest_fabric_version(&fabric_loaders);
    let new_fabric_installer_version = latest_fabric_version(&fabric_installers);

    let upstream_versions_path = "upstream-versions.json";
    let upstream_versions_content = fs::read_to_string(upstream_versions_path).await.unwrap();
    let old_upstream_versions: UpstreamVersions =
        serde_json::from_str(&upstream_versions_content).unwrap();

    let new_java_version = Version::new(&manifest.latest.release);

    let new_bedrock_lock = LockedArtifact {
        version: new_bedrock_version.to_string(),
        url: new_bedrock_url.clone(),
        sha256: if old_upstream_versions.bedrock.version == new_bedrock_version.0
            && old_upstream_versions.bedrock.url == new_bedrock_url
        {
            old_upstream_versions.bedrock.sha256.clone()
        } else {
            download_sha256(&bedrock_client, &new_bedrock_url, MAX_BEDROCK_ARCHIVE_BYTES).await
        },
    };

    let new_minecraft_lock = if old_upstream_versions.minecraft.version == new_java_version.0 {
        old_upstream_versions.minecraft.clone()
    } else {
        let version_manifest_url = &manifest
            .versions
            .iter()
            .find(|version| version.id == new_java_version.0)
            .expect("latest Minecraft release should have a version manifest")
            .url;
        let version_manifest: MojangVersionManifest = reqwest::get(version_manifest_url)
            .await
            .unwrap()
            .json()
            .await
            .unwrap();
        let server_bytes = reqwest::get(&version_manifest.downloads.server.url)
            .await
            .unwrap()
            .bytes()
            .await
            .unwrap();
        LockedArtifact {
            version: new_java_version.to_string(),
            url: version_manifest.downloads.server.url,
            sha256: format!("{:x}", Sha256::digest(&server_bytes)),
        }
    };

    let new_fabric_lock_version =
        format!("{new_java_version}-{new_fabric_loader_version}-{new_fabric_installer_version}");
    let new_fabric_lock_url = format!(
        "https://meta.fabricmc.net/v2/versions/loader/{new_java_version}/{new_fabric_loader_version}/{new_fabric_installer_version}/server/jar"
    );
    let new_fabric_lock = if old_upstream_versions.fabric.version == new_fabric_lock_version {
        old_upstream_versions.fabric.clone()
    } else {
        let server_bytes = reqwest::get(&new_fabric_lock_url)
            .await
            .unwrap()
            .bytes()
            .await
            .unwrap();
        FabricArtifact {
            version: new_fabric_lock_version,
            url: new_fabric_lock_url,
            sha256: format!("{:x}", Sha256::digest(&server_bytes)),
            game_version: new_java_version.to_string(),
            loader_version: new_fabric_loader_version.to_string(),
            installer_version: new_fabric_installer_version.to_string(),
        }
    };

    let new_geyser_lock = LockedArtifact {
        version: format!("{}-{}", geyser_build.version, geyser_build.build),
        url: format!(
            "https://download.geysermc.org/v2/projects/geyser/versions/{}/builds/{}/downloads/standalone",
            geyser_build.version, geyser_build.build
        ),
        sha256: geyser_build.downloads.standalone.sha256,
    };
    let broadcaster_asset = github_release
        .assets
        .into_iter()
        .find(|asset| asset.name == "MCXboxBroadcastStandalone.jar")
        .expect("latest MCXboxBroadcast release should have a standalone jar");
    let new_mcxboxbroadcast_lock = LockedArtifact {
        version: github_release.tag_name,
        url: broadcaster_asset.browser_download_url,
        sha256: broadcaster_asset
            .digest
            .strip_prefix("sha256:")
            .expect("MCXboxBroadcast digest should be SHA-256")
            .to_string(),
    };
    let new_upstream_versions = UpstreamVersions {
        bedrock: new_bedrock_lock,
        minecraft: new_minecraft_lock,
        fabric: new_fabric_lock,
        geyser: new_geyser_lock,
        mcxboxbroadcast: new_mcxboxbroadcast_lock,
    };

    let exists_new_bedrock_version = old_upstream_versions.bedrock != new_upstream_versions.bedrock;
    let exists_new_java_version =
        old_upstream_versions.minecraft != new_upstream_versions.minecraft;
    let exists_new_fabric_version = old_upstream_versions.fabric != new_upstream_versions.fabric;
    let exists_new_geyser_version = old_upstream_versions.geyser != new_upstream_versions.geyser;
    let exists_new_mcxboxbroadcast_version =
        old_upstream_versions.mcxboxbroadcast != new_upstream_versions.mcxboxbroadcast;

    if !exists_new_java_version
        && !exists_new_bedrock_version
        && !exists_new_fabric_version
        && !exists_new_geyser_version
        && !exists_new_mcxboxbroadcast_version
    {
        println!("No new versions found");
        return;
    }

    let mut version = semver::Version::parse(&config.minecraft_server_rs).unwrap();
    version.patch += 1;
    let tag_name = version.to_string();
    config.minecraft_server_rs = tag_name.clone();

    let mut output = Vec::new();
    let formatter = PrettyFormatter::with_indent(b"    ");
    let mut serializer = serde_json::Serializer::with_formatter(&mut output, formatter);
    config.serialize(&mut serializer).unwrap();

    let mut file = fs::File::create(versions_file_path).await.unwrap();
    file.write_all(&output).await.unwrap();

    write_pretty_json(upstream_versions_path, &new_upstream_versions).await;

    // Create a Handlebars registry
    let mut handlebars = Handlebars::new();

    let sub_tag = changed_components([
        (exists_new_bedrock_version, "Bedrock"),
        (exists_new_fabric_version, "Fabric"),
        (exists_new_java_version, "Java"),
        (exists_new_geyser_version, "Geyser"),
        (exists_new_mcxboxbroadcast_version, "MCXboxBroadcast"),
    ]);

    // it's a little sus to use raw unescaped strings here because we load these version from an external source
    // the risk of Mojang injecting something dangerous is low and likely GitLab will escape things anyway
    let tagline = format!("Version bump for {sub_tag}");
    let bedrock_changes = version_changes(
        exists_new_bedrock_version,
        &old_upstream_versions.bedrock.version,
        &new_upstream_versions.bedrock.version,
    );
    let java_changes = version_changes(
        exists_new_java_version,
        &old_upstream_versions.minecraft.version,
        &new_upstream_versions.minecraft.version,
    );
    let fabric_version = FabricServerVersion::new(
        new_java_version.clone(),
        new_fabric_loader_version.clone(),
        new_fabric_installer_version.clone(),
    );
    let old_fabric_version = FabricServerVersion::new(
        Version::new(&old_upstream_versions.fabric.game_version),
        Version::new(&old_upstream_versions.fabric.loader_version),
        Version::new(&old_upstream_versions.fabric.installer_version),
    );
    let fabric_changes = if exists_new_fabric_version {
        format!("Bump version from `{old_fabric_version}` to `{fabric_version}`")
    } else {
        "None".to_string()
    };
    let geyser_changes = version_changes(
        exists_new_geyser_version,
        &old_upstream_versions.geyser.version,
        &new_upstream_versions.geyser.version,
    );
    let mcxboxbroadcast_changes = version_changes(
        exists_new_mcxboxbroadcast_version,
        &old_upstream_versions.mcxboxbroadcast.version,
        &new_upstream_versions.mcxboxbroadcast.version,
    );

    // Generate release text
    let template_content = fs::read_to_string("deploy/release.md.hbs").await.unwrap();
    handlebars
        .register_template_string("release", template_content)
        .unwrap();
    let mut data: HashMap<&str, String> = HashMap::new();
    data.insert("version", tag_name.clone());
    data.insert("tagline", tagline);
    data.insert(
        "bedrock_version",
        new_upstream_versions.bedrock.version.clone(),
    );
    data.insert("bedrock_changes", bedrock_changes);
    data.insert("fabric_version", fabric_version.to_string());
    data.insert("fabric_changes", fabric_changes);
    data.insert(
        "java_version",
        new_upstream_versions.minecraft.version.clone(),
    );
    data.insert("java_changes", java_changes);
    data.insert(
        "geyser_version",
        new_upstream_versions.geyser.version.clone(),
    );
    data.insert("geyser_changes", geyser_changes);
    data.insert(
        "mcxboxbroadcast_version",
        new_upstream_versions.mcxboxbroadcast.version.clone(),
    );
    data.insert("mcxboxbroadcast_changes", mcxboxbroadcast_changes);
    let release_text = handlebars.render("release", &data).unwrap();

    // Update spec file
    let template_content = fs::read_to_string("deploy/minecraft-server.spec.hbs")
        .await
        .unwrap();
    handlebars
        .register_template_string("spec", template_content)
        .unwrap();
    let data = serde_json::json!({
        "version": &tag_name,
        "bedrock_version": &new_upstream_versions.bedrock.version,
        "java_version": &new_upstream_versions.minecraft.version,
        "fabric_game_version": &new_upstream_versions.fabric.game_version,
        "fabric_loader_version": &new_upstream_versions.fabric.loader_version,
        "fabric_installer_version": &new_upstream_versions.fabric.installer_version,
        "geyser_version": &new_upstream_versions.geyser.version,
        "mcxboxbroadcast_version": &new_upstream_versions.mcxboxbroadcast.version,
    });
    let result = handlebars.render("spec", &data).unwrap();
    fs::write("minecraft-server.spec", result).await.unwrap();

    // Update README
    for (name, path) in [
        ("readme-description", "deploy/readme/description.md.hbs"),
        ("readme-instructions", "deploy/readme/instructions.md.hbs"),
        ("readme-remainder", "deploy/readme/remainder.md.hbs"),
    ] {
        let partial = fs::read_to_string(path).await.unwrap();
        handlebars.register_partial(name, partial).unwrap();
    }
    let template_content = fs::read_to_string("deploy/README.md.hbs").await.unwrap();
    handlebars
        .register_template_string("readme", template_content)
        .unwrap();
    let result = handlebars.render("readme", &data).unwrap();
    fs::write("README.md", result).await.unwrap();

    // Update tito version
    let template_content = fs::read_to_string("deploy/tito/minecraft-server.hbs")
        .await
        .unwrap();
    handlebars
        .register_template_string("tito", template_content)
        .unwrap();
    let mut data: HashMap<&str, &String> = HashMap::new();
    data.insert("version", &tag_name);
    let result = handlebars.render("tito", &data).unwrap();
    fs::write(".tito/packages/minecraft-server", result)
        .await
        .unwrap();

    // Update Cargo.toml
    let path = "minecraft-server/Cargo.toml";
    let contents = fs::read_to_string(path)
        .await
        .expect("Unable to read Cargo.toml");
    let mut doc = contents
        .parse::<DocumentMut>()
        .expect("Invalid TOML format");
    doc["package"]["version"] = toml_edit::value(&tag_name);
    fs::write(path, doc.to_string())
        .await
        .expect("Unable to write to Cargo.toml");

    // Keep the workspace lockfile synchronized with the package version.
    let cargo_update_output = Command::new("cargo")
        .args(["update", "--workspace"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await
        .expect("Failed to run cargo update");

    if !cargo_update_output.status.success() {
        eprintln!("Failed to run cargo update: {cargo_update_output:?}");
        return;
    }

    if operation == Operation::Update {
        println!("Updated version locks and generated files for {tag_name}");
        return;
    }

    let gitlab_token =
        env::var("GITLAB_API_TOKEN").expect("GITLAB_API_TOKEN must contain a GitLab API token");

    // Set git configuration
    let git_config_email_output = Command::new("git")
        .args(["config", "user.email", "git@seaoflaurels.com"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await
        .expect("Failed to set git user.email");

    if !git_config_email_output.status.success() {
        eprintln!("Failed to set git user.email: {git_config_email_output:?}");
        return;
    }

    let git_config_name_output = Command::new("git")
        .args(["config", "user.name", "Kai DeLorenzo"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await
        .expect("Failed to set git user.name");

    if !git_config_name_output.status.success() {
        eprintln!("Failed to set git user.name: {git_config_name_output:?}");
        return;
    }

    // Commit the changes
    let git_checkout_output = Command::new("git")
        .args(["checkout", "main"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await
        .expect("Failed to execute git checkout command");

    if !git_checkout_output.status.success() {
        eprintln!("Failed to checkout main: {git_checkout_output:?}");
        return;
    }

    let git_add_output = Command::new("git")
        .arg("add")
        .args(UPDATED_FILES)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await
        .expect("Failed to execute git add command");

    if !git_add_output.status.success() {
        eprintln!("Failed to add changes: {git_add_output:?}");
        return;
    }

    let git_commit_output = Command::new("git")
        .args([
            "commit",
            "-m",
            &format!("Update version files for release {tag_name} (pipeline job)"),
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await
        .expect("Failed to execute git commit command");

    if !git_commit_output.status.success() {
        eprintln!("Failed to commit changes: {git_commit_output:?}");
        return;
    }

    let authenticated_remote_url =
        format!("https://oauth2:{gitlab_token}@gitlab.com/kaidelorenzo/{PROJECT_SLUG}.git");

    let git_push_output = Command::new("git")
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", "remote.release.url")
        .env("GIT_CONFIG_VALUE_0", authenticated_remote_url)
        .args(["push", "release", "main"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .await
        .expect("Failed to push release commit");

    if !git_push_output.status.success() {
        eprintln!("Failed to push release commit: {git_push_output:?}");
        std::process::exit(1);
    }

    let release_url = format!("https://gitlab.com/api/v4/projects/{PROJECT_ID}/releases");
    let release_data = serde_json::json!({
        "name": format!("minecraft-server {tag_name}"),
        "tag_name": tag_name,
        "ref": "main",
        "description": release_text,
        "assets": {
            "links": [
                {
                    "name": "Fedora and EPEL Copr",
                    "url": "https://copr.fedorainfracloud.org/coprs/kaidelorenzo/minecraft-server"
                }
            ]
        }
    });
    let response = Client::new()
        .post(&release_url)
        .bearer_auth(&gitlab_token)
        .json(&release_data)
        .send()
        .await
        .expect("Failed to create GitLab release");

    if !response.status().is_success() {
        let status = response.status();
        let body = response.text().await.unwrap_or_default();
        eprintln!("Failed to create GitLab release {tag_name}: HTTP {status}: {body}");
        std::process::exit(1);
    }

    println!("Published GitLab release {tag_name}; COPR will mirror it");
}

fn operation_from_arguments() -> Operation {
    let mut arguments = env::args().skip(1);
    let operation = match arguments.next().as_deref() {
        Some("update") => Operation::Update,
        Some("release") => Operation::Release,
        _ => {
            eprintln!("Usage: update-checker <update|release>");
            std::process::exit(2);
        }
    };
    if arguments.next().is_some() {
        eprintln!("Usage: update-checker <update|release>");
        std::process::exit(2);
    }
    operation
}

#[derive(Clone, Debug, Eq, PartialEq)]
struct Version(String);

impl Version {
    fn new(version: impl AsRef<str>) -> Self {
        Self(version.as_ref().trim().to_string())
    }
}

impl fmt::Display for Version {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(formatter)
    }
}

#[derive(Debug, Eq, PartialEq)]
struct FabricServerVersion {
    game: Version,
    loader: Version,
    installer: Version,
}

impl FabricServerVersion {
    const fn new(game: Version, loader: Version, installer: Version) -> Self {
        Self {
            game,
            loader,
            installer,
        }
    }
}

impl fmt::Display for FabricServerVersion {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            formatter,
            "{}/{}/{}",
            self.game, self.loader, self.installer
        )
    }
}

fn latest_fabric_version(versions: &[FabricVersion]) -> Version {
    Version::new(
        versions
            .iter()
            .find(|version| version.stable)
            .or_else(|| versions.first())
            .expect("Fabric version list should not be empty")
            .version
            .as_str(),
    )
}

fn bedrock_version_from_url(url: &str) -> Version {
    let version = url
        .strip_prefix(BEDROCK_URL_PREFIX)
        .and_then(|value| value.strip_suffix(BEDROCK_URL_SUFFIX))
        .expect("Bedrock Linux download URL should use Minecraft's canonical format");
    assert!(
        version.split('.').count() == 4
            && version.split('.').all(|component| !component.is_empty()
                && component.bytes().all(|byte| byte.is_ascii_digit())),
        "Bedrock version should contain four numeric components"
    );
    Version::new(version)
}

async fn download_sha256(client: &Client, url: &str, maximum_bytes: u64) -> String {
    let mut last_error = String::new();
    for attempt in 1..=3 {
        match download_sha256_once(client, url, maximum_bytes).await {
            Ok(sha256) => return sha256,
            Err(error) => {
                last_error = error;
                eprintln!("Bedrock checksum download attempt {attempt}/3 failed: {last_error}");
                if attempt < 3 {
                    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
                }
            }
        }
    }
    panic!("Unable to download Bedrock archive for checksum calculation: {last_error}");
}

async fn download_sha256_once(
    client: &Client,
    url: &str,
    maximum_bytes: u64,
) -> Result<String, String> {
    let mut response = client
        .get(url)
        .send()
        .await
        .map_err(|error| error.to_string())?
        .error_for_status()
        .map_err(|error| error.to_string())?;
    if response
        .content_length()
        .is_some_and(|length| length > maximum_bytes)
    {
        return Err("Bedrock archive exceeds the download limit".to_string());
    }
    let mut downloaded_bytes = 0_u64;
    let mut hasher = Sha256::new();
    while let Some(chunk) = response.chunk().await.map_err(|error| error.to_string())? {
        downloaded_bytes = downloaded_bytes
            .checked_add(
                u64::try_from(chunk.len())
                    .map_err(|error| format!("Bedrock archive chunk is too large: {error}"))?,
            )
            .ok_or_else(|| "Bedrock archive size exceeds u64".to_string())?;
        if downloaded_bytes > maximum_bytes {
            return Err("Bedrock archive exceeds the download limit".to_string());
        }
        hasher.update(chunk);
    }
    Ok(format!("{:x}", hasher.finalize()))
}

async fn write_pretty_json(path: &str, value: &(impl Serialize + Sync)) {
    let mut output = Vec::new();
    let formatter = PrettyFormatter::with_indent(b"    ");
    let mut serializer = serde_json::Serializer::with_formatter(&mut output, formatter);
    value.serialize(&mut serializer).unwrap();
    output.push(b'\n');
    fs::write(path, output).await.unwrap();
}

fn version_changes(changed: bool, old: &str, new: &str) -> String {
    if changed {
        format!("Bump version from `{old}` to `{new}`")
    } else {
        "None".to_string()
    }
}

fn changed_components<const N: usize>(components: [(bool, &str); N]) -> String {
    let changed: Vec<&str> = components
        .into_iter()
        .filter_map(|(is_changed, name)| is_changed.then_some(name))
        .collect();

    match changed.as_slice() {
        [] => panic!("unreachable"),
        [one] => one.to_string(),
        [first, second] => format!("{first} and {second}"),
        [head @ .., last] => format!("{} and {last}", head.join(", ")),
    }
}

#[cfg(test)]
mod tests {
    use super::{Config, FabricServerVersion, UpstreamVersions, Version};
    use std::{fs, path::Path};
    use toml_edit::DocumentMut;

    #[test]
    fn fabric_release_changes_format_components() {
        let old_version = FabricServerVersion::new(
            Version::new("26.1\n"),
            Version::new("0.19.3\n"),
            Version::new("1.1.2\n"),
        );
        let new_version = FabricServerVersion::new(
            Version::new("26.2"),
            Version::new("0.19.3"),
            Version::new("1.1.2"),
        );

        assert_eq!(
            format!("Bump version from `{old_version}` to `{new_version}`"),
            "Bump version from `26.1/0.19.3/1.1.2` to `26.2/0.19.3/1.1.2`"
        );
    }

    #[test]
    fn generated_files_match_committed_outputs() {
        let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
        let read = |path: &str| fs::read_to_string(root.join(path)).unwrap();
        let release: Config = serde_json::from_str(&read("deploy/versions.json")).unwrap();
        let upstream_versions: UpstreamVersions =
            serde_json::from_str(&read("upstream-versions.json")).unwrap();
        let fabric_version = format!(
            "{}-{}-{}",
            upstream_versions.fabric.game_version,
            upstream_versions.fabric.loader_version,
            upstream_versions.fabric.installer_version,
        );

        assert_eq!(
            super::bedrock_version_from_url(&upstream_versions.bedrock.url).0,
            upstream_versions.bedrock.version,
        );
        assert_eq!(
            upstream_versions.fabric.game_version,
            upstream_versions.minecraft.version,
        );
        assert_eq!(upstream_versions.fabric.version, fabric_version);
        assert_eq!(
            upstream_versions.fabric.url,
            format!(
                "https://meta.fabricmc.net/v2/versions/loader/{}/{}/{}/server/jar",
                upstream_versions.fabric.game_version,
                upstream_versions.fabric.loader_version,
                upstream_versions.fabric.installer_version,
            )
        );

        assert_upstream_locks(&upstream_versions);

        let cargo_manifest: DocumentMut = read("minecraft-server/Cargo.toml").parse().unwrap();
        assert_eq!(
            cargo_manifest["package"]["version"].as_str().unwrap(),
            release.minecraft_server_rs
        );
        let mut handlebars = handlebars::Handlebars::new();
        handlebars.set_strict_mode(true);
        let template_data = serde_json::json!({
            "version": release.minecraft_server_rs,
            "bedrock_version": upstream_versions.bedrock.version,
            "java_version": upstream_versions.minecraft.version,
            "fabric_game_version": upstream_versions.fabric.game_version,
            "fabric_loader_version": upstream_versions.fabric.loader_version,
            "fabric_installer_version": upstream_versions.fabric.installer_version,
            "geyser_version": upstream_versions.geyser.version,
            "mcxboxbroadcast_version": upstream_versions.mcxboxbroadcast.version,
        });
        for (name, path) in [
            ("readme-description", "deploy/readme/description.md.hbs"),
            ("readme-instructions", "deploy/readme/instructions.md.hbs"),
            ("readme-remainder", "deploy/readme/remainder.md.hbs"),
        ] {
            handlebars.register_partial(name, read(path)).unwrap();
        }
        assert_eq!(
            handlebars
                .render_template(&read("deploy/minecraft-server.spec.hbs"), &template_data)
                .unwrap(),
            read("minecraft-server.spec"),
            "minecraft-server.spec must match deploy/minecraft-server.spec.hbs",
        );
        assert_eq!(
            handlebars
                .render_template(&read("deploy/tito/minecraft-server.hbs"), &template_data,)
                .unwrap(),
            read(".tito/packages/minecraft-server"),
        );
        assert_eq!(
            handlebars
                .render_template(&read("deploy/README.md.hbs"), &template_data)
                .unwrap(),
            read("README.md"),
            "README.md must match deploy/README.md.hbs and its partials",
        );
        handlebars
            .render_template(
                &read("deploy/release.md.hbs"),
                &serde_json::json!({
                    "version": "1.2.3",
                    "tagline": "Version bump",
                    "bedrock_version": "1.2.3.4",
                    "bedrock_changes": "None",
                    "fabric_version": "1.2.3/4/5",
                    "fabric_changes": "None",
                    "java_version": "1.2.3",
                    "java_changes": "None",
                    "geyser_version": "1.2.3-4",
                    "geyser_changes": "None",
                    "mcxboxbroadcast_version": "5",
                    "mcxboxbroadcast_changes": "None",
                }),
            )
            .unwrap();
    }

    fn assert_locked_artifact(url: &str, sha256: &str) {
        assert!(url.starts_with("https://"));
        assert_eq!(sha256.len(), 64);
        assert!(
            sha256
                .bytes()
                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
        );
    }

    fn assert_upstream_locks(versions: &UpstreamVersions) {
        for artifact in [
            &versions.bedrock,
            &versions.minecraft,
            &versions.geyser,
            &versions.mcxboxbroadcast,
        ] {
            assert_locked_artifact(&artifact.url, &artifact.sha256);
        }
        assert_locked_artifact(&versions.fabric.url, &versions.fabric.sha256);
    }
}
