//! Pure, immutable terminal layout model (the "spreadsheet-like" recursive grid)
//! and its operations.
//!
//! See ARCHITECTURE.md §7. The model is a recursive split/grid tree. All
//! mutating operations are **pure functions** `&LayoutTree -> Result<LayoutTree,
//! LayoutError>` returning a new tree, which makes them trivially testable and
//! enables undo/redo at the application layer.

use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId};

/// Direction of a [`SplitContainer`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Direction {
    /// Children laid out left→right (columns).
    Row,
    /// Children laid out top→bottom (rows).
    Column,
}

/// Returns `true` when a boolean is `false`. Used as a `skip_serializing_if`
/// predicate so that default (`false`) flags are omitted from the serialized
/// form, preserving backward/forward compatibility with leaves that predate the
/// field.
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
    !*b
}

/// A leaf cell hosting zero or one terminal session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LeafCell {
    /// Node identifier.
    pub id: NodeId,
    /// The hosted session, if any (0 or 1).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub session: Option<SessionId>,
    /// The agent to launch automatically in this cell, if any.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent: Option<AgentId>,
    /// **Id de paire IdeA** (`ConversationId`, UUID) de la conversation hébergée par
    /// la cellule — pivot **logique** et **model-agnostic** de la reprise (ARCHITECTURE
    /// §19.7). C'est sous cette clé que le log canonique et le `handoff.md` sont
    /// rangés (`P6b`) puis retrouvés au (re)lancement (`P7`) ; elle **survit** au swap
    /// de profil. **Distinct** de l'id de session moteur (resumable Claude/Codex), qui
    /// vit désormais dans [`Self::engine_session_id`] / `providers.json`, plus jamais
    /// ici. Reste nommé `conversation_id` (compat sérialisation/DTO).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub conversation_id: Option<String>,
    /// **Cache** de l'id de session **moteur** (resumable du provider courant : id
    /// Claude `--resume`, ou l'UUID minté pour `--session-id`) — distinct de l'id de
    /// paire ([`Self::conversation_id`]). Additif (`None` par défaut) ; la **source de
    /// vérité** du resumable est `providers.json` (ARCHITECTURE §19.7, lot P8b). Sert
    /// au plan de session `--resume` (P8c) et à l'inspection/popup, sans jamais
    /// polluer la clé logique de la conversation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub engine_session_id: Option<String>,
    /// Whether the cell's agent process was running at the moment the cell was
    /// last closed. Used to decide whether to auto-resume the agent on reopen.
    #[serde(default, skip_serializing_if = "is_false")]
    pub agent_was_running: bool,
}

impl LeafCell {
    /// Crée une feuille **vide** (ni session, ni agent, ni conversation), porteuse du
    /// seul `id`. Constructeur de commodité **additif** : il n'altère aucune
    /// construction par littéral existante, mais offre un point d'entrée stable face
    /// aux champs additifs (comme [`Self::engine_session_id`]) — les appelants
    /// composent ensuite avec les withers `with_*`.
    #[must_use]
    pub fn new(id: NodeId) -> Self {
        Self {
            id,
            session: None,
            agent: None,
            conversation_id: None,
            engine_session_id: None,
            agent_was_running: false,
        }
    }

    /// Wither additif : pose l'agent auto-lancé de la cellule.
    #[must_use]
    pub fn with_agent(mut self, agent: Option<AgentId>) -> Self {
        self.agent = agent;
        self
    }

    /// Wither additif : pose l'**id de paire IdeA** ([`Self::conversation_id`]).
    #[must_use]
    pub fn with_conversation(mut self, conversation_id: Option<String>) -> Self {
        self.conversation_id = conversation_id;
        self
    }

    /// Wither additif : pose le **cache de l'id de session moteur**
    /// ([`Self::engine_session_id`]).
    #[must_use]
    pub fn with_engine_session(mut self, engine_session_id: Option<String>) -> Self {
        self.engine_session_id = engine_session_id;
        self
    }
}

/// A weighted child within a [`SplitContainer`]. The `weight` is a *relative*
/// resizable share; the UI normalises it for rendering.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WeightedChild {
    /// The child node.
    pub node: LayoutNode,
    /// Relative weight; invariant: `> 0`.
    pub weight: f32,
}

/// A simple n-ary weighted split (rows or columns).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SplitContainer {
    /// Node identifier.
    pub id: NodeId,
    /// Split direction.
    pub direction: Direction,
    /// Ordered children (left→right / top→bottom).
    pub children: Vec<WeightedChild>,
}

