//! Builds the seed values that pre-populate the embedded Hive Laboratory, and injects them into
//! the Laboratory HTML generated by `build.rs`.
//!
//! Everything injected here is served to every browser that opens the Laboratory and is visible
//! via "view source". It is a convenience for Laboratory users, not a place for secrets.

use hive_router_config::{
    laboratory::{LaboratoryCollectionConfig, LaboratoryConfig, LaboratoryOperationConfig},
    primitives::http_header::HttpHeaderName,
};
use serde::Serialize;
use std::collections::{BTreeMap, HashSet};

/// Sits inside a JavaScript string literal in the generated page. Carries the operations,
/// collections and tabs seed, merged with the browser's stored state after the bundle loads.
const PROPS_PLACEHOLDER: &str = "__LABORATORY_PROPS__";

/// Sits inside a JavaScript string literal in the generated page, before the Laboratory bundle.
/// Carries the global headers the page's `fetch` wrapper attaches to every router request.
const GLOBAL_HEADERS_PLACEHOLDER: &str = "__LABORATORY_GLOBAL_HEADERS__";

/// The Laboratory needs a parseable `createdAt` on a collection but (as of 0.2.0) never displays or
/// sorts by a seeded one's value. Epoch is a deliberate sentinel: if a future version surfaces it,
/// "1970" reads as a placeholder rather than a plausible-but-wrong date.
const SEEDED_COLLECTION_CREATED_AT: &str = "1970-01-01T00:00:00.000Z";

/// The page merges this with the state the Laboratory has already persisted in the browser, so it
/// is deliberately not the shape of the Laboratory's own props.
#[derive(Debug, Default, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LaboratorySeed {
    #[serde(skip_serializing_if = "Vec::is_empty")]
    operations: Vec<SeedOperation>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    tabs: Vec<SeedTab>,
    #[serde(skip_serializing_if = "Option::is_none")]
    active_tab_id: Option<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    collections: Vec<SeedCollection>,
}

#[derive(Debug, Serialize, PartialEq)]
struct SeedOperation {
    id: String,
    name: String,
    query: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    variables: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    headers: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    extensions: Option<String>,
}

#[derive(Debug, Serialize, PartialEq)]
struct SeedTab {
    id: String,
    #[serde(rename = "type")]
    tab_type: &'static str,
    data: SeedTabData,
}

#[derive(Debug, Serialize, PartialEq)]
struct SeedTabData {
    id: String,
    name: String,
}

#[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct SeedCollection {
    id: String,
    name: String,
    created_at: &'static str,
    operations: Vec<SeedCollectionOperation>,
}

#[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct SeedCollectionOperation {
    id: String,
    name: String,
    query: String,
    created_at: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    variables: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    headers: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    extensions: Option<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum LaboratoryConfigError {
    #[error("laboratory.operations contains more than one operation named '{0}'")]
    DuplicateOperationName(String),
    #[error("laboratory.collections contains more than one collection named '{0}'")]
    DuplicateCollectionName(String),
    #[error(
        "laboratory collection '{collection}' contains more than one operation named '{operation}'"
    )]
    DuplicateCollectionOperationName {
        collection: String,
        operation: String,
    },
    #[error("{location}.name must not be empty")]
    EmptyName { location: String },
    #[error("{location} ('{name}') must contain at least one operation")]
    EmptyCollection { location: String, name: String },
}

/// Stable across renders, so the page can recognise operations it has already seeded.
///
/// The name is used verbatim: ids are only compared for equality, never used as storage keys or
/// selectors, and slugifying collapsed distinct names onto each other.
fn operation_seed_id(name: &str) -> String {
    format!("router-seed:{name}")
}

/// Kept in a prefix of its own so tab and operation ids cannot collide when a name contains `:`.
fn tab_seed_id(name: &str) -> String {
    format!("router-seed-tab:{name}")
}

/// Name-derived so the merge recognises a seeded collection across reloads. Collection names are
/// validated unique, so this single trailing segment needs no encoding.
fn collection_seed_id(name: &str) -> String {
    format!("router-seed-collection:{name}")
}

/// Namespaced by collection so the same operation name may appear in two collections. Each segment
/// is encoded so a `:` in a name cannot make two distinct operations produce the same id.
fn collection_operation_seed_id(collection: &str, operation: &str) -> String {
    format!(
        "router-seed-op:{}:{}",
        encode_id_segment(collection),
        encode_id_segment(operation)
    )
}

