//! Builder for constructing diagram specifications.

use super::{Combination, DiagramSpec, InputType};
use crate::constants::{MAX_SETS, MAX_SETS_HARD_CAP};
use crate::error::DiagramError;
use std::collections::{HashMap, HashSet};

/// Builder for creating diagram specifications with a fluent API.
///
/// The specification is shape-agnostic - the shape type is determined when
/// fitting the diagram using a `Fitter`, not when building the spec.
///
/// # Examples
///
/// ```
/// use eunoia::{DiagramSpecBuilder, InputType, Fitter};
/// use eunoia::geometry::shapes::Circle;
///
/// let spec = DiagramSpecBuilder::new()
///     .set("A", 5.0)
///     .set("B", 2.0)
///     .intersection(&["A", "B"], 1.0)
///     .input_type(InputType::Exclusive)
///     .build()
///     .expect("Failed to build diagram specification");
///
/// // Shape type is chosen when fitting
/// let layout = Fitter::<Circle>::new(&spec).fit().unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct DiagramSpecBuilder {
    combinations: HashMap<Combination, f64>,
    input_type: Option<InputType>,
    set_order: Vec<String>,
    max_sets: Option<usize>,
    complement: Option<f64>,
}

impl Default for DiagramSpecBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl DiagramSpecBuilder {
    /// Creates a new diagram builder.
    pub fn new() -> Self {
        DiagramSpecBuilder {
            combinations: HashMap::new(),
            input_type: None,
            set_order: Vec::new(),
            max_sets: None,
            complement: None,
        }
    }

    /// Adds a single set with the given value.
    ///
    /// # Arguments
    ///
    /// * `name` - The name of the set
    /// * `value` - The size/value for this set
    ///
    /// # Examples
    ///
    /// ```
    /// use eunoia::DiagramSpecBuilder;
    ///
    ///
    /// let builder = DiagramSpecBuilder::new()
    ///     .set("A", 10.0);
    /// ```
    pub fn set(mut self, name: impl Into<String>, value: f64) -> Self {
        let name_string = name.into();
        let combination = Combination::new(&[&name_string]);
        // Track order of first occurrence
        if !self.set_order.contains(&name_string) {
            self.set_order.push(name_string.clone());
        }
        self.combinations.insert(combination, value);
        self
    }

    /// Adds an intersection of multiple sets with the given value.
    ///
    /// # Arguments
    ///
    /// * `sets` - A slice of set names to intersect
    /// * `value` - The size/value for this intersection
    ///
    /// # Examples
    ///
    /// ```
    /// use eunoia::DiagramSpecBuilder;
    ///
    ///
    /// let builder = DiagramSpecBuilder::new()
    ///     .set("A", 10.0)
    ///     .set("B", 8.0)
    ///     .intersection(&["A", "B"], 2.0);
    /// ```
    pub fn intersection(mut self, sets: &[&str], value: f64) -> Self {
        let combination = Combination::new(sets);
        for &name in sets {
            if !self.set_order.iter().any(|n| n == name) {
                self.set_order.push(name.to_string());
            }
        }
        self.combinations.insert(combination, value);
        self
    }

    /// Sets how the input values should be interpreted.
    ///
    /// # Arguments
    ///
    /// * `input_type` - Whether values are exclusive or inclusive
    ///
    /// # Examples
    ///
    /// ```
    /// use eunoia::{DiagramSpecBuilder, InputType};
    ///
    ///
    /// let builder = DiagramSpecBuilder::new()
    ///     .input_type(InputType::Exclusive);
    /// ```
    pub fn input_type(mut self, input_type: InputType) -> Self {
        self.input_type = Some(input_type);
        self
    }

