//! Validated, shell-free execution of the release CI matrix.

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};

mod api_surface;
mod artifact;
mod ci_gate;
mod dependency;
mod documentation;
mod provenance;
mod release_candidate;

pub use api_surface::{audit_api_surface, write_api_baseline};
pub use artifact::audit_artifacts;
pub use ci_gate::{audit_consolidated_ci, run_release_gate, run_required_gate};
pub use dependency::audit_dependencies;
pub use documentation::{audit_documentation, write_documentation_report};
pub use provenance::{audit_provenance, write_provenance_reports};
pub use release_candidate::audit_release_candidate;

pub const MATRIX_PATH: &str = "ci/release-matrix.json";
const WORKFLOW_PATH: &str = ".gitea/workflows/release.yml";
const REQUIRED_PROFILES: [&str; 7] = [
    "linux-msrv-portable",
    "linux-stable-default",
    "linux-stable-minimal",
    "linux-stable-features",
    "linux-stable-release-surface",
    "windows-stable-portable",
    "macos-stable-portable",
];
const REQUIRED_FEATURE_SETS: [&str; 11] = [
    "all-features",
    "dds-bc67",
    "default",
    "docs",
    "examples",
    "jpeg2000",
    "no-default-features",
    "real-audio",
    "skia",
    "tests",
    "vorbis",
];
const REQUIRED_FEATURES: [(&str, &str); 6] = [
    ("libremetaverse", "dds-bc67"),
    ("libremetaverse", "jpeg2000"),
    ("libremetaverse", "vorbis"),
    ("libremetaverse-imaging", "jpeg2000"),
    ("libremetaverse-imaging-skia", "skia"),
    ("libremetaverse-voice-webrtc", "real-audio"),
];

#[derive(Debug)]
pub struct MatrixError(String);

impl MatrixError {
    fn new(message: impl Into<String>) -> Self {
        Self(message.into())
    }
}

impl fmt::Display for MatrixError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.0)
    }
}

impl std::error::Error for MatrixError {}

impl From<std::io::Error> for MatrixError {
    fn from(error: std::io::Error) -> Self {
        Self::new(error.to_string())
    }
}

impl From<serde_json::Error> for MatrixError {
    fn from(error: serde_json::Error) -> Self {
        Self::new(error.to_string())
    }
}

