//! Dylib build + copy (notable implementation notes: build, copy, and
//! platform-safe paths).
//!
//! ## Implementer choices recorded here
//!
//! Three implementation decisions live in this module. Each is explained
//! inline with its rationale anchored to the checks here.
//! the choice:
//!
//! - **Cargo invocation** (the policy — "implementer chooses the specific
//!   cargo subcommand"): `cargo rustc -p <crate> --lib --release
//!   --crate-type=dylib --message-format=json` with
//!   `RUSTFLAGS="-C prefer-dynamic"`. Validated end-to-end by the
//!   inventory-on-dylib spike (verdict `GO_NATIVE`; the policy).
//!   `cargo rustc` is the only subcommand whose `--crate-type=dylib`
//!   flag overrides the consumer's `[lib]` declaration without
//!   modifying its `Cargo.toml`. The `prefer-dynamic` flag is required
//!   for compile-time-link consumers per the spike's findings.
//!
//! - **File copy primitive** (the policy — "implementer chooses the file-copy
//!   primitive"): `std::fs::copy`. POSIX semantics on Linux/macOS,
//!   `CopyFileW` on Windows. The cost (~few hundred ms on a warm cache
//!   is acceptable; the v0.2 reflink optimization is
//!   anchored deferral.
//!
//! - **Dedicated `CARGO_TARGET_DIR`** (spike note): `RUSTFLAGS=
//!   "-C prefer-dynamic"` is part of cargo's fingerprint hash, so
//!   alternating between a normal `cargo build` and the lihaaf dylib
//!   build in the same target dir thrashes the entire dependency graph.
//!   lihaaf unconditionally builds into `target/lihaaf-build/` so the
//!   adopter's normal `cargo test` loop doesn't fight lihaaf's
//!   invocations.

use std::path::{Path, PathBuf};
use std::process::Command;

use serde::Deserialize;

use crate::config::Suite;
use crate::error::{Error, Outcome};
use crate::toolchain::Toolchain;

/// Result of a successful dylib build.
#[derive(Debug, Clone)]
pub struct BuildOutput {
    /// The cargo-emitted dylib path (in the dedicated lihaaf target
    /// dir — `target/lihaaf-build/release/deps/lib<crate>-<hash>.so`).
    pub cargo_dylib_path: PathBuf,
    /// The `target/release/deps` directory containing the rest of the
    /// link tree (rlibs of dev_deps, etc.) — needed for `-L dependency=`.
    pub deps_dir: PathBuf,
    /// The cargo invocation as a single line, for diagnostics if a
    /// later step trips.
    #[allow(dead_code)] // populated for diagnostic plumbing; not currently read by any caller.
    pub invocation: String,
}

/// Parameters needed to build the dylib.
#[derive(Debug, Clone)]
pub struct BuildParams<'a> {
    /// `dylib_crate` from the metadata.
    pub crate_name: &'a str,
    /// `features` from the metadata.
    pub features: &'a [String],
    /// Path to the consumer's `Cargo.toml`.
    pub manifest_path: &'a Path,
    /// Where to put the lihaaf-private target directory. Caller chooses;
    /// session uses `<workspace_target>/lihaaf-build`.
    pub target_dir: &'a Path,
    /// Captured rustc identity, for the diagnostic if cargo can't find
    /// rustc.
    pub toolchain: &'a Toolchain,
}

