use std::{
    error,
    fs::{self, File, OpenOptions},
    io::{self, Read, Write},
    path::{Path, PathBuf},
    thread,
    time::Duration,
};

use reqwest::blocking::{Client, Response};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tempfile::{NamedTempFile, TempDir};

use crate::Error;

const UPSTREAM_VERSIONS: &str = include_str!("../../upstream-versions.json");
const EXECUTABLE_NAME: &str = "bedrock_server";
const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
const MAX_EXTRACTED_BYTES: u64 = 1024 * 1024 * 1024;
const DOWNLOAD_ATTEMPTS: usize = 3;
const PRESERVED_DISTRIBUTION_FILES: [&str; 4] = [
    "allowlist.json",
    "packetlimitconfig.json",
    "permissions.json",
    "server.properties",
];

#[derive(Clone, Copy, Deserialize)]
struct Release<'a> {
    #[serde(borrow)]
    version: &'a str,
    #[serde(borrow)]
    url: &'a str,
    #[serde(borrow)]
    sha256: &'a str,
}

impl Release<'static> {
    fn locked() -> Self {
        #[derive(Deserialize)]
        struct UpstreamVersions<'a> {
            #[serde(borrow)]
            bedrock: Release<'a>,
        }

        serde_json::from_str::<UpstreamVersions<'static>>(UPSTREAM_VERSIONS)
            .expect("upstream-versions.json should contain valid Bedrock release metadata")
            .bedrock
    }
}

#[derive(Clone, Copy)]
struct DownloadPolicy {
    attempts: usize,
    connect_timeout: Duration,
    request_timeout: Duration,
    retry_delay: Duration,
    max_archive_bytes: u64,
    max_extracted_bytes: u64,
}

impl Default for DownloadPolicy {
    fn default() -> Self {
        Self {
            attempts: DOWNLOAD_ATTEMPTS,
            connect_timeout: Duration::from_secs(15),
            request_timeout: Duration::from_mins(5),
            retry_delay: Duration::from_secs(1),
            max_archive_bytes: MAX_ARCHIVE_BYTES,
            max_extracted_bytes: MAX_EXTRACTED_BYTES,
        }
    }
}

pub fn executable_filename() -> String {
    format!("bedrock-server-{}", Release::locked().version)
}

pub fn ensure_installed(
    executable_path: &Path,
    working_directory: &Path,
) -> Result<(), Box<dyn error::Error>> {
    ensure_release_installed(
        executable_path,
        working_directory,
        Release::locked(),
        DownloadPolicy::default(),
    )
}

fn ensure_release_installed(
    executable_path: &Path,
    working_directory: &Path,
    release: Release<'_>,
    policy: DownloadPolicy,
) -> Result<(), Box<dyn error::Error>> {
    validate_release(release)?;
    let data_directory = executable_path.parent().ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            "Bedrock executable path must have a parent directory",
        )
    })?;
    fs::create_dir_all(data_directory)?;

    let lock_path = data_directory.join(".bedrock-install.lock");
    let lock_file = OpenOptions::new()
        .create(true)
        .read(true)
        .write(true)
        .truncate(false)
        .open(lock_path)?;
    lock_file.lock()?;

    let executable_marker_path = installation_marker_path(data_directory, release.version);
    let distribution_marker_path = installation_marker_path(working_directory, release.version);
    if installation_is_complete(
        executable_path,
        &executable_marker_path,
        &distribution_marker_path,
        release.sha256,
    ) {
        return Ok(());
    }

    let client = Client::builder()
        .user_agent(format!("minecraft-server/{}", env!("CARGO_PKG_VERSION")))
        .http1_only()
        .connect_timeout(policy.connect_timeout)
        .timeout(policy.request_timeout)
        .build()?;
    let archive = download_archive(&client, data_directory, release, policy)?;
    let staging_directory = extract_archive(&archive, data_directory, policy.max_extracted_bytes)?;

    merge_distribution(staging_directory.path(), working_directory)?;
    atomic_copy(
        &staging_directory.path().join(EXECUTABLE_NAME),
        executable_path,
    )?;
    write_marker(&executable_marker_path, release.sha256)?;
    write_marker(&distribution_marker_path, release.sha256)?;
    sync_directory(data_directory)?;
    sync_directory(working_directory)?;
    remove_superseded_installations(data_directory, executable_path, &executable_marker_path);
    remove_superseded_markers(working_directory, &distribution_marker_path);
    Ok(())
}

