//! Detect if libdittoffi is present and, if not, download it.
//!
//! Derived from openssl-sys build.rs.
//!
//! The search order is as follows:
//!
//!   1. DITTOFFI_SEARCH_PATH
//!   2. OUT_DIR (target/<profile>/build/dittolive-ditto-sys-.../out)
//!   3. PWD
//!   4. CARGO_BUILD_TARGET_DIR (built into Cargo, used by CI)
//!   5. Host built-in defaults (ie. /usr/lib, /lib, /usr/local/lib, $HOME/lib) controlled by linker
//!
//! It is strongly recommend internal builds use the DITTOFFI_SEARCH_PATH to
//! ensure a locally built (not downloaded) libdittoffi is used.

use std::{
    ffi::OsString,
    fmt::Write,
    io::ErrorKind,
    ops::Not,
    path::{Path, PathBuf},
};

#[rustfmt::skip]
macro_rules! LIB_NAME {() => ( "dittoffi" )}
const LIB_NAME: &str = LIB_NAME!();

// In order to centralize our public env vars, and to reduce churn when updating
// those, we do:
// const INTERNAL_NAME: &str = "PUBLIC_NAME";

// This is set up by the `.cargo/config` file for local repo builds.

/// Set to a non-zero value to ensure build script does not download binary
/// object if it cannot be found locally.
const DITTO_LOCAL_BUILD_ENV_VAR: &str = "DITTO_LOCAL_BUILD";
/// Set to non-zero value to force the build script to download the library artifact.
const CARGO_FEATURE_DOWNLOAD_ENV_VAR: &str = "CARGO_FEATURE_DOWNLOAD";
/// Provide a search hint for this script to find a library artifact.
const SEARCH_PATH_ENV_VAR: &str = "DITTOFFI_SEARCH_PATH";
/// 0 = `dylib`, 1 = `static` defaults to `static`
const LIBDITTO_STATIC_ENV_VAR: &str = "LIBDITTO_STATIC";
/// TODO: Deprecate? ~line:398
const LIBDITTO_STATIC_AMEND_WITH: &str = "LIBDITTO_STATIC_AMEND_WITH";

fn ditto_local_build() -> bool {
    std::env::var(DITTO_LOCAL_BUILD_ENV_VAR)
        .map(|var| var != "0")
        .unwrap_or(false)
}