/// Build the consumer crate as a release-mode dylib.
///
/// Returns the path of the cargo-emitted artifact in the dedicated
/// lihaaf target dir. The caller copies (or symlinks) this artifact to
/// `target/lihaaf/lib<crate>-current-<hash>.so` per the policy.
pub fn build(params: &BuildParams<'_>) -> Result<BuildOutput, Error> {
    std::fs::create_dir_all(params.target_dir).map_err(|e| {
        Error::io(
            e,
            "creating lihaaf-build target dir",
            Some(params.target_dir.to_path_buf()),
        )
    })?;

    // Compose the cargo invocation. `cargo rustc` is the subcommand
    // because it's the only one whose `--crate-type=dylib` overrides
    // `[lib]` without modifying the consumer's Cargo.toml — confirmed
    // by the inventory-on-dylib spike (the policy).
    let mut cmd = Command::new("cargo");
    cmd.arg("rustc")
        .arg("-p")
        .arg(params.crate_name)
        .arg("--lib")
        .arg("--release")
        .arg("--crate-type=dylib")
        .arg("--message-format=json-render-diagnostics")
        .arg("--manifest-path")
        .arg(params.manifest_path)
        .arg("--target-dir")
        .arg(params.target_dir);

    for f in params.features {
        cmd.arg("--features").arg(f);
    }

    // `-C prefer-dynamic` is required for compile-time-link consumers
    // (per the spike's findings). RUSTFLAGS is part of cargo's
    // fingerprint hash; a dedicated target dir avoids thrashing the
    // adopter's normal `cargo build` cache.
    let prior_rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
    let new_rustflags = if prior_rustflags.is_empty() {
        "-C prefer-dynamic".to_string()
    } else {
        format!("{prior_rustflags} -C prefer-dynamic")
    };
    cmd.env("RUSTFLAGS", &new_rustflags);

    // Format the invocation for diagnostics. The Command's
    // shape (program + args + RUSTFLAGS env) is mirrored so the adopter
    // can paste it into a shell verbatim.
    let invocation = format!(
        "RUSTFLAGS={:?} cargo rustc -p {} --lib --release --crate-type=dylib \
         --message-format=json-render-diagnostics --manifest-path {:?} --target-dir {:?}{}",
        new_rustflags,
        params.crate_name,
        params.manifest_path,
        params.target_dir,
        if params.features.is_empty() {
            String::new()
        } else {
            format!(" --features {}", params.features.join(","))
        }
    );

    let output = cmd.output().map_err(|e| Error::SubprocessSpawn {
        program: "cargo".into(),
        source: e,
    })?;

    if !output.status.success() {
        return Err(Error::Session(Outcome::DylibBuildFailed {
            invocation: invocation.clone(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        }));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);

    let dylib_path = parse_dylib_path(&stdout, params.crate_name).ok_or_else(|| {
        Error::Session(Outcome::DylibNotFound {
            invocation: invocation.clone(),
            crate_name: params.crate_name.to_string(),
        })
    })?;

    // Cargo emits the dylib at `<target>/release/lib<crate>.so` AND
    // hard-links a copy into `<target>/release/deps/lib<crate>.so`.
    // Fixtures need the deps dir on `-L dependency=` so transitive
    // crates resolve; the path points at deps/ rather than the release/ root.
    let deps_dir = dylib_path
        .parent()
        .map(|p| p.join("deps"))
        .unwrap_or_else(|| params.target_dir.join("release/deps"));

    // Toolchain shape is captured separately for drift checks;
    // recorded on the output for rendering.
    let _ = params.toolchain;

    Ok(BuildOutput {
        cargo_dylib_path: dylib_path,
        deps_dir,
        invocation,
    })
}

/// One `compiler-artifact` JSON message line. Cargo emits one per
/// crate it built; the target is the one whose package identity matches
/// the dylib_crate AND whose target crate-types include `"dylib"`.
#[derive(Debug, Deserialize)]
struct CompilerArtifact {
    reason: String,
    #[serde(default)]
    package_id: Option<String>,
    target: ArtifactTarget,
    filenames: Vec<PathBuf>,
}

#[derive(Debug, Deserialize)]
struct ArtifactTarget {
    name: String,
    #[serde(default)]
    kind: Vec<String>,
    #[serde(default)]
    crate_types: Vec<String>,
}

/// Parse the `--message-format=json-render-diagnostics` stdout stream
/// for the cargo invocation and recover the dylib path matching
/// `crate_name`.
///
/// the policy: "lihaaf finds the `compiler-artifact` message whose
/// package identity equals `dylib_crate` and whose `target.crate_types`
/// includes `"dylib"`, reads the `filenames` array, and selects the first
/// entry matching the platform's dynamic-library extension. Cargo streams
/// from older or synthetic callers may omit `package_id`; when absent,
/// `target.name` is used as the package identity substitute.
/// If multiple `compiler-artifact` messages match, the last one wins.
pub fn parse_dylib_path(stdout: &str, crate_name: &str) -> Option<PathBuf> {
    let extensions = dylib_extensions();
    let mut last_match: Option<PathBuf> = None;
    for line in stdout.lines() {
        if !line.starts_with('{') {
            continue;
        }
        let artifact: CompilerArtifact = match serde_json::from_str(line) {
            Ok(a) => a,
            Err(_) => continue,
        };
        if artifact.reason != "compiler-artifact" {
            continue;
        }
        if !artifact_matches_crate(&artifact, crate_name) {
            continue;
        }
        if !artifact_is_dylib(&artifact.target) {
            continue;
        }
        // Walk filenames; the first whose extension matches wins for
        // this artifact. Cargo orders them deterministically.
        for filename in &artifact.filenames {
            if let Some(ext) = filename.extension().and_then(|e| e.to_str())
                && extensions.contains(&ext)
            {
                last_match = Some(filename.clone());
                break;
            }
        }
    }
    last_match
}

fn artifact_matches_crate(artifact: &CompilerArtifact, crate_name: &str) -> bool {
    if let Some(package_id) = artifact.package_id.as_deref() {
        return package_id_matches_crate(package_id, crate_name);
    }

    artifact.target.name == crate_name
}

fn artifact_is_dylib(target: &ArtifactTarget) -> bool {
    if !target.crate_types.is_empty() {
        return target.crate_types.iter().any(|k| k == "dylib");
    }

    target.kind.iter().any(|k| k == "dylib")
}

fn package_id_matches_crate(package_id: &str, crate_name: &str) -> bool {
    let Some((source, suffix)) = package_id.rsplit_once('#') else {
        return false;
    };
    if package_fragment_matches_crate(suffix, crate_name) {
        return true;
    }

    let source_tail = source
        .trim_end_matches('/')
        .rsplit('/')
        .next()
        .unwrap_or_default();
    package_fragment_matches_crate(source_tail, crate_name)
}

fn package_fragment_matches_crate(fragment: &str, crate_name: &str) -> bool {
    fragment == crate_name
        || fragment.replace('-', "_") == crate_name
        || fragment
            .strip_prefix(crate_name)
            .is_some_and(|rest| rest.starts_with('@'))
}

/// Per-platform dynamic library extensions (no leading dot).
pub fn dylib_extensions() -> &'static [&'static str] {
    if cfg!(target_os = "linux") || cfg!(target_os = "android") {
        &["so"]
    } else if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
        &["dylib"]
    } else if cfg!(target_os = "windows") {
        &["dll"]
    } else {
        // Other Unixes typically use `.so`. Falling through here is
        // honest; an adopter who targets one will surface the failure
        // mode rather than getting a silent wrong guess.
        &["so"]
    }
}

