use reqwest::{
    StatusCode,
    blocking::{Body, Client},
};
use serde::{Deserialize, Serialize};
use std::{
    collections::BTreeSet,
    env,
    error::Error,
    fs::{File, OpenOptions},
    io::Write,
    path::Path,
    process::Command,
    thread,
    time::Duration,
};

#[derive(Debug, Deserialize)]
struct Issue {
    number: u64,
    title: String,
    html_url: String,
    state: String,
    #[serde(default)]
    labels: Vec<Label>,
}

#[derive(Debug, Deserialize)]
struct Label {
    name: String,
}

#[derive(Debug, Deserialize)]
struct Release {
    id: u64,
    draft: bool,
    #[serde(default)]
    assets: Option<Vec<Asset>>,
}

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

#[derive(Serialize)]
struct ReleasePayload<'a> {
    tag_name: &'a str,
    target_commitish: &'a str,
    name: &'a str,
    body: String,
    draft: bool,
    prerelease: bool,
}

struct Gitea {
    client: Client,
    api_url: String,
    repository: String,
    token: String,
}

impl Gitea {
    fn from_environment() -> Result<Self, Box<dyn Error>> {
        Ok(Self {
            client: Client::builder().build()?,
            api_url: required_env("GITEA_API_URL")?
                .trim_end_matches('/')
                .to_owned(),
            repository: required_env("GITEA_REPOSITORY")?,
            token: required_env("GITEA_TOKEN")?,
        })
    }

    fn url(&self, path: &str) -> String {
        format!("{}{path}", self.api_url)
    }

    fn issue(&self, number: u64) -> Result<Issue, Box<dyn Error>> {
        Ok(self
            .client
            .get(self.url(&format!("/repos/{}/issues/{number}", self.repository)))
            .header("Authorization", format!("token {}", self.token))
            .send()?
            .error_for_status()?
            .json()?)
    }

    fn release_for_tag(&self, tag: &str) -> Result<Option<Release>, Box<dyn Error>> {
        let response = self
            .client
            .get(self.url(&format!(
                "/repos/{}/releases/tags/{}",
                self.repository,
                percent_encode(tag)
            )))
            .header("Authorization", format!("token {}", self.token))
            .send()?;
        if response.status() == StatusCode::NOT_FOUND {
            Ok(None)
        } else {
            Ok(Some(response.error_for_status()?.json()?))
        }
    }

    fn create_release(&self, payload: &ReleasePayload<'_>) -> Result<Release, Box<dyn Error>> {
        Ok(self
            .client
            .post(self.url(&format!("/repos/{}/releases", self.repository)))
            .header("Authorization", format!("token {}", self.token))
            .json(payload)
            .send()?
            .error_for_status()?
            .json()?)
    }

    fn update_release(
        &self,
        release_id: u64,
        payload: &impl Serialize,
    ) -> Result<Release, Box<dyn Error>> {
        Ok(self
            .client
            .patch(self.url(&format!("/repos/{}/releases/{release_id}", self.repository)))
            .header("Authorization", format!("token {}", self.token))
            .json(payload)
            .send()?
            .error_for_status()?
            .json()?)
    }

    fn delete_asset(&self, release_id: u64, asset_id: u64) -> Result<(), Box<dyn Error>> {
        self.client
            .delete(self.url(&format!(
                "/repos/{}/releases/{release_id}/assets/{asset_id}",
                self.repository
            )))
            .header("Authorization", format!("token {}", self.token))
            .send()?
            .error_for_status()?;
        Ok(())
    }

