use std::{
    io::{Cursor, Read},
    path::PathBuf,
    string::FromUtf8Error,
};

use bon::Builder;
use bytes::Bytes;
use miette::Diagnostic;
use thiserror::Error;
use tracing::span;
use url::{ParseError, Url};

use crate::{
    config::Config,
    fs,
    git::GitSource,
    lockfile::RemotePackageSourceUrl,
    lua_rockspec::{LuaRockspecError, RemoteLuaRockspec, RockSourceSpec},
    luarocks,
    package::{
        PackageName, PackageReq, PackageSpec, PackageSpecFromPackageReqError, PackageVersion,
        RemotePackageTypeFilterSpec,
    },
    remote_package_db::{RemotePackageDB, RemotePackageDBError, SearchError},
    remote_package_source::RemotePackageSource,
    rockspec::Rockspec,
};

/// Builder for a rock downloader.
pub struct Download<'a> {
    package_req: &'a PackageReq,
    package_db: Option<&'a RemotePackageDB>,
    config: &'a Config,
}

impl<'a> Download<'a> {
    /// Construct a new `.src.rock` downloader.
    pub fn new(package_req: &'a PackageReq, config: &'a Config) -> Self {
        Self {
            package_req,
            package_db: None,
            config,
        }
    }

    /// Sets the package database to use for searching for packages.
    /// Instantiated from the config if not set.
    pub fn package_db(self, package_db: &'a RemotePackageDB) -> Self {
        Self {
            package_db: Some(package_db),
            ..self
        }
    }

    /// Download the package's Rockspec.
    pub async fn download_rockspec(self) -> Result<DownloadedRockspec, SearchAndDownloadError> {
        match self.package_db {
            Some(db) => download_rockspec(self.package_req, db, self.config).await,
            None => {
                let db = RemotePackageDB::from_config(self.config).await?;
                download_rockspec(self.package_req, &db, self.config).await
            }
        }
    }

    /// Download a `.src.rock` to a file.
    /// `destination_dir` defaults to the current working directory if not set.
    pub async fn download_src_rock_to_file(
        self,
        destination_dir: Option<PathBuf>,
    ) -> Result<DownloadedPackedRock, SearchAndDownloadError> {
        match self.package_db {
            Some(db) => {
                download_src_rock_to_file(self.package_req, destination_dir, db, self.config).await
            }
            None => {
                let db = RemotePackageDB::from_config(self.config).await?;
                download_src_rock_to_file(self.package_req, destination_dir, &db, self.config).await
            }
        }
    }

    /// Search for a `.src.rock` and download it to memory.
    pub async fn search_and_download_src_rock(
        self,
    ) -> Result<DownloadedPackedRockBytes, SearchAndDownloadError> {
        match self.package_db {
            Some(db) => search_and_download_src_rock(self.package_req, db, self.config).await,
            None => {
                let db = RemotePackageDB::from_config(self.config).await?;
                search_and_download_src_rock(self.package_req, &db, self.config).await
            }
        }
    }

    pub(crate) async fn download_remote_rock(
        self,
    ) -> Result<RemoteRockDownload, SearchAndDownloadError> {
        match self.package_db {
            Some(db) => download_remote_rock(self.package_req, db, self.config).await,
            None => {
                let db = RemotePackageDB::from_config(self.config).await?;
                download_remote_rock(self.package_req, &db, self.config).await
            }
        }
    }
}

pub struct DownloadedPackedRockBytes {
    pub name: PackageName,
    pub version: PackageVersion,
    pub bytes: Bytes,
    pub file_name: String,
    pub url: Url,
}

pub struct DownloadedPackedRock {
    pub name: PackageName,
    pub version: PackageVersion,
    pub path: PathBuf,
}

/// Remote Lua RockSpec that has been downloaded from a remote server, along with its source metadata
#[derive(Clone, Debug)]
pub struct DownloadedRockspec {
    pub rockspec: RemoteLuaRockspec,
    pub(crate) source: RemotePackageSource,
    pub(crate) source_url: Option<RemotePackageSourceUrl>,
}