/// Where the lihaaf-managed copy lives.
///
/// `target/lihaaf/lib<crate>-current-<hash>.so` per the policy, with
/// `<hash>` recovered from the cargo-emitted filename
/// (`lib<crate>-<hash>.so`). If the filename doesn't carry a hash
/// (synthetic test paths), `0` is substituted.
pub fn managed_dylib_path(workspace_target: &Path, cargo_dylib: &Path) -> PathBuf {
    let lihaaf_dir = workspace_target.join("lihaaf");
    let stem = cargo_dylib
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("lib");
    let ext = cargo_dylib
        .extension()
        .and_then(|s| s.to_str())
        .unwrap_or("so");
    // `lib<crate>-<hash>` → `<crate>` + `<hash>`.
    let (crate_part, hash_part) = match stem.strip_prefix("lib") {
        Some(rest) => match rest.rfind('-') {
            Some(idx) => (&rest[..idx], &rest[idx + 1..]),
            None => (rest, "0"),
        },
        None => (stem, "0"),
    };
    lihaaf_dir.join(format!("lib{crate_part}-current-{hash_part}.{ext}"))
}

/// Copy the cargo-emitted dylib to the lihaaf-managed location.
/// the policy: copy is unconditional on every session start; the implementer
/// chooses the file-copy primitive (here: `std::fs::copy`).
pub fn copy_dylib(cargo_dylib: &Path, managed: &Path) -> Result<(), Error> {
    if let Some(parent) = managed.parent() {
        std::fs::create_dir_all(parent).map_err(|e| {
            Error::io(
                e,
                "creating managed dylib parent",
                Some(parent.to_path_buf()),
            )
        })?;
    }
    // Remove any prior file/symlink at the destination to avoid
    // silently overwriting a symlink with a copy or vice versa.
    // The race-free cascade replaces the previous stat-then-act
    // `exists() || symlink_metadata().is_ok()` shape, which had two
    // independent bugs: a TOCTOU window between the check and the
    // removal, and a Windows-side dir-symlink case where
    // DeleteFileW returns PermissionDenied for directories.
    crate::util::remove_path_race_free(managed, "prior managed dylib")?;
    std::fs::copy(cargo_dylib, managed).map_err(|e| {
        Error::io(
            e,
            "copying cargo dylib to managed location",
            Some(managed.to_path_buf()),
        )
    })?;
    Ok(())
}