pub type Result<T> = std::result::Result<T, MatrixError>;

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReleaseMatrix {
    pub schema: u32,
    pub msrv: String,
    pub current: String,
    pub native_prerequisites: Vec<NativePrerequisite>,
    pub manual_gates: Vec<String>,
    pub profiles: Vec<Profile>,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct NativePrerequisite {
    pub id: String,
    pub version: String,
    pub purpose: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Profile {
    pub id: String,
    pub platform: String,
    pub host: String,
    pub execution: String,
    pub toolchain: String,
    pub target: String,
    pub feature_sets: Vec<String>,
    pub prerequisites: Vec<String>,
    pub commands: Vec<CargoInvocation>,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CargoInvocation {
    pub label: String,
    pub args: Vec<String>,
}

#[derive(Debug, Serialize)]
struct Evidence<'a> {
    schema: u32,
    profile: &'a str,
    platform: &'a str,
    host: &'a str,
    execution: &'a str,
    target: &'a str,
    requested_toolchain: &'a str,
    rustc: String,
    cargo: String,
    source_commit: String,
    recorded_unix_seconds: u64,
    feature_sets: &'a [String],
    native_prerequisites: Vec<&'a NativePrerequisite>,
    commands: &'a [CargoInvocation],
    completed_commands: usize,
    status: &'static str,
}

#[must_use]
pub fn workspace_root(start: &Path) -> Option<PathBuf> {
    start.ancestors().find_map(|directory| {
        let manifest = directory.join("Cargo.toml");
        fs::read_to_string(&manifest)
            .ok()
            .filter(|contents| contents.contains("[workspace]"))
            .map(|_| directory.to_path_buf())
    })
}

/// Loads the canonical release matrix from a workspace root.
///
/// # Errors
///
/// Returns an error when the matrix cannot be read or is not valid JSON.
pub fn load(root: &Path) -> Result<ReleaseMatrix> {
    let bytes = fs::read(root.join(MATRIX_PATH))?;
    Ok(serde_json::from_slice(&bytes)?)
}

/// Validates the matrix, Cargo feature declarations, and Gitea workflow coverage.
///
/// # Errors
///
/// Returns an error when any release invariant is missing or inconsistent.
pub fn audit(root: &Path, matrix: &ReleaseMatrix) -> Result<()> {
    audit_shape(matrix)?;
    audit_cargo_features(root)?;
    audit_workflows(root, matrix)?;
    Ok(())
}

/// Validates the platform, toolchain, feature, and command shape of a matrix.
///
/// # Errors
///
/// Returns an error when a required profile, prerequisite, or gate is absent.
pub fn audit_shape(matrix: &ReleaseMatrix) -> Result<()> {
    if matrix.schema != 1 {
        return Err(MatrixError::new("release matrix schema must be 1"));
    }
    if matrix.msrv != "1.96.0" || matrix.current != "stable" {
        return Err(MatrixError::new(
            "release matrix must cover Rust 1.96.0 and current stable",
        ));
    }

    let prerequisite_ids = unique(
        matrix
            .native_prerequisites
            .iter()
            .map(|prerequisite| prerequisite.id.as_str()),
        "native prerequisite",
    )?;
    if matrix.native_prerequisites.iter().any(|prerequisite| {
        prerequisite.version.trim().is_empty() || prerequisite.purpose.trim().is_empty()
    }) {
        return Err(MatrixError::new(
            "native prerequisites require exact versions and purposes",
        ));
    }
    let manual = matrix.manual_gates.join(" ").to_ascii_lowercase();
    for boundary in ["live grid", "physical audio", "proprietary"] {
        if !manual.contains(boundary) {
            return Err(MatrixError::new(format!(
                "manual gate boundary is missing {boundary}"
            )));
        }
    }

    let profile_ids = unique(
        matrix.profiles.iter().map(|profile| profile.id.as_str()),
        "profile",
    )?;
    for required in REQUIRED_PROFILES {
        if !profile_ids.contains(required) {
            return Err(MatrixError::new(format!(
                "required profile {required} is missing"
            )));
        }
    }

    let (platforms, toolchains, feature_sets) = audit_profiles(matrix, &prerequisite_ids)?;
    audit_release_surface(matrix)?;
    if platforms != BTreeSet::from(["linux", "macos", "windows"])
        || !toolchains.contains("stable")
        || !toolchains.contains("1.96.0")
    {
        return Err(MatrixError::new(
            "matrix must cover Linux, macOS, Windows, MSRV, and stable",
        ));
    }
    for required in REQUIRED_FEATURE_SETS {
        if !feature_sets.contains(required) {
            return Err(MatrixError::new(format!(
                "feature/test surface {required} is missing"
            )));
        }
    }
    Ok(())
}

fn audit_release_surface(matrix: &ReleaseMatrix) -> Result<()> {
    let profile = matrix
        .profiles
        .iter()
        .find(|profile| profile.id == "linux-stable-release-surface")
        .ok_or_else(|| MatrixError::new("release-surface profile is missing"))?;
    let builds_programs = profile.commands.iter().any(|invocation| {
        invocation
            .args
            .first()
            .is_some_and(|argument| argument == "build")
            && invocation
                .args
                .windows(2)
                .any(|pair| pair == ["-p", "libremetaverse-programs"])
            && invocation.args.iter().any(|argument| argument == "--bins")
    });
    if !builds_programs {
        return Err(MatrixError::new(
            "release-surface profile must build the shipped example-program binaries",
        ));
    }
    Ok(())
}

fn audit_profiles<'a>(
    matrix: &'a ReleaseMatrix,
    prerequisite_ids: &BTreeSet<&str>,
) -> Result<(BTreeSet<&'a str>, BTreeSet<&'a str>, BTreeSet<&'a str>)> {
    let mut platforms = BTreeSet::new();
    let mut toolchains = BTreeSet::new();
    let mut feature_sets = BTreeSet::new();
    for profile in &matrix.profiles {
        if profile.host != "ubuntu-latest" {
            return Err(MatrixError::new(format!(
                "profile {} violates the ubuntu-latest Gitea constraint",
                profile.id
            )));
        }
        if !matches!(profile.platform.as_str(), "linux" | "windows" | "macos") {
            return Err(MatrixError::new(format!(
                "profile {} has an unsupported platform",
                profile.id
            )));
        }
        let expected_execution = if profile.platform == "linux" {
            "native"
        } else {
            "cross"
        };
        if profile.execution != expected_execution {
            return Err(MatrixError::new(format!(
                "profile {} must use {expected_execution} execution",
                profile.id
            )));
        }
        if profile.target.trim().is_empty() || profile.commands.is_empty() {
            return Err(MatrixError::new(format!(
                "profile {} needs a target and at least one command",
                profile.id
            )));
        }
        for prerequisite in &profile.prerequisites {
            if !prerequisite_ids.contains(prerequisite.as_str()) {
                return Err(MatrixError::new(format!(
                    "profile {} references unknown prerequisite {prerequisite}",
                    profile.id
                )));
            }
        }
        for invocation in &profile.commands {
            audit_invocation(profile, invocation)?;
        }
        platforms.insert(profile.platform.as_str());
        toolchains.insert(profile.toolchain.as_str());
        feature_sets.extend(profile.feature_sets.iter().map(String::as_str));
    }
    Ok((platforms, toolchains, feature_sets))
}

fn unique<'a>(values: impl Iterator<Item = &'a str>, kind: &str) -> Result<BTreeSet<&'a str>> {
    let mut unique = BTreeSet::new();
    for value in values {
        if value.trim().is_empty() || !unique.insert(value) {
            return Err(MatrixError::new(format!(
                "{kind} identifiers must be nonempty and unique"
            )));
        }
    }
    Ok(unique)
}