pub fn main() -> ::std::io::Result<()> {
    // docs.rs builds packages without an internet connection, so we can't download binaries. Just
    // skip, because we don't need the actual binary
    if std::env::var("DOCS_RS").is_ok() {
        return Ok(());
    }

    let version = lib_version();
    for &env_var in &[
        SEARCH_PATH_ENV_VAR,
        LIBDITTO_STATIC_ENV_VAR,
        CARGO_FEATURE_DOWNLOAD_ENV_VAR,
        DITTO_LOCAL_BUILD_ENV_VAR,
        LIBDITTO_STATIC_AMEND_WITH,
    ] {
        cargo_directive!("rerun-if-env-changed" => "{}", env_var);
    }
    let ditto_local_build = ditto_local_build();
    let force_download_dittoffi = std::env::var(CARGO_FEATURE_DOWNLOAD_ENV_VAR)
        .map(|var| var != "0")
        .unwrap_or(false);

    let target = std::env::var("TARGET").unwrap(); // compilation target
    let lib_kind = lib_kind(); // static or dylib
    let lib_name = libname_from_target(&target, lib_kind); // libname given target and kind

    let lib_path = if force_download_dittoffi {
        // If forcing download, there is no need to look for local
        download_dittoffi(&version, &target, lib_name, force_download_dittoffi)
    } else if let Some(path) = local_lib_path(lib_name, lib_kind, ditto_local_build) {
        // If found locally, no need to download.
        path
    } else {
        // If not found, attempt to download.
        download_dittoffi(&version, &target, lib_name, force_download_dittoffi)
    };

    mb_amend_downloaded_libdittoffi(&lib_path);

    let lib_dir = lib_path.parent().unwrap();
    cargo_directive! {"rustc-link-lib" => "{hint}{LIB_NAME}",
        // FIXME(Daniel): for some reason the `--whole-archive` / `-force-load` hint
        // of a static lib causes trouble with the duplication of the unmagled alloc
        // symbols from the standard library present in both `libdittoffi.a` and
        // `dittolive-ditto-sys` (since both depend on their own stdlib, and somehow
        // they don't unify).
        //
        // Technically, the `alloc` functions of `dittoffi` are just internal, and
        // should not clash / cause conflicts. It is precisely the nature of
        // this `--whole-archive`/ `-force-load` thrown willy-nilly by Cargo which
        // brings them into scope (the weird part being why they don't _conflate_
        // with the redundant alloc functions, and instead, _collide_ 🤷).
        //
        // See https://github.com/rust-lang/rust/blob/481725984b4cd94ef5c00917b01c1771b6e5299c/compiler/rustc_codegen_ssa/src/back/linker.rs#L501
        // for their own reasoning, notwithstanding their lack of subjunctive.
        //
        // In the meantime, we work around it by simply skipping the
        // `static`-nudging altogether.
        hint = if lib_kind != "static" { format!("{lib_kind}=") } else { "".into() },
    }
    // linking currently forced to be static to avoid issues with dylib loader
    cargo_directive!("rustc-link-search" => "native={}", lib_dir.display());

    match () {
        _case if target.contains("linux") => {
            if target.contains("-gnueabihf") {
                // makes searching work out of the box on ARM, but don't do this for
                // x86_64 linux
                std::env::set_var("LD_LIBRARY_PATH", lib_dir.as_os_str());
            }
        }
        _case if target.contains("apple") => {
            if lib_kind == "static" {
                // macOS static libraries need the Security.framework for the ring crate
                cargo_directive!("rustc-link-lib" => "framework=Security");
                // We also need SystemConfiguration for our cocoa transports behind core.
                cargo_directive!("rustc-link-lib" => "framework=SystemConfiguration");
            }
        }
        _case if target.contains("windows") => {
            cargo_directive!("rustc-link-lib" => "ntdll");
            cargo_directive!("rustc-link-lib" => "iphlpapi");
            cargo_directive!("rustc-link-lib" => "oleaut32");
            cargo_directive!("rustc-link-lib" => "ole32");
        }
        _default => {
            cargo_directive!("warning" => "unexpected target platform");
        }
    }
    Ok(())
}

fn local_lib_path(lib_name: &str, lib_kind: &str, ditto_local_build: bool) -> Option<PathBuf> {
    // First check if the search path given has a binary
    if let Some(search_hint) = std::env::var_os(SEARCH_PATH_ENV_VAR).map(PathBuf::from) {
        eprintln!(
            "`{SEARCH_PATH_ENV_VAR}` override provided: {}",
            search_hint.display()
        );

        #[allow(clippy::disallowed_methods)]
        match search_hint.canonicalize() {
            Ok(mut canonical_path)
                if canonical_path.metadata().is_ok() && canonical_path.is_dir() =>
            {
                canonical_path.push(lib_name);
                eprintln!("Checking for {}", canonical_path.display());
                if canonical_path.exists() && canonical_path.is_file() {
                    eprintln!("Found!");
                    // Return path if given search hint is an accessible directory,
                    // and the derived `dittoffi` library is found, otherwise fall through
                    // and continue searching.
                    return Some(canonical_path);
                } else {
                    warn!(
                        "Unable to find binary at provided search location: {}",
                        canonical_path.display(),
                    );
                }
            }
            Ok(malformed_path) => {
                warn!(
                    "Malformed path given at {SEARCH_PATH_ENV_VAR}, path must be a directory and \
                     must be accessible. Given: {}",
                    malformed_path.display()
                )
            }
            Err(err) => {
                warn!("Unable to canonicalize path given at {SEARCH_PATH_ENV_VAR}: {err}")
            }
        }
    }

    let paths = candidate_locations(lib_name);

    for path in paths {
        eprintln!("Checking for {}", path.display());
        if path.exists() && path.is_file() {
            eprintln!("Found!");
            return Some(path);
        }
    }

    warn!("`{lib_name}` not found (build with `-vv` to debug the locations searched)");

    if ditto_local_build && lib_kind == "static" {
        panic!("Failed to find the `{lib_name}` static library.");
    } else {
        None
    }
}