/// A grid cell placement with spans (spreadsheet-style merging).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridCell {
    /// The hosted node (may itself be a split/grid — recursive).
    pub node: LayoutNode,
    /// Zero-based row index.
    pub row: u16,
    /// Zero-based column index.
    pub col: u16,
    /// Row span; invariant: `>= 1`.
    pub row_span: u16,
    /// Column span; invariant: `>= 1`.
    pub col_span: u16,
}

/// A spreadsheet-like grid with per-column / per-row weights and span-based
/// merging.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridContainer {
    /// Node identifier.
    pub id: NodeId,
    /// Column widths (relative); length = number of columns.
    pub col_weights: Vec<f32>,
    /// Row heights (relative); length = number of rows.
    pub row_weights: Vec<f32>,
    /// Cell placements with spans.
    pub cells: Vec<GridCell>,
}

/// A node in the layout tree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type", content = "node")]
pub enum LayoutNode {
    /// A terminal-hosting leaf.
    Leaf(LeafCell),
    /// A weighted split.
    Split(SplitContainer),
    /// A spreadsheet-style grid.
    Grid(GridContainer),
}

/// The root of a layout (one per tab).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LayoutTree {
    /// Root node.
    pub root: LayoutNode,
}

/// Errors produced by layout validation and operations.
#[derive(Debug, Clone, PartialEq, Error)]
pub enum LayoutError {
    /// A weight was not strictly positive.
    #[error("weight must be > 0, got {weight}")]
    NonPositiveWeight {
        /// Offending weight.
        weight: f32,
    },
    /// A split had no children.
    #[error("a split container must have at least one child")]
    EmptySplit,
    /// A grid span was less than one.
    #[error("grid span must be >= 1")]
    InvalidSpan,
    /// A grid cell extends beyond the grid bounds.
    #[error("grid cell at ({row},{col}) span ({row_span}x{col_span}) exceeds grid {rows}x{cols}")]
    SpanOutOfBounds {
        /// Cell row.
        row: u16,
        /// Cell column.
        col: u16,
        /// Row span.
        row_span: u16,
        /// Column span.
        col_span: u16,
        /// Grid rows.
        rows: u16,
        /// Grid cols.
        cols: u16,
    },
    /// Two grid cells overlap.
    #[error("grid cells overlap at ({row},{col})")]
    OverlappingCells {
        /// Row of the overlap.
        row: u16,
        /// Column of the overlap.
        col: u16,
    },
    /// Part of the grid surface is not covered by any cell.
    #[error("grid surface not fully covered: cell ({row},{col}) is empty")]
    UncoveredCell {
        /// Uncovered row.
        row: u16,
        /// Uncovered column.
        col: u16,
    },
    /// The same session appears in more than one leaf.
    #[error("session {0} appears in more than one leaf")]
    DuplicateSession(SessionId),
    /// A referenced node id was not found in the tree.
    #[error("node {0} not found")]
    NodeNotFound(NodeId),
    /// A merge/move spanned two distinct containers.
    #[error("operation cannot span two distinct containers")]
    CrossContainer,
    /// A referenced tab id was not found in any window.
    #[error("tab {0} not found")]
    TabNotFound(TabId),
}

impl LayoutTree {
    /// Wraps a root node into a tree (without validation).
    #[must_use]
    pub fn new(root: LayoutNode) -> Self {
        Self { root }
    }

    /// Convenience: a single-leaf tree.
    #[must_use]
    pub fn single(leaf: LeafCell) -> Self {
        Self {
            root: LayoutNode::Leaf(leaf),
        }
    }

    /// Validates **all** layout invariants on the whole tree:
    /// positive weights, valid/non-overlapping/fully-covering grid spans, and
    /// at-most-one-leaf-per-session uniqueness.
    ///
    /// # Errors
    /// Returns the first [`LayoutError`] encountered.
    pub fn validate(&self) -> Result<(), LayoutError> {
        let mut sessions = std::collections::HashSet::new();
        validate_node(&self.root, &mut sessions)
    }