fn audit_invocation(profile: &Profile, invocation: &CargoInvocation) -> Result<()> {
    let Some(subcommand) = invocation.args.first().map(String::as_str) else {
        return Err(MatrixError::new(format!(
            "profile {} contains an empty Cargo command",
            profile.id
        )));
    };
    if !matches!(subcommand, "check" | "test" | "doc" | "build") {
        return Err(MatrixError::new(format!(
            "profile {} uses disallowed Cargo subcommand {subcommand}",
            profile.id
        )));
    }
    if invocation.label.trim().is_empty()
        || !invocation
            .args
            .iter()
            .any(|argument| argument == "--locked")
        || !invocation.args.windows(2).any(|pair| pair == ["-j", "1"])
    {
        return Err(MatrixError::new(format!(
            "profile {} commands need labels, --locked, and -j 1",
            profile.id
        )));
    }
    if profile.execution == "cross"
        && !invocation
            .args
            .windows(2)
            .any(|pair| pair == ["--target", profile.target.as_str()])
    {
        return Err(MatrixError::new(format!(
            "cross profile {} command does not select its target",
            profile.id
        )));
    }
    Ok(())
}

fn audit_cargo_features(root: &Path) -> Result<()> {
    let output = Command::new(cargo_program())
        .args(["metadata", "--no-deps", "--format-version", "1"])
        .current_dir(root)
        .output()?;
    if !output.status.success() {
        return Err(MatrixError::new(
            "cargo metadata failed during matrix audit",
        ));
    }
    let metadata: Value = serde_json::from_slice(&output.stdout)?;
    let packages = metadata["packages"]
        .as_array()
        .ok_or_else(|| MatrixError::new("cargo metadata has no package array"))?;
    let feature_map = packages
        .iter()
        .filter_map(|package| Some((package["name"].as_str()?, package["features"].as_object()?)))
        .collect::<BTreeMap<_, _>>();
    for (package, feature) in REQUIRED_FEATURES {
        if !feature_map
            .get(package)
            .is_some_and(|features| features.contains_key(feature))
        {
            return Err(MatrixError::new(format!(
                "Cargo feature {package}/{feature} is missing"
            )));
        }
    }
    let defaults = feature_map
        .get("libremetaverse")
        .and_then(|features| features.get("default"))
        .and_then(Value::as_array)
        .ok_or_else(|| MatrixError::new("libremetaverse default feature set is missing"))?;
    if defaults.iter().any(|feature| {
        matches!(
            feature.as_str(),
            Some("jpeg2000" | "vorbis" | "dep:vorbis_rs")
        )
    }) {
        return Err(MatrixError::new(
            "default consumers must not enable native OpenJPEG or Vorbis adapters",
        ));
    }
    let default_tree = Command::new(cargo_program())
        .args([
            "tree",
            "--locked",
            "-p",
            "libremetaverse",
            "--edges",
            "normal,build",
            "--prefix",
            "none",
        ])
        .current_dir(root)
        .output()?;
    if !default_tree.status.success() {
        return Err(MatrixError::new(
            "cargo tree failed during default dependency audit",
        ));
    }
    let default_tree = String::from_utf8_lossy(&default_tree.stdout);
    for forbidden in [
        "libremetaverse-openjpeg ",
        "vorbis_rs ",
        "ogg_next_sys ",
        "aotuv_lancer_vorbis_sys ",
    ] {
        if default_tree.lines().any(|line| line.starts_with(forbidden)) {
            return Err(MatrixError::new(format!(
                "default consumer dependency graph unexpectedly contains {forbidden}"
            )));
        }
    }
    Ok(())
}