    fn upload_asset(&self, release_id: u64, path: &Path) -> Result<(), Box<dyn Error>> {
        let name = path
            .file_name()
            .and_then(|name| name.to_str())
            .ok_or_else(|| format!("invalid release asset name: {}", path.display()))?;
        for attempt in 1..=3 {
            let file = File::open(path)?;
            let length = file.metadata()?.len();
            let body = Body::sized(file, length);
            let response = self
                .client
                .post(self.url(&format!(
                    "/repos/{}/releases/{release_id}/assets",
                    self.repository
                )))
                .query(&[("name", name)])
                .header("Authorization", format!("token {}", self.token))
                .header("Content-Type", "application/octet-stream")
                .body(body)
                .send();
            match response {
                Ok(response) if response.status().is_success() => {
                    println!("uploaded {}", path.display());
                    return Ok(());
                }
                Ok(response)
                    if attempt < 3
                        && (response.status().is_server_error()
                            || response.status() == StatusCode::TOO_MANY_REQUESTS) => {}
                Ok(response) => {
                    return Err(response.error_for_status().unwrap_err().into());
                }
                Err(error) if attempt < 3 => {
                    eprintln!("asset upload attempt {attempt} failed: {error}");
                }
                Err(error) => return Err(error.into()),
            }
            thread::sleep(Duration::from_secs(attempt));
        }
        unreachable!("upload retry loop always returns")
    }
}

pub(crate) fn prepare() -> Result<(), Box<dyn Error>> {
    let gitea = Gitea::from_environment()?;
    let tag = required_env("GITEA_REF_NAME")?;
    let sha = required_env("GITEA_SHA")?;
    let server = required_env("GITEA_SERVER_URL")?;
    let messages = commit_messages(&sha)?;
    let issues = issue_numbers(&messages)
        .into_iter()
        .map(|number| gitea.issue(number))
        .collect::<Result<Vec<_>, _>>()?;
    let closed = issues
        .iter()
        .filter(|issue| issue.state == "closed")
        .collect::<Vec<_>>();
    let payload = ReleasePayload {
        tag_name: &tag,
        target_commitish: &sha,
        name: &tag,
        body: release_body(closed.iter().copied(), &server, &gitea.repository, &sha),
        draft: true,
        prerelease: false,
    };

    let release = if let Some(existing) = gitea.release_for_tag(&tag)? {
        if !existing.draft
            && !replace_published_release(env::var("REBUILD_PUBLISHED_RELEASE").ok().as_deref())
        {
            return Err(format!("release {tag} is already published").into());
        }
        if !existing.draft {
            gitea.update_release(existing.id, &serde_json::json!({ "draft": true }))?;
        }
        for asset in existing.assets.unwrap_or_default() {
            gitea.delete_asset(existing.id, asset.id)?;
        }
        gitea.update_release(existing.id, &payload)?
    } else {
        gitea.create_release(&payload)?
    };

    let output_path = required_env("GITHUB_OUTPUT")?;
    writeln!(
        OpenOptions::new()
            .create(true)
            .append(true)
            .open(output_path)?,
        "release_id={}",
        release.id
    )?;
    Ok(())
}

pub(crate) fn upload<'a>(paths: impl Iterator<Item = &'a Path>) -> Result<(), Box<dyn Error>> {
    let gitea = Gitea::from_environment()?;
    let release_id = required_env("RELEASE_ID")?.parse()?;
    for path in paths {
        gitea.upload_asset(release_id, path)?;
    }
    Ok(())
}

pub(crate) fn publish() -> Result<(), Box<dyn Error>> {
    let gitea = Gitea::from_environment()?;
    let release_id = required_env("RELEASE_ID")?.parse()?;
    gitea.update_release(release_id, &serde_json::json!({ "draft": false }))?;
    Ok(())
}

fn replace_published_release(value: Option<&str>) -> bool {
    value == Some("true")
}

fn required_env(name: &str) -> Result<String, Box<dyn Error>> {
    env::var(name).map_err(|_| format!("required environment variable {name} is missing").into())
}

fn commit_messages(sha: &str) -> Result<String, Box<dyn Error>> {
    let previous = Command::new("git")
        .args(["describe", "--tags", "--abbrev=0", &format!("{sha}^")])
        .output()?;
    let previous = if previous.status.success() {
        String::from_utf8(previous.stdout)?.trim().to_owned()
    } else {
        String::new()
    };
    let revision = if previous.is_empty() {
        sha.to_owned()
    } else {
        format!("{previous}..{sha}")
    };
    let output = Command::new("git")
        .args(["log", "--format=%s%n%b", &revision])
        .output()?;
    if !output.status.success() {
        return Err(format!("git log failed for {revision}").into());
    }
    Ok(String::from_utf8(output.stdout)?)
}