    /// Splits the leaf `target` into a [`SplitContainer`] with the original leaf
    /// and a new leaf `new_leaf`, in the given `direction` with equal weights.
    ///
    /// Pure: returns a new validated tree.
    ///
    /// # Errors
    /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree,
    /// - any validation error of the resulting tree.
    pub fn split(
        &self,
        target: NodeId,
        direction: Direction,
        new_leaf: LeafCell,
        container_id: NodeId,
    ) -> Result<Self, LayoutError> {
        let mut found = false;
        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Leaf(leaf) = node {
                if leaf.id == target {
                    found = true;
                    return LayoutNode::Split(SplitContainer {
                        id: container_id,
                        direction,
                        children: vec![
                            WeightedChild {
                                node: LayoutNode::Leaf(leaf.clone()),
                                weight: 1.0,
                            },
                            WeightedChild {
                                node: LayoutNode::Leaf(new_leaf.clone()),
                                weight: 1.0,
                            },
                        ],
                    });
                }
            }
            node.clone()
        });
        if !found {
            return Err(LayoutError::NodeNotFound(target));
        }
        let tree = Self { root };
        tree.validate()?;
        Ok(tree)
    }

    /// Merges a [`SplitContainer`] identified by `container` back into a single
    /// node, keeping the child at `keep_index` and discarding the rest.
    ///
    /// Pure: returns a new validated tree.
    ///
    /// # Errors
    /// - [`LayoutError::NodeNotFound`] if `container` is not a split in the tree,
    /// - [`LayoutError::CrossContainer`] if `keep_index` is out of range,
    /// - any validation error of the resulting tree.
    pub fn merge(&self, container: NodeId, keep_index: usize) -> Result<Self, LayoutError> {
        let mut result: Result<(), LayoutError> = Ok(());
        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Split(split) = node {
                if split.id == container {
                    match split.children.get(keep_index) {
                        Some(child) => return child.node.clone(),
                        None => result = Err(LayoutError::CrossContainer),
                    }
                }
            }
            node.clone()
        });
        result?;
        if root == self.root {
            return Err(LayoutError::NodeNotFound(container));
        }
        let tree = Self { root };
        tree.validate()?;
        Ok(tree)
    }

    /// Resizes the children of a [`SplitContainer`] by assigning new `weights`.
    ///
    /// Pure: returns a new validated tree.
    ///
    /// # Errors
    /// - [`LayoutError::NodeNotFound`] if `container` is not a split,
    /// - [`LayoutError::CrossContainer`] if `weights.len()` differs from the
    ///   child count,
    /// - [`LayoutError::NonPositiveWeight`] (via validation) if any weight ≤ 0.
    pub fn resize(&self, container: NodeId, weights: &[f32]) -> Result<Self, LayoutError> {
        let mut outcome: Option<Result<(), LayoutError>> = None;
        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Split(split) = node {
                if split.id == container {
                    if split.children.len() != weights.len() {
                        outcome = Some(Err(LayoutError::CrossContainer));
                        return node.clone();
                    }
                    let children = split
                        .children
                        .iter()
                        .zip(weights.iter())
                        .map(|(child, &w)| WeightedChild {
                            node: child.node.clone(),
                            weight: w,
                        })
                        .collect();
                    outcome = Some(Ok(()));
                    return LayoutNode::Split(SplitContainer {
                        id: split.id,
                        direction: split.direction,
                        children,
                    });
                }
            }
            node.clone()
        });
        match outcome {
            Some(Ok(())) => {
                let tree = Self { root };
                tree.validate()?;
                Ok(tree)
            }
            Some(Err(e)) => Err(e),
            None => Err(LayoutError::NodeNotFound(container)),
        }
    }

    /// Moves the session currently hosted by leaf `from` to leaf `to`.
    ///
    /// `from` is left empty; `to` must currently be empty. This models dragging
    /// a terminal between cells without duplicating it. Pure: returns a new
    /// validated tree.
    ///
    /// # Errors
    /// - [`LayoutError::NodeNotFound`] if either leaf is missing,
    /// - [`LayoutError::CrossContainer`] if `from` has no session or `to` is
    ///   occupied,
    /// - any validation error of the resulting tree.
    pub fn move_session(&self, from: NodeId, to: NodeId) -> Result<Self, LayoutError> {
        let session = self.session_in_leaf(from)?;
        let Some(session) = session else {
            return Err(LayoutError::CrossContainer);
        };
        // Target must exist and be empty.
        match self.session_in_leaf(to)? {
            None => {}
            Some(_) => return Err(LayoutError::CrossContainer),
        }
        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Leaf(leaf) = node {
                if leaf.id == from {
                    let mut leaf = leaf.clone();
                    leaf.session = None;
                    return LayoutNode::Leaf(leaf);
                }
                if leaf.id == to {
                    let mut leaf = leaf.clone();
                    leaf.session = Some(session);
                    return LayoutNode::Leaf(leaf);
                }
            }
            node.clone()
        });
        let tree = Self { root };
        tree.validate()?;
        Ok(tree)
    }

    /// Attaches (or, with `None`, detaches) a [`SessionId`] to the leaf `target`.
    ///
    /// This is the bridge between the layout and the terminal layer (L3/L4): when
    /// [`crate::terminal::TerminalSession`] is opened for a cell, the application
    /// records its id in the hosting leaf with this pure operation.
    ///
    /// Pure: returns a new validated tree.
    ///
    /// # Errors
    /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree,
    /// - [`LayoutError::DuplicateSession`] (via validation) if `session` is already
    ///   hosted by another leaf.
    pub fn set_session(
        &self,
        target: NodeId,
        session: Option<SessionId>,
    ) -> Result<Self, LayoutError> {
        let mut found = false;
        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Leaf(leaf) = node {
                if leaf.id == target {
                    found = true;
                    let mut leaf = leaf.clone();
                    leaf.session = session;
                    return LayoutNode::Leaf(leaf);
                }
            }
            node.clone()
        });
        if !found {
            return Err(LayoutError::NodeNotFound(target));
        }
        let tree = Self { root };
        tree.validate()?;
        Ok(tree)
    }

    /// Attaches an existing live [`SessionId`] to `target`, moving it away from
    /// any other visible leaf first.
    ///
    /// This models the IdeA-first visible/background contract: a live agent
    /// session may keep running while detached from the grid, and opening a cell
    /// on that already-running session simply re-attaches its view. The session is
    /// visible in at most one cell because any previous host is cleared before the
    /// target is set.
    ///
    /// Pure: returns a new validated tree.
    ///
    /// # Errors
    /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree,
    /// - [`LayoutError::CrossContainer`] if `target` hosts a different session.
    pub fn attach_session(&self, target: NodeId, session: SessionId) -> Result<Self, LayoutError> {
        let target_session = self.session_in_leaf(target)?;
        if let Some(existing) = target_session {
            if existing == session {
                return Ok(self.clone());
            }
            return Err(LayoutError::CrossContainer);
        }

        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Leaf(leaf) = node {
                if leaf.session == Some(session) && leaf.id != target {
                    let mut leaf = leaf.clone();
                    leaf.session = None;
                    return LayoutNode::Leaf(leaf);
                }
                if leaf.id == target {
                    let mut leaf = leaf.clone();
                    leaf.session = Some(session);
                    return LayoutNode::Leaf(leaf);
                }
            }
            node.clone()
        });
        let tree = Self { root };
        tree.validate()?;
        Ok(tree)
    }

    /// Attaches (or, with `None`, detaches) an [`AgentId`] to the leaf `target`.
    ///
    /// Records which agent should be auto-launched in the cell (feature #3).
    ///
    /// Pure: returns a new validated tree.
    ///
    /// # Errors
    /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
    pub fn set_cell_agent(
        &self,
        target: NodeId,
        agent: Option<AgentId>,
    ) -> Result<Self, LayoutError> {
        let mut found = false;
        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Leaf(leaf) = node {
                if leaf.id == target {
                    found = true;
                    let mut leaf = leaf.clone();
                    leaf.agent = agent;
                    return LayoutNode::Leaf(leaf);
                }
            }
            node.clone()
        });
        if !found {
            return Err(LayoutError::NodeNotFound(target));
        }
        let tree = Self { root };
        tree.validate()?;
        Ok(tree)
    }

    /// Sets (or, with `None`, clears) the persistent CLI `conversation_id` on
    /// the leaf `target`.
    ///
    /// Unlike [`Self::set_session`] (the ephemeral PTY binding), the conversation
    /// id survives PTY close/reopen and is what lets the agent CLI *resume* its
    /// previous conversation.
    ///
    /// Pure: returns a new validated tree.
    ///
    /// # Errors
    /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
    pub fn set_cell_conversation(
        &self,
        target: NodeId,
        conversation_id: Option<String>,
    ) -> Result<Self, LayoutError> {
        let mut found = false;
        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Leaf(leaf) = node {
                if leaf.id == target {
                    found = true;
                    let mut leaf = leaf.clone();
                    leaf.conversation_id = conversation_id.clone();
                    return LayoutNode::Leaf(leaf);
                }
            }
            node.clone()
        });
        if !found {
            return Err(LayoutError::NodeNotFound(target));
        }
        let tree = Self { root };
        tree.validate()?;
        Ok(tree)
    }

    /// Sets (or, with `None`, clears) the **engine session id** cache
    /// ([`LeafCell::engine_session_id`]) on the leaf `target` — the resumable id of
    /// the current provider (id Claude `--resume`, UUID minté pour `--session-id`).
    ///
    /// Jumeau additif de [`Self::set_cell_conversation`] (ARCHITECTURE §19.7) :
    /// l'id de paire **logique** reste porté par `conversation_id`, l'id **moteur**
    /// est rangé séparément ici (cache ; source de vérité `providers.json`). Ne touche
    /// **que** ce champ, laissant `conversation_id` intact — c'est la séparation des
    /// deux clés qui corrige l'incohérence P6/P7.
    ///
    /// Pure: returns a new validated tree.
    ///
    /// # Errors
    /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
    pub fn set_cell_engine_session(
        &self,
        target: NodeId,
        engine_session_id: Option<String>,
    ) -> Result<Self, LayoutError> {
        let mut found = false;
        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Leaf(leaf) = node {
                if leaf.id == target {
                    found = true;
                    let mut leaf = leaf.clone();
                    leaf.engine_session_id = engine_session_id.clone();
                    return LayoutNode::Leaf(leaf);
                }
            }
            node.clone()
        });
        if !found {
            return Err(LayoutError::NodeNotFound(target));
        }
        let tree = Self { root };
        tree.validate()?;
        Ok(tree)
    }

    /// Records whether the cell's agent process was `running` at close time, on
    /// the leaf `target`.
    ///
    /// Pure: returns a new validated tree.
    ///
    /// # Errors
    /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
    pub fn set_agent_running(&self, target: NodeId, running: bool) -> Result<Self, LayoutError> {
        let mut found = false;
        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Leaf(leaf) = node {
                if leaf.id == target {
                    found = true;
                    let mut leaf = leaf.clone();
                    leaf.agent_was_running = running;
                    return LayoutNode::Leaf(leaf);
                }
            }
            node.clone()
        });
        if !found {
            return Err(LayoutError::NodeNotFound(target));
        }
        let tree = Self { root };
        tree.validate()?;
        Ok(tree)
    }

    /// Collects every leaf that carries an agent, as `(leaf id, agent id)` pairs.
    ///
    /// Used by the close-time snapshot of running agents: the application walks
    /// these leaves and records, on each, whether the agent's PTY was still live
    /// (`agent_was_running`) before the global PTY kill. Pure read-only traversal.
    #[must_use]
    pub fn agent_leaves(&self) -> Vec<(NodeId, AgentId)> {
        fn walk(node: &LayoutNode, out: &mut Vec<(NodeId, AgentId)>) {
            match node {
                LayoutNode::Leaf(leaf) => {
                    if let Some(agent) = leaf.agent {
                        out.push((leaf.id, agent));
                    }
                }
                LayoutNode::Split(split) => {
                    for child in &split.children {
                        walk(&child.node, out);
                    }
                }
                LayoutNode::Grid(grid) => {
                    for cell in &grid.cells {
                        walk(&cell.node, out);
                    }
                }
            }
        }
        let mut out = Vec::new();
        walk(&self.root, &mut out);
        out
    }

    /// Réconcilie les feuilles en doublon sur un même agent : à la réouverture
    /// d'un projet, un `layouts.json` persisté peut contenir **plusieurs**
    /// feuilles portant le **même** `agent` id (constaté). L'invariant produit
    /// est **« 1 session vivante par agent »** : une seule feuille doit rester
    /// « hôte » (potentiellement vivante / reprenable), les autres deviennent des
    /// **vues mortes** — elles gardent leur agent (la cellule reste), mais ne sont
    /// plus considérées « était en cours » : leur `agent_was_running` repasse à
    /// `false` et leur `conversation_id` est retiré, de sorte qu'aucune reprise ni
    /// relance ne les vise.
    ///
    /// ## Règle déterministe de choix de l'hôte
    ///
    /// Parmi les N feuilles d'un même agent, **dans l'ordre de parcours
    /// déterministe de l'arbre** (le même pré-ordre que [`Self::agent_leaves`]),
    /// l'hôte est la **première feuille porteuse d'un signal de reprise**
    /// (`conversation_id.is_some()` **ou** `agent_was_running`) ; à défaut de tout
    /// signal, l'hôte est simplement la **première** feuille rencontrée. On garde
    /// ainsi sur l'unique hôte l'état de reprise le plus pertinent, et le choix est
    /// totalement déterministe (l'ordre de parcours est stable).
    ///
    /// ## Idempotence
    ///
    /// Un arbre **sans doublon** est renvoyé **inchangé** (`self.clone()` à
    /// l'identique). Un arbre **déjà réconcilié** (≤ 1 feuille par agent porte un
    /// signal de reprise) l'est aussi : la 2ᵉ passe ne dé-flagge rien. La fonction
    /// est donc un point fixe — utile pour garantir le no-op à la 2ᵉ ouverture.
    ///
    /// Pure : renvoie un nouvel arbre (non revalidé — la réconciliation ne touche
    /// ni la structure ni les sessions, seuls des champs scalaires de feuille).
    #[must_use]
    pub fn reconcile_duplicate_agents(&self) -> Self {
        use std::collections::{HashMap, HashSet};

        // Première passe (lecture seule) : pour chaque agent, déterminer le node
        // hôte selon la règle déterministe ci-dessus.
        let mut host_of: HashMap<AgentId, NodeId> = HashMap::new();
        let mut has_signal: HashSet<AgentId> = HashSet::new();
        for (node_id, agent_id) in self.agent_leaves() {
            // `leaf` ne peut pas être `None` ici : le node vient de l'arbre.
            let signal = self
                .leaf(node_id)
                .is_some_and(|l| l.conversation_id.is_some() || l.agent_was_running);
            match host_of.get(&agent_id) {
                // Pas encore d'hôte : cette feuille le devient (provisoirement).
                None => {
                    host_of.insert(agent_id, node_id);
                    if signal {
                        has_signal.insert(agent_id);
                    }
                }
                // Un hôte sans signal est supplanté par la première feuille à
                // signal rencontrée ensuite.
                Some(_) if signal && !has_signal.contains(&agent_id) => {
                    host_of.insert(agent_id, node_id);
                    has_signal.insert(agent_id);
                }
                Some(_) => {}
            }
        }

        // Seconde passe : dé-flagger toute feuille d'agent qui n'est pas son hôte.
        let root = map_node(&self.root, &mut |node| {
            if let LayoutNode::Leaf(leaf) = node {
                if let Some(agent) = leaf.agent {
                    let is_host = host_of.get(&agent) == Some(&leaf.id);
                    if !is_host && (leaf.agent_was_running || leaf.conversation_id.is_some()) {
                        let mut leaf = leaf.clone();
                        leaf.conversation_id = None;
                        leaf.engine_session_id = None;
                        leaf.agent_was_running = false;
                        return LayoutNode::Leaf(leaf);
                    }
                }
            }
            node.clone()
        });
        Self { root }
    }

    /// Retrouve la [`LeafCell`] portant l'identifiant `node`.
    ///
    /// Renvoie `None` si aucun node ne porte cet id, **ou** si le node existe mais
    /// est un split/grid (pas une feuille). Traversée pure en lecture seule, dans
    /// le même esprit que [`Self::agent_leaves`] / [`Self::session_in_leaf`].
    #[must_use]
    pub fn leaf(&self, node: NodeId) -> Option<&LeafCell> {
        fn find(n: &LayoutNode, id: NodeId) -> Option<&LeafCell> {
            match n {
                LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf),
                LayoutNode::Leaf(_) => None,
                LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
                LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
            }
        }
        find(&self.root, node)
    }

    /// Returns `Ok(Some(session))` / `Ok(None)` for the session held by the leaf
    /// `id`, or [`LayoutError::NodeNotFound`] if no such leaf exists.
    fn session_in_leaf(&self, id: NodeId) -> Result<Option<SessionId>, LayoutError> {
        fn find(node: &LayoutNode, id: NodeId) -> Option<Option<SessionId>> {
            match node {
                LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
                LayoutNode::Leaf(_) => None,
                LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
                LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
            }
        }
        find(&self.root, id).ok_or(LayoutError::NodeNotFound(id))
    }
}