    /// Overrides the maximum number of sets allowed in this specification.
    ///
    /// Defaults to [`crate::constants::MAX_SETS`] (32) when unset. Callers
    /// that knowingly need to handle larger diagrams can raise the cap up
    /// to [`crate::constants::MAX_SETS_HARD_CAP`] (63), the absolute
    /// representational limit set by the `usize` bitset encoding of region
    /// masks. Values above the hard cap are silently clamped to it.
    ///
    /// Note that increasing this value does not make large dense diagrams
    /// tractable: a fully-overlapping `n`-set diagram has `2^n - 1` regions,
    /// so memory and runtime grow exponentially. Sparse inputs (most
    /// subsets empty) remain tractable at any `n` up to the hard cap.
    ///
    /// # Examples
    ///
    /// ```
    /// use eunoia::DiagramSpecBuilder;
    ///
    /// let builder = DiagramSpecBuilder::new()
    ///     .set("A", 1.0)
    ///     .max_sets(40);
    /// ```
    pub fn max_sets(mut self, max_sets: usize) -> Self {
        self.max_sets = Some(max_sets.min(MAX_SETS_HARD_CAP));
        self
    }

    /// Sets the complement: the area outside every named set in the diagram.
    ///
    /// When provided, the fitter jointly optimises an axis-aligned bounding
    /// rectangle (the "container") together with the diagram shapes. The
    /// container's area minus the union of clipped shapes is matched against
    /// this value via the existing per-region loss, pinning the otherwise-free
    /// absolute scale and pulling shapes that would spill outside the box back
    /// in.
    ///
    /// Must be `≥ 0`. Setting `0.0` is meaningful (it asks for a tight
    /// container). Omitting this setter (or never calling it) preserves the
    /// classic shape-only fit path.
    ///
    /// # Examples
    ///
    /// ```
    /// use eunoia::{DiagramSpecBuilder, InputType};
    ///
    /// let spec = DiagramSpecBuilder::new()
    ///     .set("A", 25.0)
    ///     .set("B", 25.0)
    ///     .intersection(&["A", "B"], 5.0)
    ///     .complement(145.0)        // universe area = 200
    ///     .input_type(InputType::Exclusive)
    ///     .build()
    ///     .unwrap();
    /// ```
    pub fn complement(mut self, value: f64) -> Self {
        self.complement = Some(value);
        self
    }

