//! Searches, processes and uploads release files.
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::fmt;
use std::path::PathBuf;
use std::str;
use std::sync::Arc;
use std::time::{Duration, Instant};

use anyhow::{bail, Result};
use console::style;
use sha1_smol::Digest;
use symbolic::common::ByteView;
use symbolic::debuginfo::js;
use symbolic::debuginfo::sourcebundle::SourceFileType;

use crate::api::{Api, ChunkServerOptions};
use crate::constants::DEFAULT_MAX_WAIT;
use crate::utils::chunks::{upload_chunks, Chunk, ASSEMBLE_POLL_INTERVAL};
use crate::utils::fs::get_sha1_checksums;
use crate::utils::non_empty::NonEmptySlice;
use crate::utils::progress::{ProgressBar, ProgressStyle};
use crate::utils::source_bundle;

use super::file_search::ReleaseFileMatch;

#[derive(Debug, Clone)]
pub struct UploadContext<'a> {
    pub org: &'a str,
    pub projects: NonEmptySlice<'a, String>,
    pub release: Option<&'a str>,
    pub dist: Option<&'a str>,
    pub note: Option<&'a str>,
    pub wait: bool,
    pub max_wait: Duration,
    pub chunk_upload_options: &'a ChunkServerOptions,
}

#[derive(Eq, PartialEq, Debug, Copy, Clone, Hash)]
pub enum LogLevel {
    Warning,
    Error,
}

impl fmt::Display for LogLevel {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match *self {
            LogLevel::Warning => write!(f, "warning"),
            LogLevel::Error => write!(f, "error"),
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SourceFile {
    pub url: String,
    pub path: PathBuf,
    pub contents: Arc<Vec<u8>>,
    pub ty: SourceFileType,
    /// A map of headers attached to the source file.
    ///
    /// Headers that `sentry-cli` knows about are
    /// * "debug-id" for a file's debug id
    /// * "Sourcemap" for a reference to a file's sourcemap
    pub headers: BTreeMap<String, String>,
    pub messages: Vec<(LogLevel, String)>,
    pub already_uploaded: bool,
}

impl SourceFile {
    pub fn from_release_file_match(url: &str, mut file: ReleaseFileMatch) -> SourceFile {
        let (ty, debug_id) = if sourcemap::is_sourcemap_slice(&file.contents) {
            (
                SourceFileType::SourceMap,
                std::str::from_utf8(&file.contents)
                    .ok()
                    .and_then(js::discover_sourcemap_embedded_debug_id),
            )
        } else if file
            .path
            .file_name()
            .and_then(OsStr::to_str)
            .map(|x| x.ends_with("bundle"))
            .unwrap_or(false)
            && sourcemap::ram_bundle::is_ram_bundle_slice(&file.contents)
        {
            (SourceFileType::IndexedRamBundle, None)
        } else if is_hermes_bytecode(&file.contents) {
            // This is actually a big hack:
            // For the react-native Hermes case, we skip uploading the bytecode bundle,
            // and rather flag it as an empty "minified source". That way, it
            // will get a SourceMap reference, and the server side processor
            // should deal with it accordingly.
            file.contents.clear();
            (SourceFileType::MinifiedSource, None)
        } else {
            // Here, we use MinifiedSource for historical reasons. We used to guess whether
            // a JS file was a minified file or a source file, and we would treat these files
            // differently when uploading or injecting them. However, the desired behavior is
            // and has always been to treat all JS files the same, since users should be
            // responsible for providing the file paths for only files they would like to have
            // uploaded or injected. The minified file guessing furthermore was not reliable,
            // since minification is not a necessary step in the JS build process.
            //
            // We use MinifiedSource here rather than Source because we want to treat all JS
            // files the way we used to treat minified files only. To use Source, we would need
            // to analyze all possible code paths that check this value, and update those as
            // well. To keep the change minimal, we use MinifiedSource here.
            (
                SourceFileType::MinifiedSource,
                std::str::from_utf8(&file.contents)
                    .ok()
                    .and_then(js::discover_debug_id),
            )
        };

        let mut source_file = SourceFile {
            url: url.into(),
            path: file.path,
            contents: file.contents.into(),
            ty,
            headers: BTreeMap::new(),
            messages: vec![],
            already_uploaded: false,
        };

        if let Some(debug_id) = debug_id {
            source_file.set_debug_id(debug_id.to_string());
        }
        source_file
    }

    /// Returns the value of the "debug-id" header.
    pub fn debug_id(&self) -> Option<&String> {
        self.headers.get("debug-id")
    }

    /// Sets the value of the "debug-id" header.
    pub fn set_debug_id(&mut self, debug_id: String) {
        self.headers.insert("debug-id".to_owned(), debug_id);
    }

    /// Sets the value of the "Sourcemap" header.
    pub fn set_sourcemap_reference(&mut self, sourcemap: String) {
        self.headers.insert("Sourcemap".to_owned(), sourcemap);
    }

    pub fn log(&mut self, level: LogLevel, msg: String) {
        self.messages.push((level, msg));
    }

    pub fn warn(&mut self, msg: String) {
        self.log(LogLevel::Warning, msg);
    }