/// Recursively rebuilds a node, applying `f` to every node (post-order: `f` sees
/// already-rebuilt children).
fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode {
    let rebuilt = match node {
        LayoutNode::Leaf(_) => node.clone(),
        LayoutNode::Split(split) => LayoutNode::Split(SplitContainer {
            id: split.id,
            direction: split.direction,
            children: split
                .children
                .iter()
                .map(|c| WeightedChild {
                    node: map_node(&c.node, f),
                    weight: c.weight,
                })
                .collect(),
        }),
        LayoutNode::Grid(grid) => LayoutNode::Grid(GridContainer {
            id: grid.id,
            col_weights: grid.col_weights.clone(),
            row_weights: grid.row_weights.clone(),
            cells: grid
                .cells
                .iter()
                .map(|c| GridCell {
                    node: map_node(&c.node, f),
                    row: c.row,
                    col: c.col,
                    row_span: c.row_span,
                    col_span: c.col_span,
                })
                .collect(),
        }),
    };
    f(&rebuilt)
}

/// Recursive validation of a single node, accumulating seen sessions to enforce
/// global uniqueness.
fn validate_node(
    node: &LayoutNode,
    sessions: &mut std::collections::HashSet<SessionId>,
) -> Result<(), LayoutError> {
    match node {
        LayoutNode::Leaf(leaf) => {
            if let Some(session) = leaf.session {
                if !sessions.insert(session) {
                    return Err(LayoutError::DuplicateSession(session));
                }
            }
            Ok(())
        }
        LayoutNode::Split(split) => {
            if split.children.is_empty() {
                return Err(LayoutError::EmptySplit);
            }
            for child in &split.children {
                if child.weight <= 0.0 {
                    return Err(LayoutError::NonPositiveWeight {
                        weight: child.weight,
                    });
                }
                validate_node(&child.node, sessions)?;
            }
            Ok(())
        }
        LayoutNode::Grid(grid) => validate_grid(grid, sessions),
    }
}

