//! Persistence of a project's **named terminal layouts** (#4).
//!
//! A project no longer has a single layout: `.ideai/layouts.json` holds a
//! collection of named [`LayoutTree`]s plus which one is active:
//!
//! ```json
//! { "version": 1, "activeId": "…", "layouts": [ { "id": "…", "name": "Default", "tree": { … } } ] }
//! ```
//!
//! Migration: a project that still has the legacy single `.ideai/layout.json`
//! (pre-#4) is upgraded transparently — its tree becomes the first "Default"
//! layout. A missing/corrupt store self-heals to one default layout (same
//! self-healing contract as the original L4 single-file resolver).

use serde::{Deserialize, Serialize};

use domain::ports::{FileSystem, RemotePath};
use domain::{
    CustomPluginLayoutCell, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, PluginId,
    PluginLayoutType, Project,
};

use crate::error::AppError;
use crate::project::meta::{from_json_bytes, join_root, to_json_bytes, IDEAI_DIR};

/// File name of the named-layouts store inside a project's `.ideai/`.
pub const LAYOUTS_FILE: &str = "layouts.json";

/// Legacy single-layout file (pre-#4), migrated on first read if present.
const LEGACY_LAYOUT_FILE: &str = "layout.json";

/// Current schema version of `layouts.json`.
const LAYOUTS_VERSION: u32 = 1;

/// Name given to the layout created by default / migrated from the legacy file.
const DEFAULT_LAYOUT_NAME: &str = "Default";

/// Discriminates the kind of content a named layout holds.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LayoutKind {
    /// A terminal-grid layout (the original kind).
    #[default]
    Terminal,
    /// A Git-graph visualisation layout.
    GitGraph,
    /// A plugin-provided custom layout.
    Plugin {
        /// Provider plugin id and layout type.
        plugin_origin: PluginLayoutOrigin,
        /// Opaque initial state owned by the plugin.
        state: serde_json::Value,
    },
}

/// Stable origin of a plugin-provided custom layout.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginLayoutOrigin {
    /// Provider plugin id.
    pub plugin_id: PluginId,
    /// Layout type declared by the provider plugin.
    pub layout_type: PluginLayoutType,
}

/// One named layout: a stable id, a display name and its terminal grid tree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NamedLayout {
    /// Stable identifier.
    pub id: LayoutId,
    /// Display name (shown in the layouts tab bar).
    pub name: String,
    /// The kind of this layout (terminal grid or git-graph).
    #[serde(default)]
    pub kind: LayoutKind,
    /// The terminal grid for this layout (present but ignored for GitGraph).
    pub tree: LayoutTree,
}

/// On-disk shape of `.ideai/layouts.json`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LayoutsDoc {
    /// Schema version.
    pub version: u32,
    /// The currently active layout (always one of `layouts`).
    pub active_id: LayoutId,
    /// All named layouts (at least one).
    pub layouts: Vec<NamedLayout>,
}

impl LayoutsDoc {
    /// The active layout id, or the explicit one when provided.
    #[must_use]
    pub fn resolve_id(&self, id: Option<LayoutId>) -> LayoutId {
        id.unwrap_or(self.active_id)
    }

    /// Resolves a requested view id to a **valid** layout id, never failing.
    ///
    /// Returns `id` when it is present in `layouts`; otherwise falls back to
    /// `active_id` (guaranteed valid by invariant I2 / [`Self::is_valid`]).
    /// A `None` request (the current view) also resolves to `active_id`. This
    /// is the self-healing path: a stale `active_id` written by an external tool
    /// (e.g. git overwriting `layouts.json`) degrades silently instead of
    /// hard-erroring and freezing the workspace (invariant I3).
    #[must_use]
    pub fn resolve_existing_id(&self, id: Option<LayoutId>) -> LayoutId {
        match id {
            Some(id) if self.find(id).is_some() => id,
            _ => self.active_id,
        }
    }

    /// Finds a layout by id.
    #[must_use]
    pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> {
        self.layouts.iter().find(|l| l.id == id)
    }

    /// Mutable access to a layout by id.
    pub fn find_mut(&mut self, id: LayoutId) -> Option<&mut NamedLayout> {
        self.layouts.iter_mut().find(|l| l.id == id)
    }

    /// Structural validity: non-empty, `active_id` present, every tree valid.
    fn is_valid(&self) -> bool {
        !self.layouts.is_empty()
            && self.find(self.active_id).is_some()
            && self.layouts.iter().all(|l| l.tree.validate().is_ok())
    }
}

/// The default single-cell layout tree (one empty leaf).
#[must_use]
pub fn default_tree() -> LayoutTree {
    LayoutTree::single(LeafCell::new(NodeId::new_random()))
}