/// Escapes `%` and `:` so an id segment cannot contain the separator.
///
/// `%` must be encoded first: encoding `:` first would turn a literal `%3A` and a real `:` into the
/// same `%253A`, reintroducing the collision this exists to prevent.
fn encode_id_segment(segment: &str) -> String {
    segment.replace('%', "%25").replace(':', "%3A")
}

/// Serializes an operation's map field (`headers`, `variables` or `extensions`) to the JSON string
/// the Laboratory stores for an operation. An empty map is treated as unset.
///
/// The config types these fields as maps, so a non-object is already rejected at config load; no
/// validation is needed here.
fn serialize_map<V: Serialize>(map: &Option<BTreeMap<String, V>>) -> Option<String> {
    map.as_ref()
        .filter(|map| !map.is_empty())
        .map(|map| sonic_rs::to_string(map).expect("a map is always serializable"))
}

fn serialize_headers_map<V: Serialize>(
    map: &Option<BTreeMap<HttpHeaderName, V>>,
) -> Option<String> {
    map.as_ref()
        .filter(|map| !map.is_empty())
        .map(|map| sonic_rs::to_string(map).expect("a map is always serializable"))
}

fn build_operation(
    index: usize,
    operation: &LaboratoryOperationConfig,
) -> Result<(SeedOperation, SeedTab), LaboratoryConfigError> {
    validate_operation_fields(&format!("laboratory.operations[{index}]"), operation)?;

    let id = operation_seed_id(&operation.name);

    let tab = SeedTab {
        id: tab_seed_id(&operation.name),
        tab_type: "operation",
        data: SeedTabData {
            id: id.clone(),
            name: operation.name.clone(),
        },
    };

    let seed_operation = SeedOperation {
        id,
        name: operation.name.clone(),
        query: operation.query.clone(),
        variables: serialize_map(&operation.variables),
        headers: serialize_headers_map(&operation.headers),
        extensions: serialize_map(&operation.extensions),
    };

    Ok((seed_operation, tab))
}

/// Rejects a blank operation name, naming the offending operation by its config `location`. Shared
/// by top-level and collection operations. The map fields are typed in config, so they need no
/// validation here.
fn validate_operation_fields(
    location: &str,
    operation: &LaboratoryOperationConfig,
) -> Result<(), LaboratoryConfigError> {
    if operation.name.trim().is_empty() {
        return Err(LaboratoryConfigError::EmptyName {
            location: location.to_string(),
        });
    }

    Ok(())
}

fn build_collection(
    index: usize,
    collection: &LaboratoryCollectionConfig,
) -> Result<SeedCollection, LaboratoryConfigError> {
    if collection.name.trim().is_empty() {
        return Err(LaboratoryConfigError::EmptyName {
            location: format!("laboratory.collections[{index}]"),
        });
    }

    if collection.operations.is_empty() {
        return Err(LaboratoryConfigError::EmptyCollection {
            location: format!("laboratory.collections[{index}]"),
            name: collection.name.clone(),
        });
    }

    let mut seen_names = HashSet::with_capacity(collection.operations.len());
    let mut operations = Vec::with_capacity(collection.operations.len());

    for (op_index, operation) in collection.operations.iter().enumerate() {
        let location = format!("laboratory.collections[{index}].operations[{op_index}]");
        validate_operation_fields(&location, operation)?;

        if !seen_names.insert(operation.name.as_str()) {
            return Err(LaboratoryConfigError::DuplicateCollectionOperationName {
                collection: collection.name.clone(),
                operation: operation.name.clone(),
            });
        }

        operations.push(SeedCollectionOperation {
            id: collection_operation_seed_id(&collection.name, &operation.name),
            name: operation.name.clone(),
            query: operation.query.clone(),
            created_at: SEEDED_COLLECTION_CREATED_AT,
            variables: serialize_map(&operation.variables),
            headers: serialize_headers_map(&operation.headers),
            extensions: serialize_map(&operation.extensions),
        });
    }

    Ok(SeedCollection {
        id: collection_seed_id(&collection.name),
        name: collection.name.clone(),
        created_at: SEEDED_COLLECTION_CREATED_AT,
        operations,
    })
}