/// Validates a grid: positive weights, in-bounds spans, no overlaps, full
/// coverage, and recursion into cell contents.
fn validate_grid(
    grid: &GridContainer,
    sessions: &mut std::collections::HashSet<SessionId>,
) -> Result<(), LayoutError> {
    for &w in grid.col_weights.iter().chain(grid.row_weights.iter()) {
        if w <= 0.0 {
            return Err(LayoutError::NonPositiveWeight { weight: w });
        }
    }
    let rows = grid.row_weights.len();
    let cols = grid.col_weights.len();
    // Occupancy matrix to detect overlaps and gaps.
    let mut occupied = vec![false; rows * cols];
    for cell in &grid.cells {
        if cell.row_span < 1 || cell.col_span < 1 {
            return Err(LayoutError::InvalidSpan);
        }
        let row_end = cell.row as usize + cell.row_span as usize;
        let col_end = cell.col as usize + cell.col_span as usize;
        if row_end > rows || col_end > cols {
            return Err(LayoutError::SpanOutOfBounds {
                row: cell.row,
                col: cell.col,
                row_span: cell.row_span,
                col_span: cell.col_span,
                rows: rows as u16,
                cols: cols as u16,
            });
        }
        for r in cell.row as usize..row_end {
            for c in cell.col as usize..col_end {
                let idx = r * cols + c;
                if occupied[idx] {
                    return Err(LayoutError::OverlappingCells {
                        row: r as u16,
                        col: c as u16,
                    });
                }
                occupied[idx] = true;
            }
        }
        validate_node(&cell.node, sessions)?;
    }
    // Full coverage.
    for r in 0..rows {
        for c in 0..cols {
            if !occupied[r * cols + c] {
                return Err(LayoutError::UncoveredCell {
                    row: r as u16,
                    col: c as u16,
                });
            }
        }
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Window / Tab / Workspace — persisted presentation entities (ARCHITECTURE §3.2)
// ---------------------------------------------------------------------------

/// An open tab, bound 1:1 to a project.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tab {
    /// Tab identifier.
    pub id: TabId,
    /// The project shown in this tab.
    pub project_id: crate::ids::ProjectId,
    /// The terminal layout of this tab.
    pub layout: LayoutTree,
}

/// An OS window holding one or more tabs.
///
/// Invariant: a non-closed window holds at least one tab (see [`Window::new`]).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Window {
    /// Window identifier.
    pub id: WindowId,
    /// Tabs in this window.
    pub tabs: Vec<Tab>,
    /// Currently active tab.
    pub active_tab: TabId,
}