#[derive(Clone, Debug)]
pub(crate) enum RemoteRockDownload {
    RockspecOnly {
        rockspec_download: DownloadedRockspec,
    },
    BinaryRock {
        rockspec_download: DownloadedRockspec,
        packed_rock: Bytes,
    },
    SrcRock {
        rockspec_download: DownloadedRockspec,
        src_rock: Bytes,
        source_url: RemotePackageSourceUrl,
    },
}

impl RemoteRockDownload {
    pub fn rockspec(&self) -> &RemoteLuaRockspec {
        &self.rockspec_download().rockspec
    }
    pub fn rockspec_download(&self) -> &DownloadedRockspec {
        match self {
            Self::RockspecOnly { rockspec_download }
            | Self::BinaryRock {
                rockspec_download, ..
            }
            | Self::SrcRock {
                rockspec_download, ..
            } => rockspec_download,
        }
    }
    // Instead of downloading a rockspec, generate one from a `PackageReq` and a `RockSourceSpec`.
    pub(crate) fn from_package_req_and_source_spec(
        package_req: PackageReq,
        source_spec: RockSourceSpec,
    ) -> Result<Self, SearchAndDownloadError> {
        let package_spec = package_req.try_into()?;
        let source_url = Some(match &source_spec {
            RockSourceSpec::Git(GitSource { url, checkout_ref }) => RemotePackageSourceUrl::Git {
                url: url.to_string(),
                checkout_ref: checkout_ref
                    .clone()
                    .ok_or(SearchAndDownloadError::MissingCheckoutRef(url.to_string()))?,
            },
            RockSourceSpec::File(path) => RemotePackageSourceUrl::File { path: path.clone() },
            RockSourceSpec::Url(url) => RemotePackageSourceUrl::Url { url: url.clone() },
        });
        let rockspec = RemoteLuaRockspec::from_package_and_source_spec(package_spec, source_spec);
        let rockspec_content = rockspec
            .to_lua_remote_rockspec_string()
            .expect("the infallible happened");
        let rockspec_download = DownloadedRockspec {
            rockspec,
            source_url,
            source: RemotePackageSource::RockspecContent(rockspec_content),
        };
        Ok(Self::RockspecOnly { rockspec_download })
    }
}