/// Symlink the cargo-emitted dylib at the lihaaf-managed location.
/// the policy `--use-symlink` opt-in. Unsafe-by-default — the
/// caller asserts no concurrent cargo build will modify `target/`.
pub fn symlink_dylib(cargo_dylib: &Path, managed: &Path) -> Result<(), Error> {
    if let Some(parent) = managed.parent() {
        std::fs::create_dir_all(parent).map_err(|e| {
            Error::io(
                e,
                "creating managed dylib parent",
                Some(parent.to_path_buf()),
            )
        })?;
    }
    // Same race-free cascade as `copy_dylib` — see that function's
    // comment for the rationale.
    crate::util::remove_path_race_free(managed, "prior managed dylib")?;
    #[cfg(unix)]
    {
        std::os::unix::fs::symlink(cargo_dylib, managed)
            .map_err(|e| Error::io(e, "symlinking cargo dylib", Some(managed.to_path_buf())))?;
    }
    #[cfg(windows)]
    {
        std::os::windows::fs::symlink_file(cargo_dylib, managed)
            .map_err(|e| Error::io(e, "symlinking cargo dylib", Some(managed.to_path_buf())))?;
    }
    #[cfg(not(any(unix, windows)))]
    {
        // Fall back to a copy on platforms with no symlink primitive.
        // Honest: symlink was attempted and fell through.
        copy_dylib(cargo_dylib, managed)?;
    }
    Ok(())
}

/// Read the mtime of a file as Unix seconds.
pub fn mtime_unix_secs(path: &Path) -> Result<i64, Error> {
    let meta = std::fs::metadata(path)
        .map_err(|e| Error::io(e, "stat file for mtime", Some(path.to_path_buf())))?;
    let mtime = meta
        .modified()
        .map_err(|e| Error::io(e, "reading mtime", Some(path.to_path_buf())))?;
    let dur = mtime
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default();
    Ok(dur.as_secs() as i64)
}

