-- zSignalReact-1.0.lua — Reactive signal and store library for WoW addons.
-- Provides Signal (observable value), Computed (derived value), Batch
-- (deferred notification for bulk updates), and Store (keyed signal registry
-- with subscribe, effect routing, and computed helpers).
-- LibStub-registered; AceDB-agnostic; no external dependencies.

local MAJOR, MINOR = "zSignalReact-1.0", 1
---@class zSignalReact
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then return end

-- ############################################################
-- Batch state (shared across all stores created by this library instance)
-- ############################################################

local _batchDepth = 0
---@type table<Signal, true>
local _batchDirtySignals = {}

-- ############################################################
-- Signal
-- ############################################################

---@class Signal
---@field private _value any
---@field private _callbacks function[]
---@field private _batchDirty boolean
---@field private _debugName string|nil
local Signal = {}
Signal.__index = Signal

--- Create a new reactive signal with an initial value.
---@param defaultValue any
---@param debugName? string  Optional name for debug introspection
---@return Signal
function Signal.New(defaultValue, debugName)
    local self = setmetatable({}, Signal)
    self._value = defaultValue
    self._callbacks = {}
    self._batchDirty = false
    self._debugName = debugName
    return self
end

---@return any
function Signal:Get()
    return self._value
end

-- Shallow-compare two table values (for color arrays etc.).
---@param a table
---@param b table
---@return boolean
local function shallowEqual(a, b)
    for k, v in pairs(a) do
        if b[k] ~= v then return false end
    end
    for k in pairs(b) do
        if a[k] == nil then return false end
    end
    return true
end

--- Set a new value.  Notifies subscribers only when the value actually changes.
--- Tables are shallow-compared so `{1,0,0,1}` → `{1,0,0,1}` is suppressed.
--- When inside a Batch, notification is deferred until the batch completes.
---@param value any
function Signal:Set(value)
    if type(value) == "table" and type(self._value) == "table" then
        if shallowEqual(value, self._value) then return end
    elseif self._value == value then
        return
    end

    -- Shallow-copy table values so callers cannot mutate after Set
    if type(value) == "table" then
        local copy = {}
        for k, v in pairs(value) do copy[k] = v end
        self._value = copy
    else
        self._value = value
    end

    if _batchDepth > 0 then
        self._batchDirty = true
        _batchDirtySignals[self] = true
        return
    end

    self:_Notify()
end

---@private
function Signal:_Notify()
    self._batchDirty = false
    for i = 1, #self._callbacks do
        self._callbacks[i](self._value)
    end
end

--- Subscribe to value changes.  Returns an unsubscribe function.
---@param callback fun(newValue: any)
---@return fun() unsubscribe
function Signal:OnChange(callback)
    table.insert(self._callbacks, callback)
    local cbs = self._callbacks
    return function()
        for i = 1, #cbs do
            if cbs[i] == callback then
                table.remove(cbs, i)
                return
            end
        end
    end
end

---@return string
function Signal:GetName()
    return self._debugName or "(unnamed)"
end

---@return number
function Signal:GetSubscriberCount()
    return #self._callbacks
end

--- Defer all signal notifications until fn completes.
--- Batches can nest; only the outermost batch flushes.
---@param fn function
function Signal.Batch(fn)
    _batchDepth = _batchDepth + 1
    fn()
    _batchDepth = _batchDepth - 1
    if _batchDepth == 0 then
        for sig in pairs(_batchDirtySignals) do
            sig:_Notify()
        end
        wipe(_batchDirtySignals)
    end
end

-- ############################################################
-- Computed
-- ############################################################

---@class Computed : Signal
---@field private _deps Signal[]
---@field private _computeFn function
local Computed = setmetatable({}, { __index = Signal })
Computed.__index = Computed

--- Create a derived signal that recomputes when any dependency changes.
---@param deps Signal[]
---@param computeFn fun(...): any  Receives current values of all deps in order
---@param debugName? string
---@return Computed
function Computed.New(deps, computeFn, debugName)
    local self = setmetatable(Signal.New(nil, debugName), Computed)
    self._deps = deps
    self._computeFn = computeFn

    self._value = self:_Compute()

    for _, dep in ipairs(deps) do
        dep:OnChange(function()
            local newVal = self:_Compute()
            if type(newVal) == "table" and type(self._value) == "table" then
                if shallowEqual(newVal, self._value) then return end
            elseif newVal == self._value then
                return
            end
            self._value = newVal
            self:_Notify()
        end)
    end

    return self
end

---@private
function Computed:_Compute()
    local vals = {}
    for i, dep in ipairs(self._deps) do
        vals[i] = dep:Get()
    end
    return self._computeFn(unpack(vals))
end

-- Computed signals are read-only; Set is a no-op.
function Computed:Set()
    -- Intentional no-op — value is derived from dependencies.
end

-- ############################################################
-- Store
-- ############################################################

---@class zSignalStore
---@field private _defaults       table<string, any>
---@field private _settingsSource table
---@field private _signals        table<string, Signal>
---@field private _subscribeAll   fun(key: string, value: any)[]
local Store = {}
Store.__index = Store

