//! Canonical editor sample-pack manifest and validation.

use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::{Component, Path, PathBuf};

use serde::{Deserialize, Serialize};
use shared::{
    ActorKind, BrushDesc, ColliderDesc, EditorVisibility, HierarchySiblingIndex, LevelObject,
    LightDesc, MaterialDesc, PostProcessVolumeDesc, Primitive, RigidBodyDesc, TerrainDesc,
    COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC,
    COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PRIMITIVE, COMPONENT_RIGID_BODY_DESC,
    COMPONENT_TERRAIN_DESC,
};

use crate::{
    document::{SceneDocument, TRANSFORM_COMPONENT},
    ProjectDependency, ProjectValidationFinding, ProjectValidationReport, ValidationSeverity,
};

pub const EDITOR_SAMPLE_MANIFEST_PATH: &str = "assets/samples/editor_samples.ron";
pub const EDITOR_SAMPLE_SCHEMA_VERSION: u32 = 1;

const LEVEL_OBJECT_COMPONENT: &str = "shared::components::LevelObject";
const ACTOR_KIND_COMPONENT: &str = "shared::components::ActorKind";
const EDITOR_VISIBILITY_COMPONENT: &str = "shared::components::EditorVisibility";
const HIERARCHY_SIBLING_INDEX_COMPONENT: &str = "shared::components::HierarchySiblingIndex";
const NAME_COMPONENT: &str = "bevy_ecs::name::Name";
const REQUIRED_ACTOR_BASELINE: [(&str, &str); 3] = [
    (LEVEL_OBJECT_COMPONENT, "LevelObject"),
    (TRANSFORM_COMPONENT, "Transform"),
    (ACTOR_KIND_COMPONENT, "ActorKind"),
];

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum EditorSampleArea {
    Brush,
    Material,
    Terrain,
    PhysicsPlacement,
    Rendering,
}