pub fn build_laboratory_seed(
    config: &LaboratoryConfig,
) -> Result<LaboratorySeed, LaboratoryConfigError> {
    let mut seen_names = HashSet::with_capacity(config.operations.len());
    let mut operations = Vec::with_capacity(config.operations.len());
    let mut tabs = Vec::with_capacity(config.operations.len());

    for (index, operation) in config.operations.iter().enumerate() {
        // Validated before the duplicate check so a blank name is reported as blank, not as a
        // collision with the previous blank one.
        let (seed_operation, tab) = build_operation(index, operation)?;

        if !seen_names.insert(operation.name.as_str()) {
            return Err(LaboratoryConfigError::DuplicateOperationName(
                operation.name.clone(),
            ));
        }

        operations.push(seed_operation);
        tabs.push(tab);
    }

    let active_tab_id = tabs.first().map(|tab| tab.id.clone());

    let mut seen_collection_names = HashSet::with_capacity(config.collections.len());
    let mut collections = Vec::with_capacity(config.collections.len());

    for (index, collection) in config.collections.iter().enumerate() {
        let seed_collection = build_collection(index, collection)?;

        if !seen_collection_names.insert(collection.name.as_str()) {
            return Err(LaboratoryConfigError::DuplicateCollectionName(
                collection.name.clone(),
            ));
        }

        collections.push(seed_collection);
    }

    Ok(LaboratorySeed {
        operations,
        tabs,
        active_tab_id,
        collections,
    })
}

/// Escapes a string for a double-quoted JavaScript string literal inside an inline `<script>`.
///
/// Every `<` becomes `<`, which makes `</script`, `<script` and `<!--` unrepresentable, so no
/// configured value can terminate the script element early. The JavaScript engine turns the escape
/// back into `<` before `JSON.parse` sees it.
fn escape_for_js_string_literal(value: &str) -> String {
    let mut escaped = String::with_capacity(value.len());

    for character in value.chars() {
        match character {
            '\\' => escaped.push_str("\\\\"),
            '"' => escaped.push_str("\\\""),
            '\n' => escaped.push_str("\\n"),
            '\r' => escaped.push_str("\\r"),
            '\t' => escaped.push_str("\\t"),
            '<' => escaped.push_str("\\u003c"),
            // Valid in JSON but not inside a JavaScript string literal.
            '\u{2028}' => escaped.push_str("\\u2028"),
            '\u{2029}' => escaped.push_str("\\u2029"),
            _ => escaped.push(character),
        }
    }

    escaped
}