/// Resolve the workspace target directory from the consumer manifest's
/// directory + `target/`. Adopters with a custom `CARGO_TARGET_DIR` get
/// honored — env wins.
pub fn workspace_target_dir(manifest_path: &Path) -> PathBuf {
    if let Ok(env_dir) = std::env::var("CARGO_TARGET_DIR")
        && !env_dir.is_empty()
    {
        return PathBuf::from(env_dir);
    }
    let crate_dir = manifest_path
        .parent()
        .map(|p| p.to_path_buf())
        .unwrap_or_else(|| PathBuf::from("."));
    crate_dir.join("target")
}

/// Per-suite cargo target directory under `<workspace_target>/`.
///
/// The default suite uses `<workspace_target>/lihaaf-build` (kept stable
/// so adopters who never add a named suite see no cache-key change).
/// Named suites
/// use `<workspace_target>/lihaaf-build-<suite>` so each suite's
/// `--features` set lives in its own cargo cache and never thrashes a
/// sibling suite's build artifacts. Suite names are validated by
/// [`crate::config::parse`] to contain only ASCII alphanumerics, hyphens,
/// and underscores, so the substitution is filename-safe on every
/// supported platform.
pub fn build_dir_for_suite(workspace_target: &Path, suite_name: &str) -> PathBuf {
    if suite_name == crate::config::DEFAULT_SUITE_NAME {
        workspace_target.join("lihaaf-build")
    } else {
        workspace_target.join(format!("lihaaf-build-{suite_name}"))
    }
}