impl EditorSampleArea {
    pub const ALL: [Self; 5] = [
        Self::Brush,
        Self::Material,
        Self::Terrain,
        Self::PhysicsPlacement,
        Self::Rendering,
    ];

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Brush => "Brush",
            Self::Material => "Material",
            Self::Terrain => "Terrain",
            Self::PhysicsPlacement => "Physics Placement",
            Self::Rendering => "Rendering",
        }
    }

    fn required_components(self) -> &'static [&'static str] {
        match self {
            Self::Brush => &[COMPONENT_BRUSH_DESC],
            // Materials are authored through renderer-owned slots. The Material Lab uses
            // Primitive.surface slots; actor-local MaterialDesc is a brush-only legacy path.
            Self::Material => &[COMPONENT_PRIMITIVE],
            Self::Terrain => &[COMPONENT_TERRAIN_DESC],
            Self::PhysicsPlacement => &[COMPONENT_RIGID_BODY_DESC, COMPONENT_COLLIDER_DESC],
            Self::Rendering => &[COMPONENT_POST_PROCESS_VOLUME],
        }
    }
}

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

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EditorSampleEntry {
    pub id: String,
    pub label: String,
    pub area: EditorSampleArea,
    pub scene: String,
    pub summary: String,
    pub checks: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EditorSampleManifest {
    pub schema_version: u32,
    pub samples: Vec<EditorSampleEntry>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EditorSamplePackValidation {
    pub manifest_path: String,
    pub samples: Vec<EditorSampleEntry>,
    pub report: ProjectValidationReport,
}

impl EditorSamplePackValidation {
    pub fn is_release_ready(&self) -> bool {
        self.report.is_release_ready()
    }
}

pub fn load_editor_sample_manifest(project_root: &Path) -> Result<EditorSampleManifest, String> {
    let relative_path = Path::new(EDITOR_SAMPLE_MANIFEST_PATH);
    let path = project_root.join(relative_path);
    if path_contains_symlink(project_root, relative_path) {
        return Err(format!(
            "could not read {}: manifest path contains a symlink",
            path.display()
        ));
    }
    let metadata = std::fs::symlink_metadata(&path)
        .map_err(|error| format!("could not read {}: {error}", path.display()))?;
    if metadata.file_type().is_symlink() || !metadata.is_file() {
        return Err(format!(
            "could not read {}: manifest is not a regular file",
            path.display()
        ));
    }
    let text = std::fs::read_to_string(&path)
        .map_err(|error| format!("could not read {}: {error}", path.display()))?;
    ron::from_str(&text).map_err(|error| format!("could not parse {}: {error}", path.display()))
}

pub fn validate_editor_sample_pack(project_root: &Path) -> EditorSamplePackValidation {
    let mut validation = EditorSamplePackValidation {
        manifest_path: EDITOR_SAMPLE_MANIFEST_PATH.into(),
        samples: Vec::new(),
        report: ProjectValidationReport::default(),
    };
    let manifest_relative_path = Path::new(EDITOR_SAMPLE_MANIFEST_PATH);
    let manifest_path = project_root.join(manifest_relative_path);
    if path_contains_symlink(project_root, manifest_relative_path) {
        push_finding(
            &mut validation.report,
            "sample_pack.manifest_not_regular",
            None,
            Some(EDITOR_SAMPLE_MANIFEST_PATH),
            "editor sample manifest or one of its parent directories is a symlink",
            "Replace the manifest path with regular project directories and a regular RON file.",
        );
        return validation;
    }
    match std::fs::symlink_metadata(&manifest_path) {
        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
            push_finding(
                &mut validation.report,
                "sample_pack.manifest_not_regular",
                None,
                Some(EDITOR_SAMPLE_MANIFEST_PATH),
                "editor sample manifest is not a regular project file",
                "Replace it with a regular RON file at the conventional manifest path.",
            );
            return validation;
        }
        Err(error) => {
            push_finding(
                &mut validation.report,
                "sample_pack.manifest_missing",
                None,
                Some(EDITOR_SAMPLE_MANIFEST_PATH),
                &format!("editor sample manifest could not be read: {error}"),
                "Create the schema-v1 editor sample manifest at the conventional path.",
            );
            return validation;
        }
        Ok(_) => {}
    }

    let manifest = match load_editor_sample_manifest(project_root) {
        Ok(manifest) => manifest,
        Err(error) => {
            push_finding(
                &mut validation.report,
                "sample_pack.manifest_invalid",
                None,
                Some(EDITOR_SAMPLE_MANIFEST_PATH),
                &error,
                "Repair the manifest RON and retain every required schema-v1 field.",
            );
            return validation;
        }
    };
    validation.samples = manifest.samples;

    if manifest.schema_version != EDITOR_SAMPLE_SCHEMA_VERSION {
        push_finding(
            &mut validation.report,
            "sample_pack.incompatible_schema",
            None,
            Some(&manifest.schema_version.to_string()),
            &format!(
                "editor sample schema {} is unsupported; expected {}",
                manifest.schema_version, EDITOR_SAMPLE_SCHEMA_VERSION
            ),
            "Migrate the sample manifest with the current editor before release validation.",
        );
    }

    let mut ids = BTreeSet::new();
    let mut scenes = BTreeSet::new();
    let mut areas: BTreeMap<EditorSampleArea, Vec<String>> = BTreeMap::new();
    for sample in &validation.samples {
        areas
            .entry(sample.area)
            .or_default()
            .push(sample.id.clone());
        validate_entry_fields(sample, &mut ids, &mut scenes, &mut validation.report);
        validate_entry_scene(project_root, sample, &mut validation.report);
    }
    for area in EditorSampleArea::ALL {
        match areas.get(&area).map(Vec::as_slice) {
            Some([_]) => {}
            Some(ids) => push_finding(
                &mut validation.report,
                "sample_pack.duplicate_area",
                None,
                Some(area.as_str()),
                &format!(
                    "sample area {area} must have exactly one entry; found {} ({})",
                    ids.len(),
                    ids.join(", ")
                ),
                "Keep one stable manifest entry for this sample area.",
            ),
            None => push_finding(
                &mut validation.report,
                "sample_pack.missing_area",
                None,
                Some(area.as_str()),
                &format!("sample area {area} has no manifest entry"),
                "Add one documented sample scene for this required area.",
            ),
        }
    }

    normalize_report(&mut validation.report);
    validation
}

fn validate_entry_fields(
    sample: &EditorSampleEntry,
    ids: &mut BTreeSet<String>,
    scenes: &mut BTreeSet<String>,
    report: &mut ProjectValidationReport,
) {
    if !stable_id(&sample.id) {
        push_finding(
            report,
            "sample_pack.invalid_id",
            Some(&sample.id),
            Some(&sample.id),
            "sample ID must start with a lowercase ASCII letter or digit and contain only lowercase ASCII letters, digits, '.', '-', or '_'",
            "Choose one stable lowercase identifier and do not derive it from a display label.",
        );
    } else if !ids.insert(sample.id.clone()) {
        push_finding(
            report,
            "sample_pack.duplicate_id",
            Some(&sample.id),
            Some(&sample.id),
            "sample ID appears more than once",
            "Give every sample one unique stable ID.",
        );
    }
    validate_required_text(report, sample, "label", &sample.label);
    validate_required_text(report, sample, "summary", &sample.summary);
    if sample.checks.is_empty() || sample.checks.iter().any(|check| !clean_text(check)) {
        push_finding(
            report,
            "sample_pack.invalid_checks",
            Some(&sample.id),
            Some(&sample.scene),
            "sample checks must contain at least one nonempty, whitespace-normalized instruction",
            "Record concise deterministic checks for the sample's manual regression workflow.",
        );
    }
    if !scenes.insert(sample.scene.clone()) {
        push_finding(
            report,
            "sample_pack.duplicate_scene",
            Some(&sample.id),
            Some(&sample.scene),
            "sample scene appears in more than one manifest entry",
            "Use one canonical area and manifest entry for each sample scene.",
        );
    }
}

fn validate_required_text(
    report: &mut ProjectValidationReport,
    sample: &EditorSampleEntry,
    field: &str,
    value: &str,
) {
    if !clean_text(value) {
        push_finding(
            report,
            &format!("sample_pack.invalid_{field}"),
            Some(&sample.id),
            Some(&sample.scene),
            &format!("sample {field} must be nonempty and have no surrounding whitespace"),
            &format!("Provide one stable, human-readable sample {field}."),
        );
    }
}

fn validate_entry_scene(
    project_root: &Path,
    sample: &EditorSampleEntry,
    report: &mut ProjectValidationReport,
) {
    if !safe_scene_reference(&sample.scene) {
        push_finding(
            report,
            "sample_pack.unsafe_scene_path",
            Some(&sample.id),
            Some(&sample.scene),
            "sample scene must be a project-relative .scn.ron file below assets/levels/",
            "Move the scene below assets/levels/ and use a normalized project-relative path.",
        );
        return;
    }
    report.dependencies.push(ProjectDependency {
        owner_path: EDITOR_SAMPLE_MANIFEST_PATH.into(),
        owner_actor_id: None,
        kind: "editor_sample_scene".into(),
        reference: sample.scene.clone(),
    });

    let path = project_root.join(&sample.scene);
    let metadata = match std::fs::symlink_metadata(&path) {
        Ok(metadata) => metadata,
        Err(error) => {
            push_finding(
                report,
                "sample_pack.scene_missing",
                Some(&sample.id),
                Some(&sample.scene),
                &format!("sample scene is missing or unreadable: {error}"),
                "Restore the regular scene file or update the manifest entry.",
            );
            return;
        }
    };
    if metadata.file_type().is_symlink() || !metadata.is_file() {
        push_finding(
            report,
            "sample_pack.scene_not_regular",
            Some(&sample.id),
            Some(&sample.scene),
            "sample scene is not a regular file",
            "Replace symlinks and special files with regular project scene content.",
        );
        return;
    }
    if !path_stays_within_project(project_root, &path)
        || path_contains_symlink(project_root, Path::new(&sample.scene))
    {
        push_finding(
            report,
            "sample_pack.unsafe_scene_path",
            Some(&sample.id),
            Some(&sample.scene),
            "sample scene resolves through a symlink or outside the project root",
            "Keep every sample and parent directory as regular project content.",
        );
        return;
    }

    let text = match std::fs::read_to_string(&path) {
        Ok(text) => text,
        Err(error) => {
            push_finding(
                report,
                "sample_pack.scene_unreadable",
                Some(&sample.id),
                Some(&sample.scene),
                &format!("sample scene could not be read: {error}"),
                "Restore readable UTF-8 scene content.",
            );
            return;
        }
    };
    let document = match SceneDocument::from_ron_text(&text) {
        Ok(document) => document,
        Err(error) => {
            push_finding(
                report,
                "sample_pack.scene_invalid",
                Some(&sample.id),
                Some(&sample.scene),
                &format!("sample scene failed document validation: {error}"),
                "Open the sample with the current editor and repair its authored scene data.",
            );
            return;
        }
    };
    validate_actor_ids(sample, &document, report);
    let required = sample.area.required_components();
    let mut present = BTreeSet::new();
    for entity in &document.entities {
        let actor_id = entity.actor_id.as_deref().unwrap_or("<missing ActorId>");
        for component in &entity.components {
            let required_for_area = required.contains(&component.type_name.as_str());
            if required_for_area {
                present.insert(component.type_name.as_str());
            }
            if let Some(Err(error)) =
                parse_recognized_component(&component.type_name, &component.ron)
            {
                push_finding(
                    report,
                    if required_for_area {
                        "sample_pack.area_contract_malformed"
                    } else {
                        "sample_pack.authored_component_malformed"
                    },
                    Some(&sample.id),
                    Some(&sample.scene),
                    &format!(
                        "{} payload on actor `{actor_id}` is malformed: {error}",
                        component.type_name
                    ),
                    "Remove and recreate the malformed component with the current editor.",
                );
            }
        }
        let missing_baseline = REQUIRED_ACTOR_BASELINE
            .iter()
            .filter(|(type_name, _)| {
                !entity
                    .components
                    .iter()
                    .any(|component| component.type_name == *type_name)
            })
            .map(|(_, label)| *label)
            .collect::<Vec<_>>();
        if !missing_baseline.is_empty() {
            push_finding(
                report,
                "sample_pack.actor_baseline_missing",
                Some(&sample.id),
                Some(&sample.scene),
                &format!(
                    "actor `{actor_id}` lacks required baseline component(s): {}",
                    missing_baseline.join(", ")
                ),
                "Restore LevelObject, Transform, and ActorKind on every authored sample actor.",
            );
        }
    }
    let missing: Vec<_> = sample
        .area
        .required_components()
        .iter()
        .copied()
        .filter(|required| !present.contains(required))
        .collect();
    if !missing.is_empty() {
        push_finding(
            report,
            "sample_pack.area_contract_missing",
            Some(&sample.id),
            Some(&sample.scene),
            &format!(
                "{} sample lacks required authored component(s): {}",
                sample.area,
                missing.join(", ")
            ),
            "Add the required authored workflow content to the sample scene.",
        );
    }
}

fn parse_recognized_component(type_name: &str, value: &str) -> Option<Result<(), String>> {
    macro_rules! parse {
        ($ty:ty) => {
            Some(
                ron::from_str::<$ty>(value)
                    .map(|_| ())
                    .map_err(|error| error.to_string()),
            )
        };
    }
    match type_name {
        NAME_COMPONENT => parse!(String),
        TRANSFORM_COMPONENT => parse!(SerializedTransform),
        LEVEL_OBJECT_COMPONENT => parse!(LevelObject),
        ACTOR_KIND_COMPONENT => parse!(ActorKind),
        COMPONENT_PRIMITIVE => parse!(Primitive),
        COMPONENT_LIGHT_DESC => parse!(LightDesc),
        EDITOR_VISIBILITY_COMPONENT => parse!(EditorVisibility),
        HIERARCHY_SIBLING_INDEX_COMPONENT => parse!(HierarchySiblingIndex),
        COMPONENT_BRUSH_DESC => parse!(BrushDesc),
        COMPONENT_MATERIAL_DESC => parse!(MaterialDesc),
        COMPONENT_TERRAIN_DESC => parse!(TerrainDesc),
        COMPONENT_RIGID_BODY_DESC => parse!(RigidBodyDesc),
        COMPONENT_COLLIDER_DESC => parse!(ColliderDesc),
        COMPONENT_POST_PROCESS_VOLUME => parse!(PostProcessVolumeDesc),
        _ => None,
    }
}

#[derive(Debug, Deserialize)]
struct SerializedTransform {
    #[serde(rename = "translation")]
    _translation: [f32; 3],
    #[serde(rename = "rotation")]
    _rotation: [f32; 4],
    #[serde(rename = "scale")]
    _scale: [f32; 3],
}

fn validate_actor_ids(
    sample: &EditorSampleEntry,
    document: &SceneDocument,
    report: &mut ProjectValidationReport,
) {
    let mut actor_ids = BTreeSet::new();
    for entity in &document.entities {
        let Some(actor_id) = entity
            .actor_id
            .as_deref()
            .filter(|actor_id| !actor_id.trim().is_empty())
        else {
            push_finding(
                report,
                "sample_pack.actor_id_missing",
                Some(&sample.id),
                Some(&sample.scene),
                "every sample-scene entity must have a nonempty stable ActorId",
                "Assign and save stable ActorId components for every authored sample entity.",
            );
            continue;
        };
        if !actor_ids.insert(actor_id) {
            push_finding(
                report,
                "sample_pack.actor_id_duplicate",
                Some(&sample.id),
                Some(actor_id),
                "sample scene contains a duplicate ActorId",
                "Assign one unique stable ActorId to every authored sample entity.",
            );
        }
    }
}

fn stable_id(value: &str) -> bool {
    let mut bytes = value.bytes();
    let Some(first) = bytes.next() else {
        return false;
    };
    (first.is_ascii_lowercase() || first.is_ascii_digit())
        && bytes.all(|byte| {
            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'-' | b'_')
        })
}