#[derive(Error, Debug, Diagnostic)]
pub enum DownloadRockspecError {
    #[error("failed to download rockspec: {0}")]
    #[diagnostic(help(
        "check your network connection and verify the package exists on the server."
    ))]
    Request(#[from] reqwest::Error),
    #[error("failed to convert rockspec response: {0}")]
    #[diagnostic(help(
        r#"the server returned a response that is not valid UTF-8.
check your network connection and server configuration.
if the issue persists, the server may be temporarily unavailable."#
    ))]
    ResponseConversion(#[from] FromUtf8Error),
    #[error("error initialising remote package DB:\n{0}")]
    #[diagnostic(forward(0))]
    RemotePackageDB(#[from] RemotePackageDBError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    DownloadSrcRock(#[from] DownloadSrcRockError),
}

/// Find and download a rockspec for a given package requirement
async fn download_rockspec(
    package_req: &PackageReq,
    package_db: &RemotePackageDB,
    config: &Config,
) -> Result<DownloadedRockspec, SearchAndDownloadError> {
    let rockspec = match download_remote_rock(package_req, package_db, config).await? {
        RemoteRockDownload::RockspecOnly {
            rockspec_download: rockspec,
        } => rockspec,
        RemoteRockDownload::BinaryRock {
            rockspec_download: rockspec,
            ..
        } => rockspec,
        RemoteRockDownload::SrcRock {
            rockspec_download: rockspec,
            ..
        } => rockspec,
    };
    Ok(rockspec)
}

async fn download_remote_rock(
    package_req: &PackageReq,
    package_db: &RemotePackageDB,
    config: &Config,
) -> Result<RemoteRockDownload, SearchAndDownloadError> {
    let span = span!(
        tracing::Level::INFO,
        "Downloading rock",
        package = package_req.to_string(),
    );
    let _enter = span.enter();
    let remote_package = package_db.find(package_req, None)?;
    match &remote_package.source {
        RemotePackageSource::LuarocksRockspec(url) => {
            let package = &remote_package.package;
            let rockspec_name = format!("{}-{}.rockspec", package.name(), package.version());
            let bytes = crate::reqwest::https_client(config)?
                .get(format!("{}/{}", url, rockspec_name))
                .send()
                .await
                .map_err(DownloadRockspecError::Request)?
                .error_for_status()
                .map_err(DownloadRockspecError::Request)?
                .bytes()
                .await
                .map_err(DownloadRockspecError::Request)?;
            let content = String::from_utf8(bytes.into())?;
            let rockspec = DownloadedRockspec {
                rockspec: RemoteLuaRockspec::new(&content)
                    .map_err(|err| SearchAndDownloadError::Rockspec(Box::new(err)))?,
                source: remote_package.source,
                source_url: remote_package.source_url,
            };
            Ok(RemoteRockDownload::RockspecOnly {
                rockspec_download: rockspec,
            })
        }
        RemotePackageSource::RockspecContent(content) => {
            let rockspec = DownloadedRockspec {
                rockspec: RemoteLuaRockspec::new(content)
                    .map_err(|err| SearchAndDownloadError::Rockspec(Box::new(err)))?,
                source: remote_package.source,
                source_url: remote_package.source_url,
            };
            Ok(RemoteRockDownload::RockspecOnly {
                rockspec_download: rockspec,
            })
        }
        RemotePackageSource::LuarocksBinaryRock(url) => {
            // prioritise lockfile source_url
            let url = if let Some(RemotePackageSourceUrl::Url { url }) = &remote_package.source_url
            {
                url
            } else {
                url
            };
            let rock = download_binary_rock(&remote_package.package, url, config).await?;
            let rockspec = DownloadedRockspec {
                rockspec: unpack_rockspec(&rock).await?,
                source: remote_package.source,
                source_url: remote_package.source_url,
            };
            Ok(RemoteRockDownload::BinaryRock {
                rockspec_download: rockspec,
                packed_rock: rock.bytes,
            })
        }
        RemotePackageSource::LuarocksSrcRock(url) => {
            // prioritise lockfile source_url
            let url = if let Some(RemotePackageSourceUrl::Url { url }) = &remote_package.source_url
            {
                url.clone()
            } else {
                url.clone()
            };
            let rock = download_src_rock(&remote_package.package, &url, config).await?;
            let rockspec = DownloadedRockspec {
                rockspec: unpack_rockspec(&rock).await?,
                source: remote_package.source,
                source_url: remote_package.source_url,
            };
            Ok(RemoteRockDownload::SrcRock {
                rockspec_download: rockspec,
                src_rock: rock.bytes,
                source_url: RemotePackageSourceUrl::Url { url },
            })
        }
        RemotePackageSource::Local => Err(SearchAndDownloadError::LocalSource),
        #[cfg(test)]
        RemotePackageSource::Test => unimplemented!(),
    }
}

#[derive(Error, Debug, Diagnostic)]
pub enum SearchAndDownloadError {
    #[error(transparent)]
    #[diagnostic(transparent)]
    Search(#[from] SearchError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Download(#[from] DownloadSrcRockError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    DownloadRockspec(#[from] DownloadRockspecError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error("UTF-8 conversion failed: {0}")]
    #[diagnostic(help(
        r#"the server returned a response that is not valid UTF-8.
check your network connection and server configuration.
if the issue persists, the server may be temporarily unavailable."#
    ))]
    Utf8(#[from] FromUtf8Error),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Rockspec(Box<LuaRockspecError>),
    #[error("error initialising remote package DB:\n{0}")]
    #[diagnostic(forward(0))]
    RemotePackageDB(#[from] RemotePackageDBError),
    #[error("failed to read packed rock {0}:\n{1}")]
    #[diagnostic(help(
        r#"the downloaded rock may be corrupted.
check your network connection and rerun the command to re-download it."#
    ))]
    ZipRead(String, zip::result::ZipError),
    #[error("failed to extract packed rock {0}:\n{1}")]
    #[diagnostic(help(
        r#"the downloaded rock may be corrupted.