impl Window {
    /// Builds a window, enforcing the "≥ 1 tab" invariant and that `active_tab`
    /// refers to one of the tabs.
    ///
    /// # Errors
    /// Returns [`LayoutError::CrossContainer`] if `tabs` is empty or `active_tab`
    /// is not present.
    pub fn new(id: WindowId, tabs: Vec<Tab>, active_tab: TabId) -> Result<Self, LayoutError> {
        if tabs.is_empty() || !tabs.iter().any(|t| t.id == active_tab) {
            return Err(LayoutError::CrossContainer);
        }
        Ok(Self {
            id,
            tabs,
            active_tab,
        })
    }
}

/// The set of windows for a user session.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Workspace {
    /// All open OS windows.
    pub windows: Vec<Window>,
}

impl Workspace {
    /// Detaches a tab into a brand-new window (ARCHITECTURE §10, L10).
    ///
    /// A **pure** transformation returning the next workspace state — the tab is
    /// *moved*, never duplicated (the "a project is open in exactly one tab"
    /// invariant). If the source window becomes empty it is removed; otherwise, if
    /// the detached tab was the active one, the source's active tab falls back to
    /// its first remaining tab. The new window holds the tab and makes it active.
    ///
    /// # Errors
    /// - [`LayoutError::TabNotFound`] if no window contains `tab_id`,
    /// - propagates [`Window::new`] invariants for the created window.
    pub fn move_tab_to_new_window(
        &self,
        tab_id: TabId,
        new_window_id: WindowId,
    ) -> Result<Self, LayoutError> {
        let mut windows = self.windows.clone();

        // Locate the (window, tab) holding `tab_id`.
        let (wi, ti) = windows
            .iter()
            .enumerate()
            .find_map(|(wi, w)| {
                w.tabs
                    .iter()
                    .position(|t| t.id == tab_id)
                    .map(|ti| (wi, ti))
            })
            .ok_or(LayoutError::TabNotFound(tab_id))?;

        let tab = windows[wi].tabs.remove(ti);

        if windows[wi].tabs.is_empty() {
            // The source window is now empty: drop it (windows hold ≥ 1 tab).
            windows.remove(wi);
        } else if windows[wi].active_tab == tab_id {
            // The moved tab was active: fall back to the first remaining tab.
            windows[wi].active_tab = windows[wi].tabs[0].id;
        }

        let detached = Window::new(new_window_id, vec![tab.clone()], tab.id)?;
        windows.push(detached);

        Ok(Self { windows })
    }
}