/// True when the build params look usable. Cheap pre-flight check.
#[allow(dead_code)]
pub fn validate_params(_params: &BuildParams<'_>, _suite: &Suite) -> Result<(), Error> {
    // Reserved for future invariants (e.g., dylib_crate is in the
    // workspace's metadata). v0.1 leaves this as a no-op; cargo itself
    // rejects unknown -p targets clearly.
    Ok(())
}

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

    #[test]
    fn parse_dylib_path_picks_dylib_crate_type_and_extension() {
        // Linux: `.so`. The test bakes an artifact line; the parser must pick
        // the first `.so` listed in `filenames`.
        #[cfg(target_os = "linux")]
        let line = r#"{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{"name":"consumer","kind":["lib"],"crate_types":["rlib","dylib"]},"filenames":["/p/target/release/deps/libconsumer-abc.so"]}"#;
        #[cfg(target_os = "macos")]
        let line = r#"{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{"name":"consumer","kind":["lib"],"crate_types":["rlib","dylib"]},"filenames":["/p/target/release/deps/libconsumer-abc.dylib"]}"#;
        #[cfg(target_os = "windows")]
        let line = r#"{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{"name":"consumer","kind":["lib"],"crate_types":["rlib","dylib"]},"filenames":["C:/p/target/release/deps/consumer-abc.dll"]}"#;
        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
        let line = r#"{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{"name":"consumer","kind":["lib"],"crate_types":["rlib","dylib"]},"filenames":["/p/target/release/deps/libconsumer-abc.so"]}"#;

        let path = parse_dylib_path(line, "consumer").unwrap();
        assert!(path.to_string_lossy().contains("consumer-abc"));
    }

    #[test]
    fn parse_dylib_path_skips_unrelated_artifacts() {
        let stream = "\
{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"unrelated\",\"kind\":[\"lib\"]},\"filenames\":[\"/p/x.rlib\"]}
{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"consumer\",\"kind\":[\"lib\"]},\"filenames\":[\"/p/libconsumer.rlib\"]}
{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"consumer\",\"kind\":[\"dylib\"]},\"filenames\":[\"/p/libconsumer-abc.so\"]}
";
        // On non-Linux the test still expects the dylib kind to be picked
        // — the extension match guards platform-correctness in the real
        // path, but for parser unit tests `.so` is treated as accepted
        // because the test fixture string says so. Skip on Windows.
        #[cfg(target_os = "windows")]
        let _ = stream;
        #[cfg(not(target_os = "windows"))]
        {
            let p = parse_dylib_path(stream, "consumer").unwrap();
            assert!(p.to_string_lossy().ends_with(".so"));
        }
    }

    #[test]
    fn parse_dylib_path_can_match_package_id_when_lib_target_name_differs() {
        let stream = r#"{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{"name":"consumer_lib","kind":["lib"],"crate_types":["rlib","dylib"]},"filenames":["/p/libconsumer_lib-abc.so"]}"#;
        #[cfg(target_os = "windows")]
        let _ = stream;
        #[cfg(not(target_os = "windows"))]
        {
            let p = parse_dylib_path(stream, "consumer").unwrap();
            assert!(p.to_string_lossy().ends_with(".so"));
        }
    }

    #[test]
    fn parse_dylib_path_prefers_package_id_over_target_name_collision() {
        let ext = dylib_extensions()[0];
        let stream = format!(
            "\
{{\"reason\":\"compiler-artifact\",\"package_id\":\"path+file:///p#consumer@0.1.0\",\"target\":{{\"name\":\"consumer_lib\",\"kind\":[\"lib\"],\"crate_types\":[\"rlib\",\"dylib\"]}},\"filenames\":[\"/p/libconsumer_lib-right.{ext}\"]}}
{{\"reason\":\"compiler-artifact\",\"package_id\":\"path+file:///p#other@0.1.0\",\"target\":{{\"name\":\"consumer\",\"kind\":[\"lib\"],\"crate_types\":[\"rlib\",\"dylib\"]}},\"filenames\":[\"/p/libconsumer-wrong.{ext}\"]}}
"
        );

        let p = parse_dylib_path(&stream, "consumer").unwrap();
        assert!(p.to_string_lossy().contains("right"));
    }

    #[test]
    fn parse_dylib_path_uses_crate_types_when_cargo_provides_them() {
        let ext = dylib_extensions()[0];
        let stream = format!(
            r#"{{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{{"name":"consumer","kind":["dylib"],"crate_types":["rlib"]}},"filenames":["/p/libconsumer-not-dylib.{ext}"]}}"#
        );

        assert!(parse_dylib_path(&stream, "consumer").is_none());
    }

    #[test]
    fn parse_dylib_path_matches_path_package_id_source_tail() {
        let ext = dylib_extensions()[0];
        let stream = format!(
            r#"{{"reason":"compiler-artifact","package_id":"path+file:///workspace/consumer#0.1.0","target":{{"name":"consumer","kind":["dylib"],"crate_types":["dylib"]}},"filenames":["/p/libconsumer.{ext}"]}}"#
        );

        let p = parse_dylib_path(&stream, "consumer").unwrap();
        assert!(p.to_string_lossy().contains("libconsumer"));
    }

    #[test]
    fn managed_dylib_path_preserves_hash() {
        let p = managed_dylib_path(
            Path::new("/p/target"),
            Path::new("/p/target/release/deps/libconsumer-abc123.so"),
        );
        assert!(p.ends_with("libconsumer-current-abc123.so"));
        assert!(p.starts_with("/p/target/lihaaf"));
    }

    #[test]
    fn managed_dylib_path_handles_missing_hash() {
        let p = managed_dylib_path(
            Path::new("/p/target"),
            Path::new("/p/target/release/deps/libconsumer.so"),
        );
        assert!(p.ends_with("libconsumer-current-0.so"));
    }

    #[test]
    fn build_dir_for_default_suite_uses_default_name() {
        let p = build_dir_for_suite(Path::new("/p/target"), crate::config::DEFAULT_SUITE_NAME);
        // Single-suite adopters hash against the unsuffixed cargo target
        // dir for cache-key stability.
        assert_eq!(p, PathBuf::from("/p/target/lihaaf-build"));
    }

    #[test]
    fn build_dir_for_named_suite_includes_name() {
        let p = build_dir_for_suite(Path::new("/p/target"), "spatial");
        assert_eq!(p, PathBuf::from("/p/target/lihaaf-build-spatial"));
    }
}