fn validate_release(release: Release<'_>) -> Result<(), Error> {
    if release.version.is_empty() || release.url.is_empty() {
        return Err(Error::InvalidBedrockReleaseMetadata);
    }
    if release.sha256.len() != 64
        || !release
            .sha256
            .bytes()
            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
    {
        return Err(Error::InvalidBedrockReleaseMetadata);
    }
    Ok(())
}

fn installation_marker_path(data_directory: &Path, version: &str) -> PathBuf {
    data_directory.join(format!(".bedrock-server-{version}.sha256"))
}

fn installation_is_complete(
    executable_path: &Path,
    executable_marker_path: &Path,
    distribution_marker_path: &Path,
    sha256: &str,
) -> bool {
    executable_path.is_file()
        && marker_matches(executable_marker_path, sha256)
        && marker_matches(distribution_marker_path, sha256)
}

fn marker_matches(marker_path: &Path, sha256: &str) -> bool {
    fs::read_to_string(marker_path).is_ok_and(|installed_sha256| installed_sha256.trim() == sha256)
}

fn download_archive(
    client: &Client,
    data_directory: &Path,
    release: Release<'_>,
    policy: DownloadPolicy,
) -> Result<NamedTempFile, Box<dyn error::Error>> {
    let mut last_error = None;
    for attempt in 1..=policy.attempts {
        match download_archive_once(client, data_directory, release, policy.max_archive_bytes) {
            Ok(archive) => return Ok(archive),
            Err(error) => {
                eprintln!(
                    "Bedrock download attempt {attempt}/{} failed: {error}",
                    policy.attempts
                );
                last_error = Some(error);
                if attempt < policy.attempts {
                    thread::sleep(policy.retry_delay);
                }
            }
        }
    }

    Err(last_error.unwrap_or_else(|| Box::new(Error::InvalidBedrockDownloadPolicy)))
}

fn download_archive_once(
    client: &Client,
    data_directory: &Path,
    release: Release<'_>,
    max_archive_bytes: u64,
) -> Result<NamedTempFile, Box<dyn error::Error>> {
    let response = client.get(release.url).send()?.error_for_status()?;
    if response
        .content_length()
        .is_some_and(|length| length > max_archive_bytes)
    {
        return Err(Box::new(Error::BedrockArchiveTooLarge(max_archive_bytes)));
    }

    let mut archive = NamedTempFile::new_in(data_directory)?;
    let actual_sha256 = stream_response(response, &mut archive, max_archive_bytes)?;
    if actual_sha256 != release.sha256 {
        return Err(Box::new(Error::BedrockChecksumMismatch {
            expected: release.sha256.to_string(),
            actual: actual_sha256,
        }));
    }
    archive.as_file().sync_all()?;
    Ok(archive)
}

fn stream_response(
    mut response: Response,
    output: &mut NamedTempFile,
    max_archive_bytes: u64,
) -> Result<String, Box<dyn error::Error>> {
    let mut hasher = Sha256::new();
    let mut downloaded_bytes = 0_u64;
    let mut buffer = vec![0_u8; 64 * 1024];
    loop {
        let count = response.read(&mut buffer)?;
        if count == 0 {
            break;
        }
        downloaded_bytes = downloaded_bytes
            .checked_add(u64::try_from(count)?)
            .ok_or(Error::BedrockArchiveTooLarge(max_archive_bytes))?;
        if downloaded_bytes > max_archive_bytes {
            return Err(Box::new(Error::BedrockArchiveTooLarge(max_archive_bytes)));
        }
        output.write_all(&buffer[..count])?;
        hasher.update(&buffer[..count]);
    }
    output.flush()?;
    Ok(format!("{:x}", hasher.finalize()))
}

fn extract_archive(
    archive: &NamedTempFile,
    data_directory: &Path,
    max_extracted_bytes: u64,
) -> Result<TempDir, Box<dyn error::Error>> {
    let staging_directory = TempDir::new_in(data_directory)?;
    let archive_file = archive.reopen()?;
    let mut zip = zip::ZipArchive::new(archive_file)?;
    let mut extracted_bytes = 0_u64;

    for index in 0..zip.len() {
        let mut entry = zip.by_index(index)?;
        let relative_path = entry.enclosed_name().ok_or(Error::Malformed)?;
        reject_symbolic_link(&entry)?;
        extracted_bytes = extracted_bytes
            .checked_add(entry.size())
            .ok_or(Error::BedrockExtractionTooLarge(max_extracted_bytes))?;
        if extracted_bytes > max_extracted_bytes {
            return Err(Box::new(Error::BedrockExtractionTooLarge(
                max_extracted_bytes,
            )));
        }

        let output_path = staging_directory.path().join(relative_path);
        if entry.is_dir() {
            fs::create_dir_all(&output_path)?;
            continue;
        }
        if let Some(parent) = output_path.parent() {
            fs::create_dir_all(parent)?;
        }
        let mut output = OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&output_path)?;
        io::copy(&mut entry, &mut output)?;
        set_archive_permissions(&output_path, entry.unix_mode())?;
        output.sync_all()?;
    }

    if !staging_directory.path().join(EXECUTABLE_NAME).is_file() {
        return Err(Box::new(Error::Malformed));
    }
    Ok(staging_directory)
}