fn audit_workflows(root: &Path, matrix: &ReleaseMatrix) -> Result<()> {
    let workflow = fs::read_to_string(root.join(WORKFLOW_PATH))?;
    if matrix.profiles.is_empty()
        || !workflow.contains("release-gate")
        || !workflow.contains("~/.cargo/registry")
        || !workflow.contains("~/.cargo/git")
    {
        return Err(MatrixError::new(
            "release workflow must invoke the Rust gate and cache Cargo downloads",
        ));
    }
    audit_consolidated_ci(root)?;
    let workflows = root.join(".gitea/workflows");
    for entry in fs::read_dir(workflows)? {
        let path = entry?.path();
        if !matches!(
            path.extension().and_then(OsStr::to_str),
            Some("yml" | "yaml")
        ) {
            continue;
        }
        let contents = fs::read_to_string(&path)?.to_ascii_lowercase();
        if contents.contains("runs-on: windows") || contents.contains("runs-on: macos") {
            return Err(MatrixError::new(format!(
                "{} violates the ubuntu-only Gitea runner policy",
                path.display()
            )));
        }
    }
    Ok(())
}

/// Executes one profile from an empty target directory and writes its evidence record.
///
/// # Errors
///
/// Returns an error when the profile is unknown, its toolchain is incorrect, the
/// target directory is not clean, a command fails, or evidence cannot be written.
pub fn run(root: &Path, matrix: &ReleaseMatrix, profile_id: &str, evidence: &Path) -> Result<()> {
    audit(root, matrix)?;
    let profile = matrix
        .profiles
        .iter()
        .find(|profile| profile.id == profile_id)
        .ok_or_else(|| MatrixError::new(format!("unknown CI profile {profile_id}")))?;
    if evidence.exists() {
        return Err(MatrixError::new(format!(
            "{} already exists; preserve or remove it before running the profile",
            evidence.display()
        )));
    }
    let rustc = version("rustc")?;
    let cargo = version(cargo_program())?;
    if profile.toolchain != "stable"
        && rustc
            .split_whitespace()
            .nth(1)
            .is_none_or(|version| version != profile.toolchain)
    {
        return Err(MatrixError::new(format!(
            "profile {} requires rustc {}, found {rustc}",
            profile.id, profile.toolchain
        )));
    }
    let target_directory = root.join("target/ci").join(&profile.id);
    if target_directory.exists() {
        return Err(MatrixError::new(format!(
            "{} already exists; clean it before rerunning so cache cannot mask the build",
            target_directory.display()
        )));
    }

    let source_commit = command_line("git", ["rev-parse", "--verify", "HEAD"], root)?;
    let prerequisites = profile
        .prerequisites
        .iter()
        .map(|id| {
            matrix
                .native_prerequisites
                .iter()
                .find(|prerequisite| prerequisite.id == *id)
                .ok_or_else(|| MatrixError::new(format!("unknown prerequisite {id}")))
        })
        .collect::<Result<Vec<_>>>()?;
    let mut completed = 0;
    let mut failure = None;
    for invocation in &profile.commands {
        println!("ci-matrix {}: {}", profile.id, invocation.label);
        let status = Command::new(cargo_program())
            .args(&invocation.args)
            .current_dir(root)
            .env("CARGO_INCREMENTAL", "0")
            .env("CARGO_TARGET_DIR", &target_directory)
            .stdin(Stdio::null())
            .status()?;
        if status.success() {
            completed += 1;
        } else {
            failure = Some(status);
            break;
        }
    }
    let status = if failure.is_none() { "ok" } else { "failed" };
    write_evidence(
        evidence,
        &Evidence {
            schema: 1,
            profile: &profile.id,
            platform: &profile.platform,
            host: &profile.host,
            execution: &profile.execution,
            target: &profile.target,
            requested_toolchain: &profile.toolchain,
            rustc,
            cargo,
            source_commit,
            recorded_unix_seconds: SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map_err(|_| MatrixError::new("system clock predates Unix epoch"))?
                .as_secs(),
            feature_sets: &profile.feature_sets,
            native_prerequisites: prerequisites,
            commands: &profile.commands,
            completed_commands: completed,
            status,
        },
    )?;
    if let Some(status) = failure {
        return Err(MatrixError::new(format!(
            "profile {} failed with {}",
            profile.id,
            describe_status(status)
        )));
    }
    Ok(())
}