// nominate some candidate locations to search for the library
fn candidate_locations(lib_name: &str) -> Vec<PathBuf> {
    let mut locations = Vec::new();
    // First we'll check three common locations used by Cargo

    // Check the current working directory.
    if let Some(path) = std::env::var_os("PWD").map(PathBuf::from) {
        locations.push(path)
    }

    // Check the OUT_DIR, which is where the library will be downloaded if it
    // can't be found
    if let Some(out_dir) = std::env::var_os("OUT_DIR") {
        // A hack to try and get to the TARGET_DIR which is not typically passed
        // to build scripts but is used during local development and the CI
        // pipeline.
        //
        // Typical value here is:
        // `./target/<profile>/build/dittolive-ditto-sys-<hash>/out`
        let mut target_dir = PathBuf::from(out_dir);
        target_dir.pop(); // out
        target_dir.pop(); // dittolive-ditto-sys-<hash>
        target_dir.pop(); // build

        // should be target/<profile>
        eprintln!("TARGET_DIR may be {}", target_dir.display());
        // The target/<profile>/deps directory often has libdittoffi as well for
        // certain internal builds
        let mut deps_dir = target_dir.clone();
        locations.push(target_dir);
        deps_dir.push("deps");
        locations.push(deps_dir);
    }
    // always put this one last
    let out_dir = env("OUT_DIR").map(PathBuf::from).unwrap(); // always present
    locations.push(out_dir);

    // Append the lib name to each location
    locations.iter_mut().for_each(|p| p.push(lib_name));
    locations
}

fn env_inner(name: &str) -> Option<OsString> {
    let var = std::env::var_os(name);
    cargo_directive!("rerun-if-env-changed" => "{}", name);
    match var {
        Some(ref v) => eprintln!("{} = {}", name, v.to_string_lossy()),
        None => eprintln!("{} unset", name),
    }
    var
}

fn env(name: &str) -> Option<OsString> {
    let prefix = std::env::var("TARGET")
        .unwrap()
        .to_uppercase()
        .replace('-', "_");
    let prefixed = format!("{}_{}", prefix, name);
    env_inner(&prefixed).or_else(|| env_inner(name))
}

// the REQUESTED kind of library
// If the library of one type isn't present we'll switch to the other
fn lib_kind() -> &'static str {
    match std::env::var(LIBDITTO_STATIC_ENV_VAR)
        .as_ref()
        .map(|s| s.as_str())
        .ok()
    {
        Some("0") => "dylib",
        Some("1") => "static",
        Some(extraneous) => panic!(
            "Invalid `{LIBDITTO_STATIC_ENV_VAR}` env var value: got `{extraneous}`, expected `0` \
             or `1` or unset"
        ),
        None => {
            eprintln!("`{LIBDITTO_STATIC_ENV_VAR}` not set, defaulting to `static`");
            "static"
        }
    }
}

// To support cross-compilation only the specified TARGET should be used to
// determine the version of libdittoffi to fetch
fn libname_from_target(target: &str, kind: &str) -> &'static str {
    match kind {
        "dylib" if target.contains("apple") => concat!("lib", LIB_NAME!(), ".dylib"),
        "dylib" if target.contains("linux") => concat!("lib", LIB_NAME!(), ".so"),
        "dylib" if target.contains("windows") => concat!("", LIB_NAME!(), ".dll"),

        "static" if target.contains("apple") || target.contains("linux") => {
            concat!("lib", LIB_NAME!(), ".a")
        }
        "static" if target.contains("windows") => concat!("", LIB_NAME!(), ".lib"),

        _otherwise => {
            panic!("Unsupported library kind `{kind}` specification for the given target={target}")
        }
    }
}

fn lib_version() -> String {
    std::env::var("CARGO_PKG_VERSION").unwrap()
}

