//! The spec model: the declarative description of an update.
//!
//! A spec node is one of three shapes. It can be a command object such as
//! `{$set: x}`. It can be a bare function, which is shorthand for `{$apply:
//! fn}`. Or it can be a key-path object that maps child keys to nested specs
//! and may also carry sibling commands at the same level.
//!
//! The [`Spec`] enum keeps commands and key paths in one node so a single spec
//! can mix them, matching the "nibling directive" behavior where `{a: {...},
//! $merge: {...}}` applies both the child update and the command.

use indexmap::IndexMap;

use crate::value::{Callback, Value};

/// A single command inside a spec object.
///
/// A command names one `$`-prefixed operation. Commands are dispatched directly
/// and may replace or rebuild the whole target node.
#[derive(Clone)]
pub enum Command {
    /// `$push: [items]`. Append items to an array.
    Push(Vec<Value>),
    /// `$unshift: [items]`. Prepend items to an array.
    Unshift(Vec<Value>),
    /// `$splice: [[start, delete_count?, items...], ...]`. Apply each splice
    /// in order. Each inner argument list is `[start, delete_count?, items..]`.
    Splice(Vec<SpliceArgs>),
    /// `$set: value`. Replace the target entirely.
    Set(Value),
    /// `$toggle: [keys]`. Flip boolean fields on an object.
    Toggle(Vec<String>),
    /// `$unset: [keys]`. Remove keys from an object.
    Unset(Vec<String>),
    /// `$merge: object`. Shallow-merge keys into an object.
    Merge(IndexMap<String, Value>),
    /// `$add: [items]`. Add to a `Map` (as `[key, value]` pairs) or a `Set`.
    Add(Vec<Value>),
    /// `$remove: [keys]`. Remove from a `Map` or `Set`.
    Remove(Vec<Value>),
    /// `$apply: fn` or a bare function. Replace the value with `fn(current)`.
    Apply(Callback),
    /// A custom command registered through `extend`. The payload is passed to
    /// the registered function.
    Custom(String, Value),
}

/// A spec node: the description of an update at one level.
///
/// A spec is either a single command applied to the whole node, a bare function
/// shorthand for `$apply`, or a key-path object. A key-path object lists child
/// keys with nested specs and may also carry sibling commands, which is how one
/// spec mixes commands and nested updates.
#[derive(Clone)]
pub enum Spec {
    /// A bare function: `update(x, fn)` behaves like `{$apply: fn}`.
    Apply(Callback),
    /// A spec object: an ordered list of entries.
    Object(Vec<SpecEntry>),
}

/// One entry inside a spec object, in written order.
#[derive(Clone)]
pub enum SpecEntry {
    /// A command applied to the whole node.
    Command(Command),
    /// A nested key path: descend into `key` and apply `spec`.
    Child {
        /// The child key. For arrays this is the index as a string.
        key: String,
        /// The spec to apply at the child.
        spec: Spec,
    },
}

/// Arguments to a single `Array.prototype.splice` call.
///
/// The original JavaScript argument list is `[start, delete_count?, items...]`.
/// `start` follows JavaScript coercion: a negative value counts from the end. A
/// missing `delete_count` deletes through the end of the array.
///
/// An empty argument list `[]` is a no-op. It is represented by
/// [`SpliceArgs::Empty`] and skips the copy-on-write step, which is how
/// `$splice: [[]]` keeps reference equality.
#[derive(Clone, Debug)]
pub enum SpliceArgs {
    /// An empty argument list `[]`. A no-op that does not force a copy.
    Empty,
    /// A real splice.
    Call {
        /// The index to start at. May be negative.
        start: i64,
        /// How many items to delete. `None` deletes through the end.
        delete_count: Option<i64>,
        /// Items to insert at `start`.
        items: Vec<Value>,
    },
}

impl SpliceArgs {
    /// Build splice arguments from a start index and optional parts.
    pub fn new(start: i64, delete_count: Option<i64>, items: Vec<Value>) -> Self {
        SpliceArgs::Call {
            start,
            delete_count,
            items,
        }
    }

    /// An empty splice: a no-op that does not force a copy.
    pub fn empty() -> Self {
        SpliceArgs::Empty
    }
}

impl Spec {
    /// Build a key-path spec from an ordered list of entries.
    pub fn object(entries: Vec<SpecEntry>) -> Spec {
        Spec::Object(entries)
    }

    /// Build a spec holding one whole-node command.
    pub fn command(command: Command) -> Spec {
        Spec::Object(vec![SpecEntry::Command(command)])
    }

    /// `{$set: value}`.
    pub fn set(value: Value) -> Spec {
        Spec::command(Command::Set(value))
    }

    /// `{$push: items}`.
    pub fn push(items: Vec<Value>) -> Spec {
        Spec::command(Command::Push(items))
    }

    /// `{$unshift: items}`.
    pub fn unshift(items: Vec<Value>) -> Spec {
        Spec::command(Command::Unshift(items))
    }

    /// `{$splice: args}`.
    pub fn splice(args: Vec<SpliceArgs>) -> Spec {
        Spec::command(Command::Splice(args))
    }

    /// `{$toggle: keys}`.
    pub fn toggle(keys: Vec<String>) -> Spec {
        Spec::command(Command::Toggle(keys))
    }

    /// `{$unset: keys}`.
    pub fn unset(keys: Vec<String>) -> Spec {
        Spec::command(Command::Unset(keys))
    }

    /// `{$merge: object}`.
    pub fn merge<I, K>(pairs: I) -> Spec
    where
        I: IntoIterator<Item = (K, Value)>,
        K: Into<String>,
    {
        let map = pairs.into_iter().map(|(k, v)| (k.into(), v)).collect();
        Spec::command(Command::Merge(map))
    }

    /// `{$add: items}`.
    pub fn add(items: Vec<Value>) -> Spec {
        Spec::command(Command::Add(items))
    }

    /// `{$remove: keys}`.
    pub fn remove(keys: Vec<Value>) -> Spec {
        Spec::command(Command::Remove(keys))
    }

    /// `{$apply: fn}`.
    pub fn apply<F>(f: F) -> Spec
    where
        F: Fn(&Value) -> Value + 'static,
    {
        Spec::Apply(std::rc::Rc::new(f))
    }

    /// A custom command `{$name: payload}`.
    pub fn custom(name: impl Into<String>, payload: Value) -> Spec {
        Spec::command(Command::Custom(name.into(), payload))
    }

    /// A key path with one child: `{key: spec}`.
    pub fn at(key: impl Into<String>, spec: Spec) -> Spec {
        Spec::Object(vec![SpecEntry::Child {
            key: key.into(),
            spec,
        }])
    }
}