fn reject_symbolic_link(entry: &zip::read::ZipFile<'_, File>) -> Result<(), Error> {
    const FILE_TYPE_MASK: u32 = 0o170_000;
    const SYMBOLIC_LINK: u32 = 0o120_000;
    if entry
        .unix_mode()
        .is_some_and(|mode| mode & FILE_TYPE_MASK == SYMBOLIC_LINK)
    {
        return Err(Error::Malformed);
    }
    Ok(())
}

#[cfg(unix)]
fn set_archive_permissions(path: &Path, mode: Option<u32>) -> io::Result<()> {
    use std::os::unix::fs::PermissionsExt;

    if let Some(mode) = mode {
        fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o777))?;
    }
    Ok(())
}

fn merge_distribution(staging_directory: &Path, working_directory: &Path) -> io::Result<()> {
    fs::create_dir_all(working_directory)?;
    merge_directory(staging_directory, staging_directory, working_directory)
}

fn merge_directory(root: &Path, source_directory: &Path, destination: &Path) -> io::Result<()> {
    for entry in fs::read_dir(source_directory)? {
        let entry = entry?;
        let source_path = entry.path();
        let relative_path = source_path.strip_prefix(root).map_err(io::Error::other)?;
        if relative_path == Path::new(EXECUTABLE_NAME) {
            continue;
        }
        let destination_path = destination.join(relative_path);
        let file_type = entry.file_type()?;
        if file_type.is_dir() {
            fs::create_dir_all(&destination_path)?;
            merge_directory(root, &source_path, destination)?;
        } else if !is_preserved_distribution_file(relative_path) || !destination_path.exists() {
            atomic_copy(&source_path, &destination_path)?;
        }
    }
    Ok(())
}

fn is_preserved_distribution_file(relative_path: &Path) -> bool {
    PRESERVED_DISTRIBUTION_FILES
        .iter()
        .any(|path| relative_path == Path::new(path))
}

fn atomic_copy(source: &Path, destination: &Path) -> io::Result<()> {
    let parent = destination
        .parent()
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "destination has no parent"))?;
    fs::create_dir_all(parent)?;
    let mut temporary_file = NamedTempFile::new_in(parent)?;
    let mut source_file = File::open(source)?;
    io::copy(&mut source_file, &mut temporary_file)?;
    temporary_file
        .as_file()
        .set_permissions(source_file.metadata()?.permissions())?;
    temporary_file.as_file().sync_all()?;
    temporary_file
        .persist(destination)
        .map_err(|error| error.error)?;
    Ok(())
}

fn write_marker(marker_path: &Path, sha256: &str) -> io::Result<()> {
    let parent = marker_path
        .parent()
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "marker path has no parent"))?;
    let mut marker = NamedTempFile::new_in(parent)?;
    writeln!(marker, "{sha256}")?;
    marker.as_file().sync_all()?;
    marker.persist(marker_path).map_err(|error| error.error)?;
    Ok(())
}

fn sync_directory(directory: &Path) -> io::Result<()> {
    File::open(directory)?.sync_all()
}

fn remove_superseded_installations(
    data_directory: &Path,
    executable_path: &Path,
    marker_path: &Path,
) {
    let Ok(entries) = fs::read_dir(data_directory) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path == executable_path || path == marker_path || !path.is_file() {
            continue;
        }
        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
            continue;
        };
        let is_old_executable = name.starts_with("bedrock-server-");
        let is_old_marker = name.starts_with(".bedrock-server-") && name.ends_with(".sha256");
        if (is_old_executable || is_old_marker)
            && let Err(error) = fs::remove_file(&path)
        {
            eprintln!(
                "Unable to remove superseded Bedrock installation {}: {error}",
                path.display()
            );
        }
    }
}