fn clean_text(value: &str) -> bool {
    !value.is_empty() && value.trim() == value
}

fn safe_scene_reference(reference: &str) -> bool {
    let path = Path::new(reference);
    !path.is_absolute()
        && path
            .components()
            .all(|component| matches!(component, Component::Normal(_)))
        && path.starts_with("assets/levels")
        && path != Path::new("assets/levels")
        && reference.ends_with(".scn.ron")
}

fn path_stays_within_project(project_root: &Path, path: &Path) -> bool {
    let Ok(root) = project_root.canonicalize() else {
        return false;
    };
    path.canonicalize()
        .is_ok_and(|resolved| resolved.starts_with(root))
}

fn path_contains_symlink(project_root: &Path, relative: &Path) -> bool {
    let mut current = PathBuf::from(project_root);
    relative.components().any(|component| {
        current.push(component.as_os_str());
        std::fs::symlink_metadata(&current).is_ok_and(|metadata| metadata.file_type().is_symlink())
    })
}

fn push_finding(
    report: &mut ProjectValidationReport,
    code: &str,
    sample_id: Option<&str>,
    reference: Option<&str>,
    message: &str,
    repair: &str,
) {
    let message = sample_id.map_or_else(
        || message.to_string(),
        |sample_id| format!("sample `{sample_id}`: {message}"),
    );
    report.findings.push(ProjectValidationFinding {
        severity: ValidationSeverity::Error,
        code: code.into(),
        source_path: EDITOR_SAMPLE_MANIFEST_PATH.into(),
        owner_actor_id: None,
        reference: reference.map(str::to_string),
        message,
        repair: repair.into(),
    });
}