check your network connection and rerun the command to re-download it."#
    ))]
    ZipExtract(String, zip::result::ZipError),
    #[error("{0} not found in the packed rock.")]
    #[diagnostic(help(
        r#"the packed rock does not contain the expected rockspec.
check your network connection and rerun the command to re-download it."#
    ))]
    RockspecNotFoundInPackedRock(String),
    #[error(transparent)]
    #[diagnostic(transparent)]
    PackageSpecFromPackageReq(#[from] PackageSpecFromPackageReqError),
    #[error("git source {0} without a revision or tag.")]
    #[diagnostic(help(
        r#"lux requires a pinned revision or tag to ensure reproducible builds.
without one, the same version could resolve to different code at different times.
try a different version, or report it to the package maintainer."#
    ))]
    MissingCheckoutRef(String),
    #[error("cannot download from a local rock source.")]
    #[diagnostic(
        help(
            r#"lux cannot download rocks from a local path.
for local dependencies, use `path` in your lux.toml."#
        ),
        url("https://lux.lumen-labs.org/reference/lux-toml#local-dependencies")
    )]
    LocalSource,
    #[error("cannot download from a local rock or embedded rockspec source.")]
    #[diagnostic(
        help(
            r#"lux cannot download rocks from a local path.
for local dependencies, use `path` in your lux.toml."#
        ),
        url("https://lux.lumen-labs.org/reference/lux-toml#local-dependencies")
    )]
    NonURLSource,
    #[error("client error:\n{0}")]
    #[diagnostic(help(
        "check your network connection and verify the package exists on the server."
    ))]
    Request(#[from] reqwest::Error),
}

async fn search_and_download_src_rock(
    package_req: &PackageReq,
    package_db: &RemotePackageDB,
    config: &Config,
) -> Result<DownloadedPackedRockBytes, SearchAndDownloadError> {
    let filter = Some(RemotePackageTypeFilterSpec {
        rockspec: false,
        binary: false,
        src: true,
    });
    let remote_package = package_db.find(package_req, filter)?;
    let source_url = remote_package
        .source
        .url()
        .ok_or(SearchAndDownloadError::NonURLSource)?;
    Ok(download_src_rock(&remote_package.package, &source_url, config).await?)
}

#[derive(Error, Debug, Diagnostic)]
pub enum DownloadSrcRockError {
    #[error("failed to download source rock: {0}")]
    #[diagnostic(help(
        "check your network connection and verify the package exists on the server."
    ))]
    Request(#[from] reqwest::Error),
    #[error("failed to parse source rock URL: {0}")]
    Parse(#[from] ParseError),
}

#[tracing::instrument(name = "Downloading src.rock", skip_all)]
pub(crate) async fn download_src_rock(
    package: &PackageSpec,
    server_url: &Url,
    config: &Config,
) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
    ArchiveDownload::new()
        .package(package)
        .server_url(server_url)
        .config(config)
        .ext("src.rock")
        .download()
        .await
}

#[tracing::instrument(name = "Downloading binary rock", skip_all)]
pub(crate) async fn download_binary_rock(
    package: &PackageSpec,
    server_url: &Url,
    config: &Config,
) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
    let ext = format!("{}.rock", luarocks::current_platform_luarocks_identifier());
    ArchiveDownload::new()
        .package(package)
        .server_url(server_url)
        .config(config)
        .ext(&ext)
        .fallback_ext("all.rock")
        .download()
        .await
}