    /// Builds the diagram specification, validating all inputs.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - No sets are defined
    /// - Any value is negative
    /// - An intersection references undefined sets
    ///
    /// # Examples
    ///
    /// ```
    /// use eunoia::{DiagramSpecBuilder, InputType};
    ///
    ///
    /// let spec = DiagramSpecBuilder::new()
    ///     .set("A", 5.0)
    ///     .set("B", 2.0)
    ///     .intersection(&["A", "B"], 1.0)
    ///     .build()
    ///     .expect("Failed to build diagram specification");
    /// ```
    pub fn build(self) -> Result<DiagramSpec, DiagramError> {
        // Check that we have at least one set
        if self.combinations.is_empty() {
            return Err(DiagramError::EmptySets);
        }

        // Collect all unique set names from all combinations
        let mut all_set_names = HashSet::new();
        let mut single_sets = HashSet::new();

        for combination in self.combinations.keys() {
            for set_name in combination.sets() {
                all_set_names.insert(set_name.clone());
            }
            if combination.len() == 1 {
                single_sets.insert(combination.sets()[0].clone());
            }
        }

        // For sets that appear in intersections but not as single sets,
        // implicitly add them with value 0.0 (they exist entirely within intersections)
        let mut combinations = self.combinations;
        for set_name in &all_set_names {
            if !single_sets.contains(set_name) {
                let combination = Combination::new(&[set_name.as_str()]);
                combinations.insert(combination, 0.0);
                single_sets.insert(set_name.clone());
            }
        }

        // `set_order` is updated by both `set()` and `intersection()` on
        // first occurrence, so it already contains every name reachable from
        // `combinations.keys()`. Cloning preserves first-seen order for a
        // deterministic `set_names()` — previously we appended unseen names
        // by iterating `all_set_names` (a `HashSet`), which produced
        // run-to-run shuffling for intersection-only sets.
        let ordered_set_names: Vec<String> = self.set_order.clone();
        debug_assert!(
            ordered_set_names.iter().all(|n| all_set_names.contains(n))
                && all_set_names.len() == ordered_set_names.len(),
            "set_order must mirror combinations.keys()'s name set",
        );

        // Enforce the cap on set count. The internal RegionMask is a usize
        // bitset, but the default cap (MAX_SETS) sits well below the bit
        // limit because a fully overlapping diagram has 2^n - 1 regions.
        // Callers can raise the cap via `max_sets`, clamped to
        // MAX_SETS_HARD_CAP.
        let effective_max = self.max_sets.unwrap_or(MAX_SETS);
        if ordered_set_names.len() > effective_max {
            return Err(DiagramError::TooManySets {
                requested: ordered_set_names.len(),
                max: effective_max,
            });
        }

        // Validate that all values are non-negative
        for (combination, &value) in &combinations {
            if value < 0.0 {
                return Err(DiagramError::InvalidValue {
                    combination: combination.to_string(),
                    value,
                });
            }
        }

        // Reject negative complement. Use the sentinel `<complement>` for
        // the offending-combination string since the all-zeros region has
        // no `Combination` representation.
        if let Some(c) = self.complement
            && c < 0.0
        {
            return Err(DiagramError::InvalidValue {
                combination: "<complement>".to_string(),
                value: c,
            });
        }

        // Reduce input to the canonical exclusive representation. The
        // inclusive view is computed on demand by `DiagramSpec` and is
        // never stored in the spec; this keeps build cost proportional to
        // input size, with no `2^|c|` subset walk per input combination.
        let input_type = self.input_type.unwrap_or_default();
        let exclusive_areas = match input_type {
            InputType::Exclusive => combinations,
            InputType::Inclusive => DiagramSpec::inclusive_to_exclusive_static(&combinations)?,
        };

        Ok(DiagramSpec {
            exclusive_areas,
            input_type,
            set_names: ordered_set_names,
            complement: self.complement,
        })
    }
}

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

    #[test]
    fn test_builder_simple() {
        let spec = DiagramSpecBuilder::new()
            .set("A", 5.0)
            .set("B", 2.0)
            .build()
            .unwrap();

        assert_eq!(spec.set_names().len(), 2);
        assert!(spec.set_names().contains(&"A".to_string()));
        assert!(spec.set_names().contains(&"B".to_string()));

        // Both representations should be available
        assert!(spec.get_inclusive(&Combination::new(&["A"])).is_some());
        assert!(spec.get_exclusive(&Combination::new(&["A"])).is_some());
    }

    #[test]
    fn test_builder_with_intersection() {
        let spec = DiagramSpecBuilder::new()
            .set("A", 5.0)
            .set("B", 2.0)
            .intersection(&["A", "B"], 1.0)
            .input_type(InputType::Exclusive)
            .build()
            .unwrap();

        assert_eq!(spec.input_type(), InputType::Exclusive);
        assert_eq!(spec.exclusive_areas().len(), 3);
        assert_eq!(spec.inclusive_areas().len(), 3);
    }

    #[test]
    fn test_builder_implicit_zero_set() {
        // When a set is referenced in an intersection but not defined as a single set,
        // it should be implicitly added with value 0.0
        let spec = DiagramSpecBuilder::new()
            .set("B", 5.0)
            .intersection(&["A", "B"], 1.0)
            .input_type(InputType::Exclusive)
            .build()
            .unwrap();

        // Set A should be implicitly added with exclusive value 0.0
        let combo_a = Combination::new(&["A"]);
        assert_eq!(spec.get_exclusive(&combo_a), Some(0.0));

        // In inclusive representation, A should have total size = 1.0 (just the intersection)
        assert_eq!(spec.get_inclusive(&combo_a), Some(1.0));
    }

    #[test]
    fn test_contained_set_exclusive() {
        // Test case: A=0, B=5, A&B=1 (exclusive)
        // This means A is entirely contained within B
        let spec = DiagramSpecBuilder::new()
            .set("A", 0.0)
            .set("B", 5.0)
            .intersection(&["A", "B"], 1.0)
            .input_type(InputType::Exclusive)
            .build()
            .unwrap();

        // Exclusive areas
        assert_eq!(spec.get_exclusive(&Combination::new(&["A"])), Some(0.0));
        assert_eq!(spec.get_exclusive(&Combination::new(&["B"])), Some(5.0));
        assert_eq!(
            spec.get_exclusive(&Combination::new(&["A", "B"])),
            Some(1.0)
        );

        // Inclusive areas (total sizes)
        assert_eq!(spec.get_inclusive(&Combination::new(&["A"])), Some(1.0)); // A total = 0 + 1
        assert_eq!(spec.get_inclusive(&Combination::new(&["B"])), Some(6.0)); // B total = 5 + 1
        assert_eq!(
            spec.get_inclusive(&Combination::new(&["A", "B"])),
            Some(1.0)
        );
    }

    #[test]
    fn set_names_follow_first_seen_input_order() {
        // Mix of `set` and `intersection`-only entries. Names must come out
        // in first-seen order — previously, intersection-only sets were
        // appended via a `HashSet` walk, which produced run-to-run shuffling.
        let spec = DiagramSpecBuilder::new()
            .set("B", 1.0)
            .intersection(&["B", "D"], 1.0)
            .set("A", 1.0)
            .intersection(&["C", "A"], 1.0)
            .input_type(InputType::Exclusive)
            .build()
            .unwrap();

        assert_eq!(
            spec.set_names(),
            &[
                "B".to_string(),
                "D".to_string(),
                "A".to_string(),
                "C".to_string()
            ]
        );

        // Repeat builds with intersection-only inputs to pin determinism for
        // the path that previously walked a `HashSet` of names.
        for _ in 0..4 {
            let s = DiagramSpecBuilder::new()
                .intersection(&["X", "Y"], 1.0)
                .intersection(&["Y", "Z"], 1.0)
                .intersection(&["X", "Z"], 1.0)
                .input_type(InputType::Exclusive)
                .build()
                .unwrap();
            assert_eq!(
                s.set_names(),
                &["X".to_string(), "Y".to_string(), "Z".to_string()]
            );
        }
    }

    #[test]
    fn test_implicit_set_from_three_way() {
        // Test case: A=0, B=5, A&B=1, A&B&C=0.1 (disjoint)
        // Set C is only referenced in the 3-way intersection
        let spec = DiagramSpecBuilder::new()
            .set("A", 0.0)
            .set("B", 5.0)
            .intersection(&["A", "B"], 1.0)
            .intersection(&["A", "B", "C"], 0.1)
            .input_type(InputType::Exclusive)
            .build()
            .unwrap();

        // All three sets should exist
        assert_eq!(spec.set_names().len(), 3);
        assert!(spec.set_names().contains(&"A".to_string()));
        assert!(spec.set_names().contains(&"B".to_string()));
        assert!(spec.set_names().contains(&"C".to_string()));

        // Check that C was implicitly added with value 0.0
        assert_eq!(spec.get_exclusive(&Combination::new(&["C"])), Some(0.0));

        // C's inclusive area should be 0.1 (just the 3-way intersection)
        assert_eq!(spec.get_inclusive(&Combination::new(&["C"])), Some(0.1));
    }

    #[test]
    fn test_nested_containment() {
        // Test case: B=5, A&B=2, A&B&C=1 (disjoint)
        // A is contained in B, and C is contained in A&B
        let spec = DiagramSpecBuilder::new()
            .set("B", 5.0)
            .intersection(&["A", "B"], 2.0)
            .intersection(&["A", "B", "C"], 1.0)
            .input_type(InputType::Exclusive)
            .build()
            .unwrap();

        // All three sets should exist
        assert_eq!(spec.set_names().len(), 3);

        // Expected exclusive areas
        assert_eq!(spec.get_exclusive(&Combination::new(&["A"])), Some(0.0));
        assert_eq!(spec.get_exclusive(&Combination::new(&["B"])), Some(5.0));
        assert_eq!(spec.get_exclusive(&Combination::new(&["C"])), Some(0.0));
        assert_eq!(
            spec.get_exclusive(&Combination::new(&["A", "B"])),
            Some(2.0)
        );
        assert_eq!(
            spec.get_exclusive(&Combination::new(&["A", "B", "C"])),
            Some(1.0)
        );

        // Expected inclusive areas (total sizes)
        assert_eq!(spec.get_inclusive(&Combination::new(&["A"])), Some(3.0)); // 0 + 2 + 1
        assert_eq!(spec.get_inclusive(&Combination::new(&["B"])), Some(8.0)); // 5 + 2 + 1
        assert_eq!(spec.get_inclusive(&Combination::new(&["C"])), Some(1.0)); // 0 + 1
    }

    #[test]
    fn test_builder_negative_value_error() {
        let result = DiagramSpecBuilder::new().set("A", -5.0).build();

        assert!(matches!(result, Err(DiagramError::InvalidValue { .. })));
    }

    #[test]
    fn test_builder_empty_error() {
        let result = DiagramSpecBuilder::new().build();
        assert!(matches!(result, Err(DiagramError::EmptySets)));
    }

    #[test]
    fn test_three_way_intersection() {
        let spec = DiagramSpecBuilder::new()
            .set("A", 10.0)
            .set("B", 8.0)
            .set("C", 12.0)
            .intersection(&["A", "B"], 2.0)
            .intersection(&["A", "C"], 3.0)
            .intersection(&["B", "C"], 1.0)
            .intersection(&["A", "B", "C"], 0.5)
            .build()
            .unwrap();

        assert_eq!(spec.set_names().len(), 3);
        assert_eq!(spec.exclusive_areas().len(), 7);
        assert_eq!(spec.inclusive_areas().len(), 7);
    }

    #[test]
    fn test_too_many_sets_rejected() {
        // MAX_SETS + 1 distinct singletons should be rejected with TooManySets,
        // before any 2^n preprocessing step has a chance to allocate.
        let mut builder = DiagramSpecBuilder::new();
        for i in 0..(MAX_SETS + 1) {
            builder = builder.set(format!("S{i}"), 1.0);
        }
        let result = builder.build();
        assert!(
            matches!(
                result,
                Err(DiagramError::TooManySets { requested, max })
                if requested == MAX_SETS + 1 && max == MAX_SETS
            ),
            "expected TooManySets for n = MAX_SETS + 1, got {result:?}"
        );
    }

    #[test]
    fn test_max_sets_accepted() {
        // Exactly MAX_SETS singletons should build cleanly. The sparse
        // exclusive→inclusive path must not blow up at this size.
        let mut builder = DiagramSpecBuilder::new();
        for i in 0..MAX_SETS {
            builder = builder.set(format!("S{i}"), 1.0);
        }
        let spec = builder.build().expect("MAX_SETS singletons should build");
        assert_eq!(spec.set_names().len(), MAX_SETS);
        // Sparse: exactly one inclusive entry per singleton, no power-set blowup.
        assert_eq!(spec.inclusive_areas().len(), MAX_SETS);
    }

    #[test]
    fn test_max_sets_override_raises_cap() {
        // With an override, MAX_SETS + 1 singletons should now build cleanly.
        let n = MAX_SETS + 1;
        let mut builder = DiagramSpecBuilder::new().max_sets(n);
        for i in 0..n {
            builder = builder.set(format!("S{i}"), 1.0);
        }
        let spec = builder
            .build()
            .expect("MAX_SETS + 1 singletons should build with override");
        assert_eq!(spec.set_names().len(), n);
    }

    #[test]
    fn test_max_sets_override_still_enforces_limit() {
        // Override of N rejects N + 1 distinct singletons.
        let limit = MAX_SETS + 2;
        let mut builder = DiagramSpecBuilder::new().max_sets(limit);
        for i in 0..(limit + 1) {
            builder = builder.set(format!("S{i}"), 1.0);
        }
        let result = builder.build();
        assert!(
            matches!(
                result,
                Err(DiagramError::TooManySets { requested, max })
                if requested == limit + 1 && max == limit
            ),
            "expected TooManySets reporting the overridden cap, got {result:?}"
        );
    }

    #[test]
    fn test_max_sets_override_clamped_to_hard_cap() {
        // Asking for more than the hard cap silently clamps. Building one
        // more set than the hard cap must report `max = MAX_SETS_HARD_CAP`.
        let n = MAX_SETS_HARD_CAP + 1;
        let mut builder = DiagramSpecBuilder::new().max_sets(usize::MAX);
        for i in 0..n {
            builder = builder.set(format!("S{i}"), 1.0);
        }
        let result = builder.build();
        assert!(
            matches!(
                result,
                Err(DiagramError::TooManySets { requested, max })
                if requested == n && max == MAX_SETS_HARD_CAP
            ),
            "expected TooManySets clamped to MAX_SETS_HARD_CAP, got {result:?}"
        );
    }

    #[test]
    fn test_max_sets_below_default_tightens_cap() {
        // Override below the default tightens the limit.
        let mut builder = DiagramSpecBuilder::new().max_sets(2);
        for i in 0..3 {
            builder = builder.set(format!("S{i}"), 1.0);
        }
        let result = builder.build();
        assert!(
            matches!(
                result,
                Err(DiagramError::TooManySets { requested, max })
                if requested == 3 && max == 2
            ),
            "expected TooManySets with tightened cap, got {result:?}"
        );
    }

    #[test]
    fn complement_field_round_trips_through_builder() {
        let spec = DiagramSpecBuilder::new()
            .set("A", 5.0)
            .set("B", 3.0)
            .complement(42.0)
            .build()
            .unwrap();
        assert_eq!(spec.complement(), Some(42.0));
        assert_eq!(spec.complement, Some(42.0));
    }

    #[test]
    fn builder_omits_complement_by_default() {
        let spec = DiagramSpecBuilder::new()
            .set("A", 5.0)
            .set("B", 3.0)
            .build()
            .unwrap();
        assert_eq!(spec.complement(), None);
    }

    #[test]
    fn builder_rejects_negative_complement() {
        let result = DiagramSpecBuilder::new()
            .set("A", 5.0)
            .set("B", 3.0)
            .complement(-1.0)
            .build();
        assert!(
            matches!(
                result,
                Err(DiagramError::InvalidValue { ref combination, value })
                    if combination == "<complement>" && value < 0.0
            ),
            "expected InvalidValue for negative complement, got {result:?}"
        );
    }

    #[test]
    fn complement_works_with_inclusive_input() {
        // Inclusive input goes through inclusion-exclusion in the builder.
        // The complement is orthogonal — it should round-trip unchanged.
        let spec = DiagramSpecBuilder::new()
            .set("A", 10.0)
            .set("B", 8.0)
            .intersection(&["A", "B"], 5.0)
            .complement(20.0)
            .input_type(InputType::Inclusive)
            .build()
            .unwrap();

        assert_eq!(spec.complement(), Some(20.0));
        // Inclusion–exclusion: A=10, B=8, A∩B=5 → exclusive A=5, B=3, A∩B=5.
        assert_eq!(spec.get_exclusive(&Combination::new(&["A"])), Some(5.0));
        assert_eq!(spec.get_exclusive(&Combination::new(&["B"])), Some(3.0));
        assert_eq!(
            spec.get_exclusive(&Combination::new(&["A", "B"])),
            Some(5.0)
        );
    }

    #[test]
    fn complement_rejects_inconsistent_inclusive_input() {
        // Inclusive input that decomposes to a negative exclusive area is
        // rejected the same way regardless of whether complement is set.
        // (A=5, B=5, A∩B=10 means A∩B > A or B, which is impossible.)
        let result = DiagramSpecBuilder::new()
            .set("A", 5.0)
            .set("B", 5.0)
            .intersection(&["A", "B"], 10.0)
            .complement(3.0)
            .input_type(InputType::Inclusive)
            .build();
        assert!(
            matches!(result, Err(DiagramError::InvalidValue { .. })),
            "inclusive-input inconsistency should be rejected even with complement, got {result:?}",
        );
    }

    #[test]
    fn builder_accepts_zero_complement() {
        // complement = 0 is a meaningful "tight container" request.
        let spec = DiagramSpecBuilder::new()
            .set("A", 5.0)
            .set("B", 3.0)
            .complement(0.0)
            .build()
            .unwrap();
        assert_eq!(spec.complement(), Some(0.0));
    }

    #[test]
    fn test_sparse_exclusive_to_inclusive_no_power_set_blowup() {
        // 25 disjoint singletons + one pair. With the old dense
        // exclusive→inclusive, this would walk 2^25 ≈ 33M masks. The sparse
        // version touches only the input combinations + their subsets.
        let mut builder = DiagramSpecBuilder::new();
        for i in 0..25 {
            builder = builder.set(format!("S{i}"), 1.0);
        }
        let spec = builder
            .intersection(&["S0", "S1"], 0.5)
            .input_type(InputType::Exclusive)
            .build()
            .expect("sparse 25-set spec should build");

        // S0 inclusive = own exclusive (1.0) + pair (0.5) = 1.5
        assert!(
            (spec
                .get_inclusive(&Combination::new(&["S0"]))
                .expect("S0 inclusive")
                - 1.5)
                .abs()
                < 1e-10
        );
        // Untouched singletons keep their input area as inclusive area.
        assert!(
            (spec
                .get_inclusive(&Combination::new(&["S5"]))
                .expect("S5 inclusive")
                - 1.0)
                .abs()
                < 1e-10
        );
        // The pair appears in the inclusive map exactly once.
        assert!(
            (spec
                .get_inclusive(&Combination::new(&["S0", "S1"]))
                .expect("S0&S1 inclusive")
                - 0.5)
                .abs()
                < 1e-10
        );
        // No spurious 3+ way regions get materialized.
        assert!(
            spec.get_inclusive(&Combination::new(&["S0", "S1", "S2"]))
                .is_none()
        );
        // Total inclusive entries = 25 singletons + 1 pair = 26 (sparse).
        assert_eq!(spec.inclusive_areas().len(), 26);
    }

    #[test]
    fn test_get_combination() {
        let spec = DiagramSpecBuilder::new()
            .set("A", 5.0)
            .set("B", 2.0)
            .intersection(&["A", "B"], 1.0)
            .build()
            .unwrap();

        let combo_ab = Combination::new(&["A", "B"]);
        assert_eq!(spec.get_inclusive(&combo_ab), Some(1.0));

        let combo_ac = Combination::new(&["A", "C"]);
        assert_eq!(spec.get_inclusive(&combo_ac), None);
    }

    #[test]
    fn test_build_scales_with_large_kway_intersection() {
        // A single 30-way intersection used to walk 2^30 ≈ 1B subsets at
        // build time via the eager exclusive→inclusive expansion. With the
        // lazy inclusive view, build cost is bounded by the input size.
        let n = 30;
        let names: Vec<String> = (0..n).map(|i| format!("S{i}")).collect();
        let mut builder = DiagramSpecBuilder::new();
        for name in &names {
            builder = builder.set(name.as_str(), 1.0);
        }
        let intersection_refs: Vec<&str> = names.iter().map(String::as_str).collect();
        let start = std::time::Instant::now();
        let spec = builder
            .intersection(&intersection_refs, 0.5)
            .input_type(InputType::Exclusive)
            .build()
            .expect("30-way spec should build");
        let elapsed = start.elapsed();

        // 30 singletons + 1 thirty-way intersection.
        assert_eq!(spec.exclusive_areas().len(), n + 1);

        // A loose timing bound — anything under a second proves we're not
        // walking 2^30 subsets. On a typical dev machine this finishes in
        // microseconds.
        assert!(
            elapsed < std::time::Duration::from_secs(1),
            "30-way build took {elapsed:?}; expected sub-second"
        );
    }
}