fn normalize_report(report: &mut ProjectValidationReport) {
    report.dependencies.sort_by(|left, right| {
        (
            &left.owner_path,
            &left.owner_actor_id,
            &left.kind,
            &left.reference,
        )
            .cmp(&(
                &right.owner_path,
                &right.owner_actor_id,
                &right.kind,
                &right.reference,
            ))
    });
    report.dependencies.dedup();
    report.findings.sort_by(|left, right| {
        (
            &left.source_path,
            &left.owner_actor_id,
            &left.code,
            &left.reference,
            &left.message,
            &left.repair,
        )
            .cmp(&(
                &right.source_path,
                &right.owner_actor_id,
                &right.code,
                &right.reference,
                &right.message,
                &right.repair,
            ))
    });
    report.findings.dedup();
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicU64, Ordering};

    static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1);

    fn fixture_root() -> PathBuf {
        let root = std::env::temp_dir().join(format!(
            "blacksite-editor-samples-{}-{}",
            std::process::id(),
            NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
        ));
        std::fs::create_dir_all(root.join("assets/samples")).unwrap();
        std::fs::create_dir_all(root.join("assets/levels/samples")).unwrap();
        root
    }

    fn sample(area: EditorSampleArea, id: &str, scene: &str) -> EditorSampleEntry {
        EditorSampleEntry {
            id: id.into(),
            label: format!("{area} Sample"),
            area,
            scene: scene.into(),
            summary: format!("Validates the {area} workflow."),
            checks: vec![format!("Open and inspect the {area} sample.")],
        }
    }

    fn valid_samples() -> Vec<EditorSampleEntry> {
        vec![
            sample(
                EditorSampleArea::Brush,
                "brush-blockout",
                "assets/levels/samples/brush_blockout.scn.ron",
            ),
            sample(
                EditorSampleArea::Material,
                "material-lab",
                "assets/levels/samples/material_lab.scn.ron",
            ),
            sample(
                EditorSampleArea::Terrain,
                "terrain-sculpt",
                "assets/levels/samples/terrain_sculpt.scn.ron",
            ),
            sample(
                EditorSampleArea::PhysicsPlacement,
                "physics-drop",
                "assets/levels/samples/physics_drop.scn.ron",
            ),
            sample(
                EditorSampleArea::Rendering,
                "rendering-lab",
                "assets/levels/samples/rendering_lab.scn.ron",
            ),
        ]
    }

    fn valid_component_ron(type_name: &str) -> String {
        match type_name {
            COMPONENT_BRUSH_DESC => ron::to_string(&BrushDesc::default()).unwrap(),
            COMPONENT_PRIMITIVE => ron::to_string(&Primitive::default()).unwrap(),
            COMPONENT_MATERIAL_DESC => ron::to_string(&MaterialDesc::default()).unwrap(),
            COMPONENT_TERRAIN_DESC => ron::to_string(&TerrainDesc::default()).unwrap(),
            COMPONENT_RIGID_BODY_DESC => ron::to_string(&RigidBodyDesc::default()).unwrap(),
            COMPONENT_COLLIDER_DESC => ron::to_string(&ColliderDesc::default()).unwrap(),
            COMPONENT_POST_PROCESS_VOLUME => {
                ron::to_string(&PostProcessVolumeDesc::default()).unwrap()
            }
            _ => panic!("no typed fixture for {type_name}"),
        }
    }

    fn scene_components(sample: &EditorSampleEntry, malformed: Option<&str>) -> String {
        sample
            .area
            .required_components()
            .iter()
            .map(|type_name| {
                let value = if malformed == Some(*type_name) {
                    "\"malformed-required-component\"".to_string()
                } else {
                    valid_component_ron(type_name)
                };
                format!("\"{type_name}\":{value}")
            })
            .collect::<Vec<_>>()
            .join(",")
    }

    fn write_sample_scene(root: &Path, sample: &EditorSampleEntry, malformed: Option<&str>) {
        let components = scene_components(sample, malformed);
        let path = root.join(&sample.scene);
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(
            path,
            format!(
                "(schema_version:4,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"sample-{}\"),\"{LEVEL_OBJECT_COMPONENT}\":(),\"{TRANSFORM_COMPONENT}\":(translation:(0.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0)),\"{ACTOR_KIND_COMPONENT}\":Empty,{components}}})}})",
                sample.id
            ),
        )
        .unwrap();
    }

    fn write_fixture(root: &Path, samples: &[EditorSampleEntry]) {
        let manifest = EditorSampleManifest {
            schema_version: EDITOR_SAMPLE_SCHEMA_VERSION,
            samples: samples.to_vec(),
        };
        std::fs::write(
            root.join(EDITOR_SAMPLE_MANIFEST_PATH),
            ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap(),
        )
        .unwrap();
        for sample in samples {
            write_sample_scene(root, sample, None);
        }
    }

    #[test]
    fn valid_pack_preserves_manifest_order_and_is_release_ready() {
        let root = fixture_root();
        let mut samples = valid_samples();
        samples.reverse();
        write_fixture(&root, &samples);

        let validation = validate_editor_sample_pack(&root);

        assert!(
            validation.is_release_ready(),
            "{:?}",
            validation.report.findings
        );
        assert_eq!(validation.samples.len(), EditorSampleArea::ALL.len());
        assert_eq!(validation.samples[0].area, EditorSampleArea::Rendering);
        assert_eq!(
            validation.report.dependencies.len(),
            EditorSampleArea::ALL.len()
        );
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn missing_or_malformed_manifest_is_blocking() {
        let missing_root = fixture_root();
        let missing = validate_editor_sample_pack(&missing_root);
        assert_eq!(missing.report.findings.len(), 1);
        assert_eq!(
            missing.report.findings[0].code,
            "sample_pack.manifest_missing"
        );
        std::fs::remove_dir_all(missing_root).unwrap();

        let malformed_root = fixture_root();
        std::fs::write(
            malformed_root.join(EDITOR_SAMPLE_MANIFEST_PATH),
            "(schema_version:1,samples:[",
        )
        .unwrap();
        let malformed = validate_editor_sample_pack(&malformed_root);
        assert_eq!(malformed.report.findings.len(), 1);
        assert_eq!(
            malformed.report.findings[0].code,
            "sample_pack.manifest_invalid"
        );
        std::fs::remove_dir_all(malformed_root).unwrap();
    }

    #[test]
    fn missing_scene_and_duplicate_manifest_keys_are_blocking() {
        let missing_root = fixture_root();
        let samples = valid_samples();
        write_fixture(&missing_root, &samples);
        std::fs::remove_file(missing_root.join(&samples[0].scene)).unwrap();
        let missing = validate_editor_sample_pack(&missing_root);
        assert!(missing
            .report
            .findings
            .iter()
            .any(|finding| finding.code == "sample_pack.scene_missing"));
        std::fs::remove_dir_all(missing_root).unwrap();

        let duplicate_root = fixture_root();
        let mut duplicates = valid_samples();
        duplicates[1].id = duplicates[0].id.clone();
        duplicates[1].scene = duplicates[0].scene.clone();
        write_fixture(&duplicate_root, &duplicates);
        let duplicate = validate_editor_sample_pack(&duplicate_root);
        let codes = duplicate
            .report
            .findings
            .iter()
            .map(|finding| finding.code.as_str())
            .collect::<BTreeSet<_>>();
        assert!(codes.contains("sample_pack.duplicate_id"));
        assert!(codes.contains("sample_pack.duplicate_scene"));
        std::fs::remove_dir_all(duplicate_root).unwrap();
    }

    #[test]
    fn every_required_area_payload_is_type_checked() {
        for malformed_type in [
            COMPONENT_BRUSH_DESC,
            COMPONENT_PRIMITIVE,
            COMPONENT_TERRAIN_DESC,
            COMPONENT_RIGID_BODY_DESC,
            COMPONENT_COLLIDER_DESC,
            COMPONENT_POST_PROCESS_VOLUME,
        ] {
            let root = fixture_root();
            let samples = valid_samples();
            write_fixture(&root, &samples);
            let sample = samples
                .iter()
                .find(|sample| sample.area.required_components().contains(&malformed_type))
                .unwrap();
            write_sample_scene(&root, sample, Some(malformed_type));

            let validation = validate_editor_sample_pack(&root);

            assert!(
                validation.report.findings.iter().any(|finding| {
                    finding.code == "sample_pack.area_contract_malformed"
                        && finding.message.contains(malformed_type)
                }),
                "{malformed_type} was not type-checked: {:?}",
                validation.report.findings
            );
            assert!(!validation.is_release_ready());
            std::fs::remove_dir_all(root).unwrap();
        }
    }

    #[test]
    fn malformed_secondary_recognized_payload_blocks_the_pack() {
        let root = fixture_root();
        let samples = valid_samples();
        write_fixture(&root, &samples);
        let sample = &samples[0];
        let path = root.join(&sample.scene);
        let text = std::fs::read_to_string(&path).unwrap();
        let brush = valid_component_ron(COMPONENT_BRUSH_DESC);
        let required = format!("\"{COMPONENT_BRUSH_DESC}\":{brush}");
        let with_secondary =
            format!("{required},\"{COMPONENT_MATERIAL_DESC}\":\"malformed-secondary-material\"");
        assert!(text.contains(&required));
        std::fs::write(&path, text.replacen(&required, &with_secondary, 1)).unwrap();

        let validation = validate_editor_sample_pack(&root);

        assert!(validation.report.findings.iter().any(|finding| {
            finding.code == "sample_pack.authored_component_malformed"
                && finding.message.contains(COMPONENT_MATERIAL_DESC)
                && finding.message.contains("sample-brush-blockout")
        }));
        assert!(!validation.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn missing_actor_baseline_component_blocks_the_pack() {
        let root = fixture_root();
        let samples = valid_samples();
        write_fixture(&root, &samples);
        let sample = &samples[0];
        let path = root.join(&sample.scene);
        let text = std::fs::read_to_string(&path).unwrap();
        let actor_kind = format!("\"{ACTOR_KIND_COMPONENT}\":Empty,");
        assert!(text.contains(&actor_kind));
        std::fs::write(&path, text.replacen(&actor_kind, "", 1)).unwrap();

        let validation = validate_editor_sample_pack(&root);

        assert!(validation.report.findings.iter().any(|finding| {
            finding.code == "sample_pack.actor_baseline_missing"
                && finding.message.contains("ActorKind")
                && finding.message.contains("sample-brush-blockout")
        }));
        assert!(!validation.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn malformed_actor_kind_payload_blocks_the_pack() {
        let root = fixture_root();
        let samples = valid_samples();
        write_fixture(&root, &samples);
        let sample = &samples[0];
        let path = root.join(&sample.scene);
        let text = std::fs::read_to_string(&path).unwrap();
        let actor_kind = format!("\"{ACTOR_KIND_COMPONENT}\":Empty");
        let malformed_actor_kind = format!("\"{ACTOR_KIND_COMPONENT}\":(unexpected_field:true)");
        assert!(text.contains(&actor_kind));
        std::fs::write(&path, text.replacen(&actor_kind, &malformed_actor_kind, 1)).unwrap();

        let validation = validate_editor_sample_pack(&root);

        assert!(validation.report.findings.iter().any(|finding| {
            finding.code == "sample_pack.authored_component_malformed"
                && finding.message.contains(ACTOR_KIND_COMPONENT)
                && finding.message.contains("sample-brush-blockout")
        }));
        assert!(!validation.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn unknown_extension_component_remains_allowed() {
        let root = fixture_root();
        let samples = valid_samples();
        write_fixture(&root, &samples);
        let sample = &samples[0];
        let path = root.join(&sample.scene);
        let text = std::fs::read_to_string(&path).unwrap();
        let brush = valid_component_ron(COMPONENT_BRUSH_DESC);
        let required = format!("\"{COMPONENT_BRUSH_DESC}\":{brush}");
        let with_extension =
            format!("{required},\"game_extension::CustomSampleData\":(enabled:true)");
        assert!(text.contains(&required));
        std::fs::write(&path, text.replacen(&required, &with_extension, 1)).unwrap();

        let validation = validate_editor_sample_pack(&root);

        assert!(
            validation.is_release_ready(),
            "unknown extension should remain allowed: {:?}",
            validation.report.findings
        );
        std::fs::remove_dir_all(root).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn manifest_parent_symlink_is_rejected() {
        use std::os::unix::fs::symlink;

        let root = fixture_root();
        let samples = valid_samples();
        write_fixture(&root, &samples);
        let manifest = std::fs::read(root.join(EDITOR_SAMPLE_MANIFEST_PATH)).unwrap();
        std::fs::remove_file(root.join(EDITOR_SAMPLE_MANIFEST_PATH)).unwrap();
        std::fs::remove_dir(root.join("assets/samples")).unwrap();
        let outside = std::env::temp_dir().join(format!(
            "blacksite-editor-samples-outside-{}-{}",
            std::process::id(),
            NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
        ));
        std::fs::create_dir_all(&outside).unwrap();
        std::fs::write(outside.join("editor_samples.ron"), manifest).unwrap();
        symlink(&outside, root.join("assets/samples")).unwrap();

        let load_error = load_editor_sample_manifest(&root).unwrap_err();
        let validation = validate_editor_sample_pack(&root);

        assert!(load_error.contains("symlink"), "{load_error}");
        assert_eq!(validation.report.findings.len(), 1);
        assert_eq!(
            validation.report.findings[0].code,
            "sample_pack.manifest_not_regular"
        );
        std::fs::remove_file(root.join("assets/samples")).unwrap();
        std::fs::remove_dir_all(root).unwrap();
        std::fs::remove_dir_all(outside).unwrap();
    }

    #[test]
    fn missing_and_duplicate_actor_ids_block_the_pack() {
        let root = fixture_root();
        let samples = valid_samples();
        write_fixture(&root, &samples);
        let brush = valid_component_ron(COMPONENT_BRUSH_DESC);
        std::fs::write(
            root.join(&samples[0].scene),
            format!(
                "(schema_version:4,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"duplicate\"),\"{COMPONENT_BRUSH_DESC}\":{brush}}}),2:(components:{{\"shared::components::ActorId\":(\"duplicate\")}}),3:(components:{{}})}})"
            ),
        )
        .unwrap();

        let validation = validate_editor_sample_pack(&root);
        let codes = validation
            .report
            .findings
            .iter()
            .map(|finding| finding.code.as_str())
            .collect::<BTreeSet<_>>();

        assert!(codes.contains("sample_pack.actor_id_missing"));
        assert!(codes.contains("sample_pack.actor_id_duplicate"));
        assert!(!validation.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn duplicate_and_missing_areas_are_deterministic_blockers() {
        let root = fixture_root();
        let mut samples = valid_samples();
        samples.pop();
        samples.push(sample(
            EditorSampleArea::Brush,
            "brush-second",
            "assets/levels/samples/brush_second.scn.ron",
        ));
        write_fixture(&root, &samples);

        let validation = validate_editor_sample_pack(&root);
        let codes = validation
            .report
            .findings
            .iter()
            .map(|finding| finding.code.as_str())
            .collect::<Vec<_>>();

        assert!(codes.contains(&"sample_pack.duplicate_area"));
        assert!(codes.contains(&"sample_pack.missing_area"));
        assert!(!validation.is_release_ready());
        std::fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn unsafe_or_semantically_empty_scene_is_rejected() {
        let root = fixture_root();
        let mut samples = valid_samples();
        samples[0].scene = "../outside.scn.ron".into();
        write_fixture(&root, &samples);
        std::fs::write(
            root.join(&samples[1].scene),
            "(schema_version:4,resources:{},entities:{})",
        )
        .unwrap();

        let validation = validate_editor_sample_pack(&root);
        let codes = validation
            .report
            .findings
            .iter()
            .map(|finding| finding.code.as_str())
            .collect::<BTreeSet<_>>();

        assert!(codes.contains("sample_pack.unsafe_scene_path"));
        assert!(codes.contains("sample_pack.area_contract_missing"));
        std::fs::remove_dir_all(root).unwrap();
    }
}