fn issue_numbers(message: &str) -> Vec<u64> {
    let bytes = message.as_bytes();
    let mut numbers = BTreeSet::new();
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] != b'#' {
            index += 1;
            continue;
        }
        let start = index + 1;
        let mut end = start;
        while end < bytes.len() && bytes[end].is_ascii_digit() {
            end += 1;
        }
        if end > start
            && let Ok(number) = message[start..end].parse()
        {
            numbers.insert(number);
        }
        index = end.max(index + 1);
    }
    numbers.into_iter().collect()
}

fn release_body<'a>(
    issues: impl Iterator<Item = &'a Issue>,
    server: &str,
    repository: &str,
    sha: &str,
) -> String {
    let mut fixes = Vec::new();
    let mut improvements = Vec::new();
    let mut other = Vec::new();
    for issue in issues {
        let title = issue.title.split_whitespace().collect::<Vec<_>>().join(" ");
        let entry = format!(
            "- [#{} {}]({})",
            issue.number,
            markdown(&title),
            issue.html_url
        );
        let labels = issue
            .labels
            .iter()
            .map(|label| label.name.as_str())
            .collect::<BTreeSet<_>>();
        if labels.contains("bug") {
            fixes.push(entry);
        } else if labels.contains("enhancement") {
            improvements.push(entry);
        } else {
            other.push(entry);
        }
    }
    let mut sections = Vec::new();
    for (title, entries) in [
        ("Fixes", fixes),
        ("Improvements", improvements),
        ("Other changes", other),
    ] {
        if !entries.is_empty() {
            sections.push(format!("## {title}\n{}", entries.join("\n")));
        }
    }
    if sections.is_empty() {
        sections.push("No closed issues were linked from commits in this release.".to_owned());
    }
    let short_sha = &sha[..sha.len().min(12)];
    sections.push(format!(
        "Built from [{short_sha}]({}/{repository}/commit/{sha}).",
        server.trim_end_matches('/')
    ));
    sections.join("\n\n")
}

fn markdown(text: &str) -> String {
    text.replace('\\', "\\\\")
        .replace('[', "\\[")
        .replace(']', "\\]")
}

fn percent_encode(value: &str) -> String {
    let mut encoded = String::new();
    for byte in value.bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
            encoded.push(char::from(byte));
        } else {
            encoded.push('%');
            encoded.push_str(&format!("{byte:02X}"));
        }
    }
    encoded
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn issue_references_are_unique_and_sorted() {
        assert_eq!(
            issue_numbers("Fix #12, refs #3 and #12; ignore #x"),
            [3, 12]
        );
    }

    #[test]
    fn release_notes_are_grouped_linked_and_escaped() {
        let issues = [
            Issue {
                number: 12,
                title: "Fix [unlock]".to_owned(),
                html_url: "https://example.test/issues/12".to_owned(),
                state: "closed".to_owned(),
                labels: vec![Label {
                    name: "bug".to_owned(),
                }],
            },
            Issue {
                number: 3,
                title: "Add search".to_owned(),
                html_url: "https://example.test/issues/3".to_owned(),
                state: "closed".to_owned(),
                labels: vec![Label {
                    name: "enhancement".to_owned(),
                }],
            },
        ];
        let body = release_body(
            issues.iter(),
            "https://example.test",
            "owner/repo",
            "1234567890abcdef",
        );
        assert!(body.contains("## Fixes\n- [#12 Fix \\[unlock\\]]"));
        assert!(body.contains("## Improvements\n- [#3 Add search]"));
        assert!(
            body.contains(
                "[1234567890ab](https://example.test/owner/repo/commit/1234567890abcdef)"
            )
        );
    }

    #[test]
    fn release_tag_is_encoded_as_one_api_path_segment() {
        assert_eq!(percent_encode("release/1+beta"), "release%2F1%2Bbeta");
    }

    #[test]
    fn published_release_replacement_requires_explicit_opt_in() {
        assert!(!replace_published_release(None));
        assert!(!replace_published_release(Some("false")));
        assert!(!replace_published_release(Some("TRUE")));
        assert!(replace_published_release(Some("true")));
    }
}