/// Download the dittoffi lib from S3. This function is only called if the lib cannot be found
/// locally and will panic if the download fails. This ensures good compilation error messages to
/// users rather than failing at the linker step.
fn download_dittoffi(version: &str, target: &str, lib_name: &str, force_download: bool) -> PathBuf {
    let output_path = std::env::var_os("OUT_DIR").unwrap().with(lib_name);
    // Always use a release build of libdittoffi
    let url = format!(
        "https://software.ditto.live/rust/Ditto/{version}/{target}/release/{libname}",
        target = target,
        version = version,
        libname = lib_name,
    );

    let download = match output_path.metadata() {
        Ok(_) => {
            eprintln!(
                "{} is already downloaded at {}, force: {force_download}",
                &lib_name,
                output_path.display()
            );
            force_download
        }
        Err(err) if err.kind() == ErrorKind::NotFound => true,
        Err(other_err) => {
            // Build script should panic if download fails.
            panic!(
                "Unexpected error when downloading {} to {}: {other_err}",
                &lib_name,
                &output_path.display()
            );
        }
    };

    if download {
        warn!("Attempting to download {}", &url);
        ::std::process::Command::new("curl")
            .arg("-o")
            .arg(&output_path)
            .arg("--create-dirs")
            .arg("--location")
            .arg("--compressed")
            .arg("--max-time")
            .arg("1024")
            .arg("-f") // Force build script to fail on 404
            .arg(&url)
            .status() // wait for output
            .expect("Failed to download Ditto SDK binary component for compilation target")
            .success()
            .not()
            .then(|| {
                warn!("Failed to download Ditto SDK binary component from {url}");
                panic!("Failed to download Ditto SDK binary component");
            });
    }

    output_path
}