fn version(program: impl AsRef<OsStr>) -> Result<String> {
    command_line(program, ["--version"], Path::new("."))
}

fn command_line(
    program: impl AsRef<OsStr>,
    args: impl IntoIterator<Item = impl AsRef<OsStr>>,
    directory: &Path,
) -> Result<String> {
    let output = Command::new(program)
        .args(args)
        .current_dir(directory)
        .output()?;
    if !output.status.success() {
        return Err(MatrixError::new("version/provenance command failed"));
    }
    String::from_utf8(output.stdout)
        .map(|value| value.trim().to_owned())
        .map_err(|_| MatrixError::new("version/provenance output was not UTF-8"))
}

fn cargo_program() -> std::ffi::OsString {
    std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())
}

fn write_evidence(path: &Path, evidence: &Evidence<'_>) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
    serde_json::to_writer_pretty(&mut file, evidence)?;
    file.write_all(b"\n")?;
    file.sync_all()?;
    Ok(())
}

fn describe_status(status: ExitStatus) -> String {
    status.code().map_or_else(
        || "termination by signal".to_owned(),
        |code| format!("exit {code}"),
    )
}

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

    #[test]
    fn checked_in_matrix_has_complete_shape() {
        let matrix: ReleaseMatrix =
            serde_json::from_str(include_str!("../../../ci/release-matrix.json")).unwrap();
        audit_shape(&matrix).unwrap();
    }

    #[test]
    fn runner_rejects_unknown_profiles_before_execution() {
        let root = workspace_root(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap();
        let matrix = load(&root).unwrap();
        let evidence = std::env::temp_dir().join("metacrate-ci-matrix-unknown.json");
        assert!(run(&root, &matrix, "not-a-profile", &evidence).is_err());
        assert!(!evidence.exists());
    }
}