    pub fn error(&mut self, msg: String) {
        self.log(LogLevel::Error, msg);
    }
}

/// A map from URLs to source files.
///
/// The keys correspond to the `url` field on the values.
pub type SourceFiles = BTreeMap<String, SourceFile>;

pub struct FileUpload<'a> {
    context: &'a UploadContext<'a>,
    files: SourceFiles,
}

impl<'a> FileUpload<'a> {
    pub fn new(context: &'a UploadContext) -> Self {
        FileUpload {
            context,
            files: SourceFiles::new(),
        }
    }

    pub fn files(&mut self, files: &SourceFiles) -> &mut Self {
        for (k, v) in files {
            if !v.already_uploaded {
                self.files.insert(k.to_owned(), v.to_owned());
            }
        }
        self
    }

    pub fn upload(&self) -> Result<()> {
        upload_files_chunked(self.context, &self.files)
    }
}

fn poll_assemble(checksum: Digest, chunks: &[Digest], context: &UploadContext) -> Result<()> {
    let progress_style = ProgressStyle::default_spinner().template("{spinner} Processing files...");

    let pb = ProgressBar::new_spinner();
    pb.enable_steady_tick(100);
    pb.set_style(progress_style);

    let assemble_start = Instant::now();
    let options_max_wait = match context.chunk_upload_options.max_wait {
        0 => DEFAULT_MAX_WAIT,
        secs => Duration::from_secs(secs),
    };

    let max_wait = context.max_wait.min(options_max_wait);

    let api = Api::current();
    let authenticated_api = api.authenticated()?;

    let response = loop {
        // prefer standalone artifact bundle upload over legacy release based upload
        let response = authenticated_api.assemble_artifact_bundle(
            context.org,
            context.projects,
            checksum,
            chunks,
            context.release,
            context.dist,
        )?;

        // Poll until there is a response, unless the user has specified to skip polling. In
        // that case, we return the potentially partial response from the server. This might
        // still contain a cached error.
        if !context.wait || response.state.is_finished() {
            break response;
        }

        if assemble_start.elapsed() > max_wait {
            break response;
        }

        std::thread::sleep(ASSEMBLE_POLL_INTERVAL);
    };

    if response.state.is_err() {
        let message = response.detail.as_deref().unwrap_or("unknown error");
        bail!("Failed to process uploaded files: {message}");
    }

    pb.finish_with_duration("Processing");

    if response.state.is_pending() {
        if context.wait {
            bail!("Failed to process files in {}s", max_wait.as_secs());
        } else {
            println!(
                "{} File upload complete (processing pending on server)",
                style(">").dim()
            );
        }
    } else {
        println!("{} File processing complete", style(">").dim());
    }

    print_upload_context_details(context);

    Ok(())
}

fn upload_files_chunked(context: &UploadContext, files: &SourceFiles) -> Result<()> {
    let options = context.chunk_upload_options;
    let archive = source_bundle::build(context, files.values(), None)?;

    let progress_style =
        ProgressStyle::default_spinner().template("{spinner} Optimizing bundle for upload...");

    let pb = ProgressBar::new_spinner();
    pb.enable_steady_tick(100);
    pb.set_style(progress_style);

    let view = ByteView::open(archive.path())?;
    let (checksum, checksums) = get_sha1_checksums(&view, options.chunk_size);
    let mut chunks = view
        .chunks(options.chunk_size.into())
        .zip(checksums.iter())
        .map(|(data, checksum)| Chunk((*checksum, data)))
        .collect::<Vec<_>>();

    pb.finish_with_duration("Optimizing");

    let progress_style = ProgressStyle::default_bar().template(&format!(
        "{} Uploading files...\
       \n{{wide_bar}}  {{bytes}}/{{total_bytes}} ({{eta}})",
        style(">").dim(),
    ));

    let api = Api::current();
    let response = api.authenticated()?.assemble_artifact_bundle(
        context.org,
        context.projects,
        checksum,
        &checksums,
        context.release,
        context.dist,
    )?;
    chunks.retain(|Chunk((digest, _))| response.missing_chunks.contains(digest));

    if !chunks.is_empty() {
        upload_chunks(&chunks, options, progress_style)?;
        println!("{} Uploaded files to Sentry", style(">").dim());
    } else {
        println!(
            "{} Nothing to upload, all files are on the server",
            style(">").dim()
        );
    }
    poll_assemble(checksum, &checksums, context)
}

fn print_upload_context_details(context: &UploadContext) {
    println!(
        "{} {}",
        style("> Organization:").dim(),
        style(context.org).yellow()
    );
    println!(
        "{} {}",
        style("> Projects:").dim(),
        style(context.projects.join(", ")).yellow()
    );
    println!(
        "{} {}",
        style("> Release:").dim(),
        style(context.release.unwrap_or("None")).yellow()
    );
    println!(
        "{} {}",
        style("> Dist:").dim(),
        style(context.dist.unwrap_or("None")).yellow()
    );
    println!(
        "{} {}",
        style("> Upload type:").dim(),
        style("artifact bundle").yellow()
    );
}

fn is_hermes_bytecode(slice: &[u8]) -> bool {
    // The hermes bytecode format magic is defined here:
    // https://github.com/facebook/hermes/blob/5243222ef1d92b7393d00599fc5cff01d189a88a/include/hermes/BCGen/HBC/BytecodeFileFormat.h#L24-L25
    const HERMES_MAGIC: [u8; 8] = [0xC6, 0x1F, 0xBC, 0x03, 0xC1, 0x03, 0x19, 0x1F];
    slice.starts_with(&HERMES_MAGIC)
}