/// Always substitutes both placeholders, emitting `{}` when there is nothing to seed. Leaving a
/// placeholder in place would make the page's `JSON.parse` throw on every load of an unconfigured
/// router.
pub fn render_laboratory_html(
    template: &str,
    config: &LaboratoryConfig,
) -> Result<String, LaboratoryConfigError> {
    let seed = build_laboratory_seed(config)?;
    let seed_json = sonic_rs::to_string(&seed).expect("laboratory seed is always serializable");

    // Serialized directly (not via `serialize_map`) so an empty map becomes `{}`, not nothing —
    // the placeholder must always be replaced. The page's fetch wrapper skips an empty map.
    let global_headers_json = sonic_rs::to_string(&config.global_headers)
        .expect("laboratory global headers are always serializable");

    Ok(template
        .replace(PROPS_PLACEHOLDER, &escape_for_js_string_literal(&seed_json))
        .replace(
            GLOBAL_HEADERS_PLACEHOLDER,
            &escape_for_js_string_literal(&global_headers_json),
        ))
}

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

    fn operation(name: &str) -> LaboratoryOperationConfig {
        LaboratoryOperationConfig {
            name: name.to_string(),
            query: format!("query {name} {{ hello }}"),
            variables: None,
            headers: None,
            extensions: None,
        }
    }

    fn config_with_operations(operations: Vec<LaboratoryOperationConfig>) -> LaboratoryConfig {
        LaboratoryConfig {
            operations,
            ..Default::default()
        }
    }

    fn collection(
        name: &str,
        operations: Vec<LaboratoryOperationConfig>,
    ) -> LaboratoryCollectionConfig {
        LaboratoryCollectionConfig {
            name: name.to_string(),
            operations,
        }
    }

    fn config_with_collections(collections: Vec<LaboratoryCollectionConfig>) -> LaboratoryConfig {
        LaboratoryConfig {
            collections,
            ..Default::default()
        }
    }

    #[cfg(test)]
    fn config_with_global_headers(headers: &[(&str, &str)]) -> LaboratoryConfig {
        LaboratoryConfig {
            global_headers: headers
                .iter()
                .map(|(k, v)| (HttpHeaderName::from(*k), v.to_string()))
                .collect(),
            ..Default::default()
        }
    }

    /// Unescapes the injected literal the way a JavaScript engine would before `JSON.parse` runs.
    fn extract_injected_json(html: &str) -> String {
        let marker = "JSON.parse(\"";
        let start = html.find(marker).expect("template should contain the call") + marker.len();
        // The literal itself may contain `");`, so anchor on the last occurrence, which is the
        // one the template closes the call with.
        let end = html.rfind("\");").expect("the call should be terminated");
        assert!(start <= end, "the injected literal is malformed: {html}");

        let literal = &html[start..end];
        let mut unescaped = String::with_capacity(literal.len());
        let mut chars = literal.chars();

        while let Some(character) = chars.next() {
            if character != '\\' {
                unescaped.push(character);
                continue;
            }

            match chars.next().expect("dangling escape") {
                'n' => unescaped.push('\n'),
                'r' => unescaped.push('\r'),
                't' => unescaped.push('\t'),
                '"' => unescaped.push('"'),
                '\\' => unescaped.push('\\'),
                'u' => {
                    let code: String = (&mut chars).take(4).collect();
                    let code = u32::from_str_radix(&code, 16).expect("invalid unicode escape");
                    unescaped.push(char::from_u32(code).expect("invalid code point"));
                }
                other => panic!("unexpected escape: \\{other}"),
            }
        }

        unescaped
    }

    /// Returns a seeded operation's `query` as the browser sees it after `JSON.parse` — the
    /// round-trip through injection, escaping and parsing.
    fn injected_operation_query(html: &str, index: usize) -> String {
        let seed: serde_json::Value =
            serde_json::from_str(&extract_injected_json(html)).expect("should be valid JSON");
        seed["operations"][index]["query"]
            .as_str()
            .expect("query should be present")
            .to_string()
    }

    const TEMPLATE: &str = r#"<script>JSON.parse("__LABORATORY_PROPS__");</script>"#;

    const GLOBAL_HEADERS_TEMPLATE: &str =
        r#"<script>JSON.parse("__LABORATORY_GLOBAL_HEADERS__");</script>"#;

    #[test]
    fn injects_configured_global_headers() {
        let html = render_laboratory_html(
            GLOBAL_HEADERS_TEMPLATE,
            &config_with_global_headers(&[("X-Env", "staging")]),
        )
        .unwrap();

        assert!(
            !html.contains(GLOBAL_HEADERS_PLACEHOLDER),
            "the global-headers placeholder must be replaced"
        );
        // Round-trips through injection, escaping and parse as the page would see it.
        let parsed: serde_json::Value =
            serde_json::from_str(&extract_injected_json(&html)).expect("should be valid JSON");
        assert_eq!(parsed["x-env"], "staging");
    }

    #[test]
    fn empty_global_headers_still_substitute_to_an_object() {
        let html =
            render_laboratory_html(GLOBAL_HEADERS_TEMPLATE, &LaboratoryConfig::default()).unwrap();

        assert!(
            !html.contains(GLOBAL_HEADERS_PLACEHOLDER),
            "the placeholder must always be replaced"
        );
        assert_eq!(extract_injected_json(&html), "{}");
    }

    #[test]
    fn a_global_header_value_cannot_break_out_of_the_script_element() {
        let html = render_laboratory_html(
            GLOBAL_HEADERS_TEMPLATE,
            &config_with_global_headers(&[("X-Evil", "</script><script>alert(1)</script>")]),
        )
        .unwrap();

        assert!(
            !html.to_lowercase().contains("</script><script>"),
            "the global header value escaped the string literal: {html}"
        );
        assert_eq!(
            html.to_lowercase().matches("</script>").count(),
            1,
            "unexpected number of closing script tags: {html}"
        );
    }

    /// The wrapper only works if it is installed before the bundle captures `globalThis.fetch`, and
    /// only stays invisible if the library keeps capturing fetch at init. Both are load-bearing
    /// assumptions about `LABORATORY_HTML`; a lab upgrade that breaks either fails here. This is a
    /// coarse tripwire, not a behavioral test — only a browser can confirm the header is sent.
    #[cfg(not(feature = "graphiql"))]
    #[test]
    fn the_global_headers_wrapper_is_installed_before_the_bundle() {
        let page = crate::LABORATORY_HTML;

        let wrapper = page
            .find("window.fetch = function")
            .expect("the fetch wrapper should be present");
        let placeholder = page
            .find(GLOBAL_HEADERS_PLACEHOLDER)
            .expect("the global-headers placeholder should be present");
        let bundle = page
            .find("HiveLaboratory")
            .expect("the laboratory bundle should be present");

        assert!(
            placeholder < bundle && wrapper < bundle,
            "the global-headers wrapper must be installed before the bundle"
        );
        assert!(
            page.contains("globalThis.fetch"),
            "the bundle no longer captures globalThis.fetch — the wrapper may not be used"
        );
    }

    /// These strings are a contract with the Laboratory bundle and with already-seeded browsers.
    /// Nothing else fails on a rename: the page renders, it just stops recognising existing state.
    #[test]
    fn the_generated_page_uses_the_agreed_storage_keys() {
        let page = crate::LABORATORY_HTML;

        // Owned by the Laboratory bundle. A mismatch here silently disables merging, which looks
        // like "seeding overwrites my tabs".
        assert!(
            page.contains(r#"var STORAGE_NAMESPACE = "hive-laboratory";"#),
            "the laboratory storage namespace changed"
        );

        for key in ["operations", "tabs", "activeTabId", "collections"] {
            assert!(
                page.contains(&format!("readStored(\"{key}\")")),
                "the page no longer reads the '{key}' laboratory storage key"
            );
        }

        // Ours. A rename re-opens seeded tabs that users had closed, once.
        assert!(
            page.contains(r#"var SEEDED_TABS_KEY = "hive-router:seeded-tab-ids";"#),
            "the seeded-tab bookkeeping key changed"
        );

        assert!(
            page.contains(PROPS_PLACEHOLDER),
            "the generated page no longer contains the seed placeholder"
        );
    }

    /// Serde renames silently break the page: the seed still parses, the field is just
    /// `undefined`. Assert every key we emit is one the page actually reads.
    #[test]
    fn every_seed_field_is_read_by_the_generated_page() {
        let config = LaboratoryConfig {
            operations: vec![operation("GetHello")],
            collections: vec![collection("Onboarding", vec![operation("ListUsers")])],
            ..Default::default()
        };

        let seed = build_laboratory_seed(&config).expect("should build");
        let json = sonic_rs::to_string(&seed).expect("should serialize");
        let fields: std::collections::HashMap<String, sonic_rs::Value> =
            sonic_rs::from_str(&json).expect("should be an object");

        assert_eq!(
            fields.len(),
            4,
            "every seed field must be populated for this test to be meaningful"
        );

        for field in fields.keys() {
            assert!(
                crate::LABORATORY_HTML.contains(&format!("seed.{field}")),
                "the generated page never reads 'seed.{field}'"
            );
        }
    }

    /// Leaving the placeholder in place makes the page's `JSON.parse` throw on every load, which
    /// every user would see in the console.
    #[test]
    fn injects_an_empty_seed_when_there_is_nothing_to_seed() {
        let html = render_laboratory_html(TEMPLATE, &LaboratoryConfig::default()).unwrap();

        assert!(
            !html.contains(PROPS_PLACEHOLDER),
            "the placeholder must always be replaced"
        );
        assert_eq!(extract_injected_json(&html), "{}");
    }

    #[test]
    fn seeds_an_operation_with_a_matching_tab() {
        let seed = build_laboratory_seed(&config_with_operations(vec![operation("GetHello")]))
            .expect("should build");

        assert_eq!(seed.operations.len(), 1);
        assert_eq!(seed.tabs.len(), 1);

        let operation_id = &seed.operations[0].id;
        let tab = &seed.tabs[0];

        assert_eq!(
            &tab.data.id, operation_id,
            "the tab must point at the seeded operation"
        );
        assert_eq!(tab.tab_type, "operation");
        assert_eq!(tab.data.name, "GetHello");
        assert_eq!(
            seed.active_tab_id.as_ref(),
            Some(&tab.id),
            "the first seeded tab is the active one"
        );
    }

    #[test]
    fn seed_ids_are_stable_across_renders() {
        let config = config_with_operations(vec![operation("Get Hello")]);

        let first = build_laboratory_seed(&config).unwrap();
        let second = build_laboratory_seed(&config).unwrap();

        assert_eq!(first.operations[0].id, second.operations[0].id);
        assert_eq!(first.operations[0].id, "router-seed:Get Hello");
        assert_eq!(first.tabs[0].id, "router-seed-tab:Get Hello");
    }

    /// Slugified ids collapsed distinct names onto each other, which made two operations named in
    /// any non-Latin script impossible to seed.
    #[test]
    fn names_that_differ_only_in_punctuation_or_script_get_distinct_ids() {
        for names in [
            ["Get-Hello", "get_hello"],
            ["获取用户", "查询数据"],
            ["a b", "a-b"],
        ] {
            let seed = build_laboratory_seed(&config_with_operations(
                names.iter().map(|name| operation(name)).collect(),
            ))
            .unwrap_or_else(|error| panic!("{names:?} should be distinct, got: {error}"));

            assert_ne!(seed.operations[0].id, seed.operations[1].id);
            assert_ne!(seed.tabs[0].id, seed.tabs[1].id);
        }
    }

    #[test]
    fn operation_and_tab_ids_cannot_collide_when_a_name_contains_a_colon() {
        let seed = build_laboratory_seed(&config_with_operations(vec![
            operation("a"),
            operation("tab:a"),
        ]))
        .expect("should build");

        let ids: HashSet<&str> = seed
            .operations
            .iter()
            .map(|operation| operation.id.as_str())
            .chain(seed.tabs.iter().map(|tab| tab.id.as_str()))
            .collect();

        assert_eq!(ids.len(), 4, "every seeded id must be distinct: {ids:?}");
    }

    #[test]
    fn reports_a_blank_name_as_blank_rather_than_as_a_duplicate() {
        let error = build_laboratory_seed(&config_with_operations(vec![
            operation("  "),
            operation("  "),
        ]))
        .expect_err("a blank name should be rejected");

        assert!(
            matches!(
                &error,
                LaboratoryConfigError::EmptyName { location } if location == "laboratory.operations[0]"
            ),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn rejects_duplicate_operation_names() {
        let error = build_laboratory_seed(&config_with_operations(vec![
            operation("GetHello"),
            operation("GetHello"),
        ]))
        .expect_err("duplicates should be rejected");

        assert!(
            matches!(error, LaboratoryConfigError::DuplicateOperationName(name) if name == "GetHello"),
            "unexpected error"
        );
    }

    #[test]
    fn seeds_a_collection_with_namespaced_operation_ids() {
        let seed = build_laboratory_seed(&config_with_collections(vec![collection(
            "Onboarding",
            vec![operation("GetHello"), operation("ListUsers")],
        )]))
        .expect("should build");

        assert_eq!(seed.collections.len(), 1);
        let coll = &seed.collections[0];
        assert_eq!(coll.id, "router-seed-collection:Onboarding");
        assert_eq!(coll.name, "Onboarding");
        assert_eq!(coll.created_at, SEEDED_COLLECTION_CREATED_AT);
        assert_eq!(coll.operations.len(), 2);
        assert_eq!(coll.operations[0].id, "router-seed-op:Onboarding:GetHello");
        assert_eq!(coll.operations[0].created_at, SEEDED_COLLECTION_CREATED_AT);

        // Collections are self-contained: they do not add top-level operations or tabs.
        assert!(seed.operations.is_empty());
        assert!(seed.tabs.is_empty());
    }

    #[test]
    fn the_same_operation_name_in_two_collections_gets_distinct_ids() {
        let seed = build_laboratory_seed(&config_with_collections(vec![
            collection("A", vec![operation("GetHello")]),
            collection("B", vec![operation("GetHello")]),
        ]))
        .expect("should build");

        assert_ne!(seed.collections[0].id, seed.collections[1].id);
        assert_ne!(
            seed.collections[0].operations[0].id,
            seed.collections[1].operations[0].id
        );
    }

    #[test]
    fn rejects_duplicate_collection_names() {
        let error = build_laboratory_seed(&config_with_collections(vec![
            collection("Onboarding", vec![operation("A")]),
            collection("Onboarding", vec![operation("B")]),
        ]))
        .expect_err("duplicate collection names should be rejected");

        assert!(
            matches!(&error, LaboratoryConfigError::DuplicateCollectionName(name) if name == "Onboarding"),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn rejects_duplicate_operation_names_within_a_collection() {
        let error = build_laboratory_seed(&config_with_collections(vec![collection(
            "Onboarding",
            vec![operation("GetHello"), operation("GetHello")],
        )]))
        .expect_err("duplicate operation names within a collection should be rejected");

        assert!(
            matches!(
                &error,
                LaboratoryConfigError::DuplicateCollectionOperationName { collection, operation }
                    if collection == "Onboarding" && operation == "GetHello"
            ),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn reports_a_blank_collection_name_with_its_location() {
        let error = build_laboratory_seed(&config_with_collections(vec![collection(
            "  ",
            vec![operation("A")],
        )]))
        .expect_err("a blank collection name should be rejected");

        assert!(
            matches!(&error, LaboratoryConfigError::EmptyName { location } if location == "laboratory.collections[0]"),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn rejects_an_empty_collection() {
        let error = build_laboratory_seed(&config_with_collections(vec![collection(
            "Onboarding",
            vec![],
        )]))
        .expect_err("a collection with no operations should be rejected");

        assert!(
            matches!(
                &error,
                LaboratoryConfigError::EmptyCollection { location, name }
                    if location == "laboratory.collections[0]" && name == "Onboarding"
            ),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn collection_created_at_serializes_as_camel_case() {
        // `createdAt` is nested, so the top-level field test does not cover a serde rename on it.
        // The Laboratory calls `new Date(createdAt)`, so the key name and value both matter.
        let json = sonic_rs::to_string(
            &build_laboratory_seed(&config_with_collections(vec![collection(
                "Onboarding",
                vec![operation("GetHello")],
            )]))
            .expect("should build"),
        )
        .expect("should serialize");

        let expected = format!("\"createdAt\":\"{SEEDED_COLLECTION_CREATED_AT}\"");
        assert!(
            json.contains(&expected),
            "the collection and its operation must serialize createdAt as camelCase: {json}"
        );
        // Both the collection and its one operation carry it.
        assert_eq!(json.matches(&expected).count(), 2, "{json}");
        assert!(
            !json.contains("created_at"),
            "createdAt must not serialize as snake_case: {json}"
        );
    }

    #[test]
    fn collection_operation_ids_cannot_collide_when_names_contain_a_colon() {
        // collection "a:b" op "c"  vs  collection "a" op "b:c" would collide under a naive join.
        let seed = build_laboratory_seed(&config_with_collections(vec![
            collection("a:b", vec![operation("c")]),
            collection("a", vec![operation("b:c")]),
        ]))
        .expect("should build");

        assert_ne!(
            seed.collections[0].operations[0].id, seed.collections[1].operations[0].id,
            "colon-containing names produced a colliding operation id"
        );
    }

    #[test]
    fn id_encoding_stays_injective_for_percent_and_colon() {
        // Pins the encode order: a literal "%3A" and a real ":" must not collapse to the same id.
        let seed = build_laboratory_seed(&config_with_collections(vec![collection(
            "C",
            vec![operation("%3A"), operation(":")],
        )]))
        .expect("should build");

        assert_ne!(
            seed.collections[0].operations[0].id, seed.collections[0].operations[1].id,
            "'%3A' and ':' produced a colliding operation id (encode order regressed)"
        );
    }

    #[test]
    fn reports_a_blank_collection_operation_name_with_its_location() {
        let error = build_laboratory_seed(&config_with_collections(vec![collection(
            "Onboarding",
            vec![operation("  ")],
        )]))
        .expect_err("a blank collection operation name should be rejected");

        assert!(
            matches!(&error, LaboratoryConfigError::EmptyName { location } if location == "laboratory.collections[0].operations[0]"),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn a_config_with_only_collections_is_injected() {
        let html = render_laboratory_html(
            TEMPLATE,
            &config_with_collections(vec![collection("Onboarding", vec![operation("GetHello")])]),
        )
        .expect("should render");

        assert!(
            !html.contains(PROPS_PLACEHOLDER),
            "the placeholder must be replaced when only collections are configured"
        );
        assert!(
            html.contains("router-seed-collection:Onboarding"),
            "the seeded collection must reach the page"
        );
    }

    #[test]
    fn nested_variables_serialize_to_a_json_string() {
        let mut operation = operation("GetHello");
        operation.variables = Some(BTreeMap::from([
            (
                "filter".to_string(),
                serde_json::json!({ "status": "active", "tags": ["a", "b"] }),
            ),
            ("limit".to_string(), serde_json::json!(10)),
        ]));

        let seed =
            build_laboratory_seed(&config_with_operations(vec![operation])).expect("should build");

        // The library stores variables as a JSON string; types and nesting are preserved.
        let variables = seed.operations[0]
            .variables
            .as_deref()
            .expect("variables should be present");
        let parsed: serde_json::Value =
            serde_json::from_str(variables).expect("should be valid JSON");
        assert_eq!(parsed["limit"], 10);
        assert_eq!(parsed["filter"]["tags"][1], "b");
    }

    #[test]
    fn an_empty_variables_object_is_treated_as_unset() {
        let mut operation = operation("GetHello");
        operation.variables = Some(BTreeMap::new());
        operation.extensions = Some(BTreeMap::new());

        let seed =
            build_laboratory_seed(&config_with_operations(vec![operation])).expect("should build");

        assert!(seed.operations[0].variables.is_none());
        assert!(seed.operations[0].extensions.is_none());
    }

    #[test]
    fn a_headers_map_serializes_to_a_json_string() {
        let mut operation = operation("GetHello");
        operation.headers = Some(BTreeMap::from([
            (HttpHeaderName::from("X-Team"), "payments".to_string()),
            (HttpHeaderName::from("X-Env"), "staging".to_string()),
        ]));

        let seed =
            build_laboratory_seed(&config_with_operations(vec![operation])).expect("should build");

        // BTreeMap sorts keys, so the injected JSON string is deterministic.
        assert_eq!(
            seed.operations[0].headers.as_deref(),
            Some(r#"{"x-env":"staging","x-team":"payments"}"#)
        );
    }

    #[test]
    fn an_empty_headers_map_is_treated_as_no_headers() {
        let mut operation = operation("GetHello");
        operation.headers = Some(BTreeMap::new());

        let seed =
            build_laboratory_seed(&config_with_operations(vec![operation])).expect("should build");

        assert!(seed.operations[0].headers.is_none());
    }

    #[test]
    fn a_configured_value_cannot_break_out_of_the_script_element() {
        let mut op = operation("Evil");
        op.query =
            "query { field } // </script><script>alert('xss')</script> <!-- ${'</SCRIPT'} -->"
                .to_string();

        let html = render_laboratory_html(TEMPLATE, &config_with_operations(vec![op])).unwrap();

        assert!(
            !html.to_lowercase().contains("</script><script>"),
            "the injected value escaped the string literal: {html}"
        );
        assert!(
            !html.contains("<!--"),
            "the injected value emitted an HTML comment: {html}"
        );
        assert_eq!(
            html.to_lowercase().matches("</script>").count(),
            1,
            "unexpected number of closing script tags: {html}"
        );

        // The browser still parses back exactly the configured value.
        assert!(injected_operation_query(&html, 0).contains("</script><script>"));
    }

    #[test]
    fn a_collection_operation_query_cannot_break_out_of_the_element() {
        let mut op = operation("Evil");
        op.query = "query { field } // </script><script>alert(1)</script>".to_string();

        let html = render_laboratory_html(
            TEMPLATE,
            &config_with_collections(vec![collection("Onboarding", vec![op])]),
        )
        .unwrap();

        assert!(
            !html.to_lowercase().contains("</script><script>"),
            "the collection query escaped the string literal: {html}"
        );
        assert_eq!(
            html.to_lowercase().matches("</script>").count(),
            1,
            "unexpected number of closing script tags: {html}"
        );
    }

    #[test]
    fn line_separators_survive_the_round_trip() {
        // U+2028 is valid inside JSON but terminates a JavaScript string literal.
        let mut op = operation("Sep");
        op.query = "query { field } # \u{2028}\u{2029}".to_string();

        let html = render_laboratory_html(TEMPLATE, &config_with_operations(vec![op])).unwrap();

        assert_eq!(
            injected_operation_query(&html, 0),
            "query { field } # \u{2028}\u{2029}",
            "line separators must survive"
        );
    }
}