fn remove_superseded_markers(directory: &Path, marker_path: &Path) {
    let Ok(entries) = fs::read_dir(directory) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path == marker_path || !path.is_file() {
            continue;
        }
        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
            continue;
        };
        if name.starts_with(".bedrock-server-")
            && name.ends_with(".sha256")
            && let Err(error) = fs::remove_file(&path)
        {
            eprintln!(
                "Unable to remove superseded Bedrock distribution marker {}: {error}",
                path.display()
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use std::{
        io::{Cursor, Read, Write},
        net::{TcpListener, TcpStream},
        sync::{Arc, Barrier},
        thread::{self, JoinHandle},
        time::Duration,
    };

    use sha2::{Digest, Sha256};
    use tempfile::tempdir;
    use zip::{ZipWriter, write::SimpleFileOptions};

    use super::{DownloadPolicy, Release, ensure_release_installed, installation_marker_path};

    struct TestResponse {
        status: &'static str,
        body: Vec<u8>,
        body_bytes_to_send: usize,
    }

    #[test]
    fn retries_interrupted_body_and_publishes_complete_installation() {
        let archive = test_archive();
        let partial_length = archive.len() / 2;
        let (url, server) = serve(vec![
            TestResponse {
                status: "200 OK",
                body: archive.clone(),
                body_bytes_to_send: partial_length,
            },
            TestResponse {
                status: "200 OK",
                body: archive.clone(),
                body_bytes_to_send: archive.len(),
            },
        ]);
        let sha256 = format!("{:x}", Sha256::digest(&archive));
        let root = tempdir().unwrap();
        let data_directory = root.path().join("data");
        let working_directory = root.path().join("config");
        let executable = data_directory.join("bedrock-server-test");
        std::fs::create_dir_all(&data_directory).unwrap();
        std::fs::create_dir_all(working_directory.join("definitions")).unwrap();
        std::fs::write(&executable, "partial executable").unwrap();
        std::fs::write(working_directory.join("server.properties"), "user settings").unwrap();
        std::fs::write(
            working_directory.join("definitions/runtime.txt"),
            "old runtime data",
        )
        .unwrap();

        ensure_release_installed(
            &executable,
            &working_directory,
            Release {
                version: "test",
                url: &url,
                sha256: &sha256,
            },
            test_policy(2),
        )
        .unwrap();
        server.join().unwrap();

        assert_eq!(std::fs::read(&executable).unwrap(), b"executable");
        assert_eq!(
            std::fs::read_to_string(working_directory.join("server.properties")).unwrap(),
            "user settings"
        );
        assert_eq!(
            std::fs::read_to_string(working_directory.join("definitions/runtime.txt")).unwrap(),
            "new runtime data"
        );
        assert_eq!(
            std::fs::read_to_string(working_directory.join("release-notes.txt")).unwrap(),
            "release notes"
        );
        assert_eq!(
            std::fs::read_to_string(installation_marker_path(&data_directory, "test"))
                .unwrap()
                .trim(),
            sha256
        );
    }

    #[test]
    fn checksum_failure_does_not_publish_executable_or_marker() {
        let archive = test_archive();
        let (url, server) = serve(vec![TestResponse {
            status: "200 OK",
            body: archive.clone(),
            body_bytes_to_send: archive.len(),
        }]);
        let root = tempdir().unwrap();
        let data_directory = root.path().join("data");
        let working_directory = root.path().join("config");
        let executable = data_directory.join("bedrock-server-test");

        let error = ensure_release_installed(
            &executable,
            &working_directory,
            Release {
                version: "test",
                url: &url,
                sha256: "0000000000000000000000000000000000000000000000000000000000000000",
            },
            test_policy(1),
        )
        .unwrap_err();
        server.join().unwrap();

        assert!(error.to_string().contains("checksum mismatch"));
        assert!(!executable.exists());
        assert!(!installation_marker_path(&data_directory, "test").exists());
        assert!(!working_directory.exists());
    }

    #[test]
    fn unsuccessful_http_status_does_not_create_installation() {
        let (url, server) = serve(vec![TestResponse {
            status: "404 Not Found",
            body: b"not a zip".to_vec(),
            body_bytes_to_send: 9,
        }]);
        let root = tempdir().unwrap();
        let data_directory = root.path().join("data");
        let working_directory = root.path().join("config");
        let executable = data_directory.join("bedrock-server-test");

        let error = ensure_release_installed(
            &executable,
            &working_directory,
            Release {
                version: "test",
                url: &url,
                sha256: "0000000000000000000000000000000000000000000000000000000000000000",
            },
            test_policy(1),
        )
        .unwrap_err();
        server.join().unwrap();

        assert!(error.to_string().contains("404"));
        assert!(!executable.exists());
        assert!(!working_directory.exists());
    }

    #[test]
    fn concurrent_installations_share_one_download() {
        let archive = test_archive();
        let (url, server) = serve(vec![TestResponse {
            status: "200 OK",
            body: archive.clone(),
            body_bytes_to_send: archive.len(),
        }]);
        let sha256 = format!("{:x}", Sha256::digest(&archive));
        let root = tempdir().unwrap();
        let executable = root.path().join("data/bedrock-server-test");
        let working_directory = root.path().join("config");
        let barrier = Arc::new(Barrier::new(2));
        let mut installations = Vec::new();

        for _ in 0..2 {
            let url = url.clone();
            let sha256 = sha256.clone();
            let executable = executable.clone();
            let working_directory = working_directory.clone();
            let barrier = Arc::clone(&barrier);
            installations.push(thread::spawn(move || {
                barrier.wait();
                ensure_release_installed(
                    &executable,
                    &working_directory,
                    Release {
                        version: "test",
                        url: &url,
                        sha256: &sha256,
                    },
                    test_policy(1),
                )
                .unwrap();
            }));
        }
        for installation in installations {
            installation.join().unwrap();
        }
        server.join().unwrap();

        assert_eq!(std::fs::read(executable).unwrap(), b"executable");
    }

    #[test]
    fn a_new_working_directory_receives_distribution_assets() {
        let archive = test_archive();
        let (url, server) = serve(vec![
            TestResponse {
                status: "200 OK",
                body: archive.clone(),
                body_bytes_to_send: archive.len(),
            },
            TestResponse {
                status: "200 OK",
                body: archive.clone(),
                body_bytes_to_send: archive.len(),
            },
        ]);
        let sha256 = format!("{:x}", Sha256::digest(&archive));
        let root = tempdir().unwrap();
        let executable = root.path().join("data/bedrock-server-test");
        let first_working_directory = root.path().join("first-config");
        let second_working_directory = root.path().join("second-config");
        let release = Release {
            version: "test",
            url: &url,
            sha256: &sha256,
        };

        ensure_release_installed(
            &executable,
            &first_working_directory,
            release,
            test_policy(1),
        )
        .unwrap();
        ensure_release_installed(
            &executable,
            &second_working_directory,
            release,
            test_policy(1),
        )
        .unwrap();
        server.join().unwrap();

        assert!(
            first_working_directory
                .join("definitions/runtime.txt")
                .is_file()
        );
        assert!(
            second_working_directory
                .join("definitions/runtime.txt")
                .is_file()
        );
    }

    fn test_policy(attempts: usize) -> DownloadPolicy {
        DownloadPolicy {
            attempts,
            connect_timeout: Duration::from_secs(2),
            request_timeout: Duration::from_secs(2),
            retry_delay: Duration::ZERO,
            max_archive_bytes: 1024 * 1024,
            max_extracted_bytes: 1024 * 1024,
        }
    }

    fn test_archive() -> Vec<u8> {
        let mut archive = ZipWriter::new(Cursor::new(Vec::new()));
        let executable_options = SimpleFileOptions::default().unix_permissions(0o755);
        archive
            .start_file("bedrock_server", executable_options)
            .unwrap();
        archive.write_all(b"executable").unwrap();
        archive
            .start_file("server.properties", SimpleFileOptions::default())
            .unwrap();
        archive.write_all(b"archive defaults").unwrap();
        archive
            .start_file("definitions/runtime.txt", SimpleFileOptions::default())
            .unwrap();
        archive.write_all(b"new runtime data").unwrap();
        archive
            .start_file("release-notes.txt", SimpleFileOptions::default())
            .unwrap();
        archive.write_all(b"release notes").unwrap();
        archive.finish().unwrap().into_inner()
    }

    fn serve(responses: Vec<TestResponse>) -> (String, JoinHandle<()>) {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let address = listener.local_addr().unwrap();
        let server = thread::spawn(move || {
            for response in responses {
                let (mut stream, _) = listener.accept().unwrap();
                verify_request(&mut stream);
                write!(
                    stream,
                    "HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                    response.status,
                    response.body.len()
                )
                .unwrap();
                stream
                    .write_all(&response.body[..response.body_bytes_to_send])
                    .unwrap();
            }
        });
        (format!("http://{address}/bedrock.zip"), server)
    }

    fn verify_request(stream: &mut TcpStream) {
        stream
            .set_read_timeout(Some(Duration::from_secs(2)))
            .unwrap();
        let mut request = Vec::new();
        let mut buffer = [0_u8; 1024];
        while !request.windows(4).any(|window| window == b"\r\n\r\n") {
            let count = stream.read(&mut buffer).unwrap();
            assert_ne!(count, 0);
            request.extend_from_slice(&buffer[..count]);
        }
        let request = String::from_utf8(request).unwrap().to_ascii_lowercase();
        assert!(request.contains("user-agent: minecraft-server/"));
    }
}