/// In order to try and offer compatibility with Rust SDK users with a different toolchain than
/// the one we used to compile the then bundled `libdittoffi.a` (see the `rust-toolchain` file),
/// we have some amending to do (see #7661).
///
/// Indeed, `libdittoffi.a` reëxports its own `cat rust-toolchain`-stdlib C symbols (mainly related
/// to `alloc` and `panic` machinery), which are in turn reëxported by the resulting
/// `dittolive-ditto-sys.rlib`, but the user's toolchain also has its own share of *equally-named*
/// stdlib C symbols.
///   - When the toolchains match, so do the functions, allowing for some linker-dedup logic to
///     trigger, thereby avoiding the issue, it seems.
///   - When they don't, linker-dedup logic may not apply, and we end up with multiple conflicting
///     definitions of these C symbols.
///
/// The following procedure "namespaces" the `libdittoffi.a` stdlib symbols to avoid this.
fn mb_amend_downloaded_libdittoffi(libdittoffi_location: &Path) {
    const CONFLICTING_STDLIB_SYMBOLS: &[&str] = &[
        "__rdl_alloc",
        "__rdl_dealloc",
        "__rdl_realloc",
        "__rdl_alloc_zeroed",
        "__rust_drop_panic",
        "__rust_foreign_exception",
        "rust_begin_unwind",
        "rust_panic",
        "__rg_oom",
        "rust_eh_personality",
        "__rdl_oom",
    ];
    const PSEUDO_NAMESPACE: &str = "__dittoffi_libstd";
    let rename = |symbol: &str| format!("{PSEUDO_NAMESPACE}@{symbol}");
    // `objcopy` and `.a` files are such a Unix-ism that it makes no sense
    // to try and make all this portable.
    let objcopy = match ::std::env::var_os(LIBDITTO_STATIC_AMEND_WITH) {
        Some(path) if cfg!(unix) => path.into_string().expect("valid UTF-8"),
        Some(_) => panic!("`LIBDITTO_STATIC_AMEND_WITH` is only supported on a Unix host"),
        None => return,
    };
    // Note: customers will almost always be using the downloaded `libditto{ffi,all}.a` artifact,
    // which now comes pre-sanitized, so there is no longer need to do this.
    if ditto_local_build().not() {
        warn! {"\
            Using `LIBDITTO_STATIC_AMEND_WITH` is no longer necessary, and is deprecated. \
            It could end up being ignored altogether in a future release.\
        "}
    }
    if libdittoffi_location
        .to_str()
        .map_or(true, |s| s.ends_with(".a").not())
    {
        warn! {"\
            Ignoring the given `LIBDITTO_STATIC_AMEND_WITH` directive, since \
            the chosen `libdittoffi` is not a static library: {libdittoffi_location:?}.\
        "}
        return;
    }
    #[allow(clippy::disallowed_methods)]
    let libdittoffi_location = libdittoffi_location
        .canonicalize()
        .expect("`libdittoffi.a` path to be valid");
    let libdittoffi_orig = &libdittoffi_location.with_extension("a.orig");
    let libdittoffi_amended = &libdittoffi_orig.with_extension("amended");
    let libdittoffi_a = libdittoffi_location.display();
    let (libdittoffi_orig, libdittoffi_amended) =
        (libdittoffi_orig.display(), libdittoffi_amended.display());
    let create_conflicting_stdlib_symbols_file = [
        "cat> conflicting-stdlib-symbols.txt <<'EOF'\n",
        &CONFLICTING_STDLIB_SYMBOLS
            .iter()
            .fold(String::new(), |mut buf, s| {
                let _ = writeln!(buf, "{} {}", s, rename(s));
                buf
            }),
        "EOF\n",
    ]
    .concat();
    let script = format! { r##"(
        set -euxo pipefail
        if [ ! -f {libdittoffi_orig:?} ]; then
            ln {libdittoffi_a:?} {libdittoffi_orig:?}
        fi
        cd $(mktemp -d)
        ar --plugin '' -x {libdittoffi_a:?}  # unpack
        mkdir amended/
        {{ set +x; }} >/dev/null
        {create_conflicting_stdlib_symbols_file}
        echo amend each '*.o'
        MAX_PARALLEL=64
        i=0
        for file in *.o; do
            if ((i++ % MAX_PARALLEL == 0)); then wait; fi
            {objcopy:?} --redefine-syms=conflicting-stdlib-symbols.txt $file amended/$file &
        done
        wait
        echo ar --plugin "''" -crs {libdittoffi_amended:?} 'amended/*.o'
        ar --plugin '' -crs {libdittoffi_amended:?} amended/*.o  # pack back
        set -x
        mv {libdittoffi_amended:?} {libdittoffi_a:?}
    ) >&2"## };
    ::std::process::Command::new("/bin/bash")
        .args(["-c", &script])
        .status()
        .expect("/bin/bash to exist")
        .success()
        .not()
        .then(|| panic!("failed to amend `{libdittoffi_a:?}`"));
}

trait WithPath {
    fn with(self, path: impl AsRef<Path>) -> PathBuf;
}

impl<ImplIntoPathBuf> WithPath for ImplIntoPathBuf
where
    ImplIntoPathBuf: Into<PathBuf>,
{
    fn with(self: ImplIntoPathBuf, path: impl AsRef<Path>) -> PathBuf {
        let mut path_buf: PathBuf = self.into();
        path_buf.push(path);
        path_buf
    }
}

use do_not_use_println_but_cargo_directive_or_eprintln::*;
#[rustfmt::skip]
mod do_not_use_println_but_cargo_directive_or_eprintln {
    macro_rules! cargo_directive {(
        $kind:expr => $($contents:tt)*
    ) => ({
        let contents = ::std::format!($($contents)*);
        let contents = contents.trim();
        assert_eq!(contents.contains('\n'), false);
        ::std::println!(
            ::core::concat!("cargo:", $kind, "={}"), contents,
        );
    })}
    pub(in crate) use cargo_directive;
    #[allow(unused)]
    pub(in crate) use cargo_directive as println; // render println ambiguous to prevent misusage
}

macro_rules! warn_ {( $($msg:tt)* ) => ({
    let msg = ::std::format!($($msg)*);
    msg.lines().for_each(|line| {
        cargo_directive!("warning"=>
            "[{}] {}",
            env!("CARGO_PKG_NAME"),
            line,
        );
    });
})}
use warn_ as warn;