/// Builds a single custom plugin layout tree.
#[must_use]
pub fn plugin_layout_tree(origin: &PluginLayoutOrigin, state: serde_json::Value) -> LayoutTree {
    LayoutTree::new(LayoutNode::CustomPluginLayout(CustomPluginLayoutCell {
        id: NodeId::new_random(),
        plugin_id: origin.plugin_id.clone(),
        layout_type: origin.layout_type.clone(),
        state,
    }))
}

/// Builds a fresh doc holding one layout (`tree`) made active.
fn doc_with(id: LayoutId, name: &str, tree: LayoutTree) -> LayoutsDoc {
    LayoutsDoc {
        version: LAYOUTS_VERSION,
        active_id: id,
        layouts: vec![NamedLayout {
            id,
            name: name.to_owned(),
            kind: LayoutKind::Terminal,
            tree,
        }],
    }
}

fn layouts_path(project: &Project) -> RemotePath {
    RemotePath::new(join_root(
        &project.root,
        &format!("{IDEAI_DIR}/{LAYOUTS_FILE}"),
    ))
}

fn legacy_path(project: &Project) -> RemotePath {
    RemotePath::new(join_root(
        &project.root,
        &format!("{IDEAI_DIR}/{LEGACY_LAYOUT_FILE}"),
    ))
}

/// Reads the legacy single layout tree if present and valid (for migration).
async fn read_legacy_tree(fs: &dyn FileSystem, project: &Project) -> Option<LayoutTree> {
    let bytes = fs.read(&legacy_path(project)).await.ok()?;
    let tree = from_json_bytes::<LayoutTree>(&bytes).ok()?;
    tree.validate().is_ok().then_some(tree)
}

/// Writes the doc to `.ideai/layouts.json` (creating `.ideai/` if needed).
pub async fn persist_doc(
    fs: &dyn FileSystem,
    project: &Project,
    doc: &LayoutsDoc,
) -> Result<(), AppError> {
    let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
    fs.create_dir_all(&ideai_dir).await?;
    fs.write(&layouts_path(project), &to_json_bytes(doc)?)
        .await?;
    Ok(())
}

/// Resolves the project's layouts doc, with idempotent initialisation /
/// migration / self-healing (mirrors the L4 single-file resolver contract).
///
/// - **Present & valid**: returned as-is (no write).
/// - **Absent**: migrated from the legacy `layout.json` if present, else seeded
///   with one empty "Default" layout — and **persisted** (write-through, so ids
///   are stable from the first load).
/// - **Present but corrupt/invalid**: overwritten with a fresh default.
pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result<LayoutsDoc, AppError> {
    if let Ok(bytes) = fs.read(&layouts_path(project)).await {
        if let Ok(doc) = from_json_bytes::<LayoutsDoc>(&bytes) {
            if doc.is_valid() {
                return Ok(doc);
            }
        }
        // Present-but-invalid: fall through and re-seed.
    }

    // First run for this project (or self-heal): migrate the legacy tree if any.
    let tree = match read_legacy_tree(fs, project).await {
        Some(tree) => tree,
        None => default_tree(),
    };
    let doc = doc_with(LayoutId::new_random(), DEFAULT_LAYOUT_NAME, tree);
    persist_doc(fs, project, &doc).await?;
    Ok(doc)
}

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

    /// Builds a doc with two layouts; the first one is active.
    fn two_layout_doc() -> (LayoutsDoc, LayoutId, LayoutId) {
        let active = LayoutId::new_random();
        let other = LayoutId::new_random();
        let doc = LayoutsDoc {
            version: LAYOUTS_VERSION,
            active_id: active,
            layouts: vec![
                NamedLayout {
                    id: active,
                    name: "Default".to_owned(),
                    kind: LayoutKind::Terminal,
                    tree: default_tree(),
                },
                NamedLayout {
                    id: other,
                    name: "Second".to_owned(),
                    kind: LayoutKind::Terminal,
                    tree: default_tree(),
                },
            ],
        };
        (doc, active, other)
    }

    #[test]
    fn resolve_existing_id_returns_present_id() {
        let (doc, _active, other) = two_layout_doc();
        // A present, non-active id is returned verbatim.
        assert_eq!(doc.resolve_existing_id(Some(other)), other);
    }

    #[test]
    fn resolve_existing_id_falls_back_to_active_when_absent() {
        let (doc, active, _other) = two_layout_doc();
        // A stale id absent from `layouts` degrades to the valid active id.
        let stale = LayoutId::new_random();
        assert_eq!(doc.resolve_existing_id(Some(stale)), active);
    }

    #[test]
    fn resolve_existing_id_none_resolves_to_active() {
        let (doc, active, _other) = two_layout_doc();
        // The "current view" request resolves to the active id.
        assert_eq!(doc.resolve_existing_id(None), active);
    }
}