--- Read the current value for a key, falling back to the default.
---@param key string
---@return any
function Store:Get(key)
    local v = self._settingsSource[key]
    if v == nil then return self._defaults[key] end
    return v
end

--- Write a value to the settings source and fire the associated signal.
---@param key   string
---@param value any
function Store:Set(key, value)
    if self._settingsSource[key] ~= value then
        self._settingsSource[key] = value
        local sig = self._signals[key]
        if sig then sig:Set(value) end
    end
end

--- Re-sync all signals from the current settings source.
--- Call after AceDB profile switches or any bulk external write.
function Store:Sync()
    local src      = self._settingsSource
    local defaults = self._defaults
    Signal.Batch(function()
        for key, sig in pairs(self._signals) do
            local current = src[key]
            if current == nil then current = defaults[key] end
            sig:Set(current)
        end
    end)
end

--- Defer all signal notifications until fn completes.
---@param fn function
function Store:Batch(fn)
    Signal.Batch(fn)
end

--- Bulk-reset a list of keys to their defaults inside a Batch.
---@param keys string[]
function Store:ApplyDefaults(keys)
    local src      = self._settingsSource
    local defaults = self._defaults
    Signal.Batch(function()
        for _, key in ipairs(keys) do
            local default = defaults[key]
            src[key] = default
            local sig = self._signals[key]
            if sig then sig:Set(default) end
        end
    end)
end

--- Subscribe to changes on a single key.  Returns an unsubscribe function.
---@param key      string
---@param callback fun(newValue: any)
---@return fun() unsubscribe
function Store:Subscribe(key, callback)
    local sig = self._signals[key]
    if sig then
        return sig:OnChange(callback)
    end
    return function() end  -- no-op for unknown keys
end

--- Subscribe to changes on any key in the store.
--- `callback` receives (key, newValue).  Uses a single internal dispatch list,
--- so registration is O(1) regardless of how many signals the store contains.
---@param callback fun(key: string, newValue: any)
---@return fun() unsubscribe
function Store:SubscribeAll(callback)
    local list = self._subscribeAll
    list[#list + 1] = callback
    return function()
        for i = 1, #list do
            if list[i] == callback then
                table.remove(list, i)
                return
            end
        end
    end
end

--- Register a callback that fires when any key in `keySet` changes.
--- `keySet` is used as a set (keys are strings, values are truthy).
--- `callback` receives (key, newValue).  Returns an unsubscribe function.
---@param keySet   table<string, any>
---@param callback fun(key: string, newValue: any)
---@return fun() unsubscribe
function Store:RegisterEffectMap(keySet, callback)
    local unsubs = {}
    for key in pairs(keySet) do
        local capturedKey = key
        local sig = self._signals[capturedKey]
        if sig then
            unsubs[#unsubs + 1] = sig:OnChange(function(value)
                callback(capturedKey, value)
            end)
        end
    end
    return function()
        for i = 1, #unsubs do unsubs[i]() end
    end
end

--- Create a derived read-only signal whose dependencies are named keys in
--- this store.  `computeFn` receives the current values of dep keys in order.
---@param keys      string[]        Key names within this store
---@param computeFn fun(...): any
---@param debugName? string
---@return Computed
function Store:Computed(keys, computeFn, debugName)
    local depSigs = {}
    for i, key in ipairs(keys) do
        depSigs[i] = self._signals[key]
    end
    return Computed.New(depSigs, computeFn, debugName)
end

--- Return the raw Signal for a key (escape hatch for advanced use).
---@param key string
---@return Signal|nil
function Store:GetSignal(key)
    return self._signals[key]
end

-- ############################################################
-- Library API
-- ############################################################

lib.Signal   = Signal
lib.Computed = Computed

--- Create a new Store backed by `settingsSource`.
--- One Signal is created for every key in `defaults` and immediately
--- available via Subscribe, GetSignal, RegisterEffectMap, and Computed.
---@param defaults       table<string, any>  Canonical default values; used by ApplyDefaults.
---@param settingsSource table               Live settings table (e.g. an AceDB profile proxy).
---@return zSignalStore
function lib:NewStore(defaults, settingsSource)
    ---@type zSignalStore
    local store = setmetatable({
        _defaults       = defaults,
        _settingsSource = settingsSource,
        _signals        = {},
        _subscribeAll   = {},
    }, Store)

    -- Create one Signal per default key, deep-copying table defaults so each
    -- signal owns its initial value independently.
    for key, defaultValue in pairs(defaults) do
        local initVal
        if type(defaultValue) == "table" then
            local copy = {}
            for k, v in pairs(defaultValue) do copy[k] = v end
            initVal = copy
        else
            initVal = defaultValue
        end
        store._signals[key] = Signal.New(initVal, key)
    end

    -- Wire each signal to the SubscribeAll dispatch list.
    -- The closure checks the list length before iterating, so this is a
    -- no-op when no SubscribeAll callbacks are registered (the common case).
    local subscribeAll = store._subscribeAll
    for key, sig in pairs(store._signals) do
        local capturedKey = key
        sig:OnChange(function(value)
            for i = 1, #subscribeAll do
                subscribeAll[i](capturedKey, value)
            end
        end)
    end

    return store
end