#[tracing::instrument(name = "Downloading package", skip_all)]
async fn download_src_rock_to_file(
    package_req: &PackageReq,
    destination_dir: Option<PathBuf>,
    package_db: &RemotePackageDB,
    config: &Config,
) -> Result<DownloadedPackedRock, SearchAndDownloadError> {
    let rock = search_and_download_src_rock(package_req, package_db, config).await?;
    let full_rock_name = mk_packed_rock_name(&rock.name, &rock.version, "src.rock");
    fs::tokio::write(
        destination_dir
            .map(|dest| dest.join(&full_rock_name))
            .unwrap_or_else(|| full_rock_name.clone().into()),
        &rock.bytes,
    )
    .await?;

    Ok(DownloadedPackedRock {
        name: rock.name.to_owned(),
        version: rock.version.to_owned(),
        path: full_rock_name.into(),
    })
}

#[derive(Builder)]
#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
struct ArchiveDownload<'a> {
    package: &'a PackageSpec,

    server_url: &'a Url,

    ext: &'a str,

    fallback_ext: Option<&'a str>,

    config: &'a Config,
}

impl<State> ArchiveDownloadBuilder<'_, State>
where
    State: archive_download_builder::State + archive_download_builder::IsComplete,
{
    async fn download(self) -> Result<DownloadedPackedRockBytes, DownloadSrcRockError> {
        let args = self._build();
        let package = args.package;

        let span = span!(
            tracing::Level::INFO,
            "Downloading",
            package = package.name().to_string(),
            version = package.version().to_string(),
            server = args.server_url.to_string(),
        );

        let _enter = span.enter();
        let ext = args.ext;
        let server_url = args.server_url;
        let full_rock_name = mk_packed_rock_name(package.name(), package.version(), ext);
        tracing::debug!(message = format!("📥 Downloading {full_rock_name}").as_str());
        let url = server_url.join(&full_rock_name)?;
        let response = crate::reqwest::https_client(args.config)?
            .get(url.clone())
            .send()
            .await?;
        let bytes = if response.status().is_success() {
            response.bytes().await
        } else {
            match args.fallback_ext {
                Some(ext) => {
                    let full_rock_name =
                        mk_packed_rock_name(package.name(), package.version(), ext);
                    let url = server_url.join(&full_rock_name)?;
                    crate::reqwest::https_client(args.config)?
                        .get(url.clone())
                        .send()
                        .await?
                        .error_for_status()?
                        .bytes()
                        .await
                }
                None => response.error_for_status()?.bytes().await,
            }
        }?;
        Ok(DownloadedPackedRockBytes {
            name: package.name().clone(),
            version: package.version().clone(),
            bytes,
            file_name: full_rock_name,
            url,
        })
    }
}

fn mk_packed_rock_name(name: &PackageName, version: &PackageVersion, ext: &str) -> String {
    format!("{name}-{version}.{ext}")
}

pub(crate) async fn unpack_rockspec(
    rock: &DownloadedPackedRockBytes,
) -> Result<RemoteLuaRockspec, SearchAndDownloadError> {
    let cursor = Cursor::new(&rock.bytes);
    let rockspec_file_name = format!("{}-{}.rockspec", rock.name, rock.version);
    let mut zip = zip::ZipArchive::new(cursor)
        .map_err(|err| SearchAndDownloadError::ZipRead(rock.file_name.clone(), err))?;
    let rockspec_index = (0..zip.len())
        .find(|&i| {
            unsafe { zip.by_index(i).unwrap_unchecked() }
                .name()
                .eq(&rockspec_file_name)
        })
        .ok_or(SearchAndDownloadError::RockspecNotFoundInPackedRock(
            rockspec_file_name.to_string(),
        ))?;
    let mut rockspec_file = zip
        .by_index(rockspec_index)
        .map_err(|err| SearchAndDownloadError::ZipExtract(rock.file_name.clone(), err))?;
    let mut content = String::new();
    rockspec_file
        .read_to_string(&mut content)
        .map_err(|source| fs::FsError::ReadToString {
            path: PathBuf::from(&rockspec_file_name),
            source,
        })?;
    let rockspec = RemoteLuaRockspec::new(&content)
        .map_err(|err| SearchAndDownloadError::Rockspec(Box::new(err)))?;
    Ok(rockspec)
}