/// Current persisted schema version for OS window state.
pub const WINDOW_STATE_SNAPSHOT_VERSION: u32 = 1;

/// Persisted kind of an IdeA OS window.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PersistedWindowKind {
    /// The primary IdeA window.
    Main,
    /// A detached project view window.
    View,
}

/// Physical top-left position of a window or monitor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistedWindowPosition {
    /// X coordinate in physical pixels.
    pub x: i32,
    /// Y coordinate in physical pixels.
    pub y: i32,
}

/// Physical size of a window or monitor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistedWindowSize {
    /// Width in physical pixels.
    pub width: u32,
    /// Height in physical pixels.
    pub height: u32,
}

/// Best-effort monitor descriptor captured with a window.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistedMonitorState {
    /// Human-readable monitor name when the platform exposes one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Scale factor reported by the windowing system.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scale_factor: Option<f64>,
    /// Monitor origin in the global desktop coordinate space.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub position: Option<PersistedWindowPosition>,
    /// Monitor physical size.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub size: Option<PersistedWindowSize>,
}

/// Persisted state of one IdeA webview window.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistedWindowState {
    /// Stable Tauri label. For detached views this is `view-<panel>`.
    pub label: String,
    /// Window kind.
    pub kind: PersistedWindowKind,
    /// Detached view panel id. Present only for [`PersistedWindowKind::View`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub panel: Option<String>,
    /// Legacy detached view project id. Ignored by panel-only restore.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project_id: Option<crate::ids::ProjectId>,
    /// App URL loaded in the window.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    /// Whether the window was visible.
    pub visible: bool,
    /// Whether the window was maximized.
    pub maximized: bool,
    /// Whether the window was fullscreen.
    pub fullscreen: bool,
    /// Outer window position.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub outer_position: Option<PersistedWindowPosition>,
    /// Outer window size.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub outer_size: Option<PersistedWindowSize>,
    /// Current monitor at snapshot time.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub monitor: Option<PersistedMonitorState>,
    /// Optional focus recency, left unset until focus tracking exists.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_focused_at: Option<u64>,
}

/// Persisted snapshot of the IdeA OS windows.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WindowStateSnapshot {
    /// Schema version.
    pub version: u32,
    /// Captured windows.
    pub windows: Vec<PersistedWindowState>,
}

impl WindowStateSnapshot {
    /// Builds a versioned snapshot from captured windows.
    #[must_use]
    pub fn new(windows: Vec<PersistedWindowState>) -> Self {
        Self {
            version: WINDOW_STATE_SNAPSHOT_VERSION,
            windows,
        }
    }
}

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