--- === hs.watchable ===
---
--- A minimalistic Key-Value-Observer framework for Lua.
---
--- This module allows you to generate a table with a defined label or path that can be used to share data with other modules or code.  Other modules can register as watchers to a specific key-value pair within the watchable object table and will be automatically notified when the key-value pair changes.
---
--- The goal is to provide a mechanism for sharing state information between separate and (mostly) unrelated code easily and in an independent fashion.

local USERDATA_TAG = "hs.watchable"
-- local module       = require(USERDATA_TAG..".internal")
local module = {}

-- private variables and methods -----------------------------------------

local mt_object, mt_watcher
mt_object = {
    __watchers = {},
    __objects = setmetatable({}, {__mode = "kv"}),
    __values = setmetatable({}, {__mode = "k"}),
    __canChange = setmetatable({}, {__mode = "k"}),
    __name = USERDATA_TAG,
    __type = USERDATA_TAG,
    __index = function(self, index)
        return mt_object.__values[self][index]
    end,
    __newindex = function(self, index, value)
        local oldValue = mt_object.__values[self][index]
        mt_object.__values[self][index] = value
--         if oldValue ~= value then
        local objectPath = mt_object.__objects[self]
        if mt_object.__watchers[objectPath] then
            if mt_object.__watchers[objectPath][index] then
                for _, v in pairs(mt_object.__watchers[objectPath][index]) do
                    if v._active and v._callback then
                        if v._alwaysNotify or oldValue ~= value then
                            v._callback(v, objectPath, index, oldValue, value)
                        end
                    end
                end
            end
            if mt_object.__watchers[objectPath]["*"] then
                for _, v in pairs(mt_object.__watchers[objectPath]["*"]) do
                    if v._active and v._callback then
                        if v._alwaysNotify or oldValue ~= value then
                            v._callback(v, objectPath, index, oldValue, value)
                        end
                    end
                end
            end
        end
--         end
    end,
    __len = function(self)
        return #mt_object.__values[self]
    end,
    __pairs = function(self) return pairs(mt_object.__values[self]) end,
    __tostring = function(self) return USERDATA_TAG .. " table for path " .. mt_object.__objects[self] end,
}

mt_watcher = {
    __name = USERDATA_TAG .. ".watcher",
    __type = USERDATA_TAG .. ".watcher",
    __index = {
--- hs.watchable:pause() -> watchableObject
--- Method
--- Temporarily stop notifications about the key-value pair(s) watched by this watchableObject.
---
--- Parameters:
---  * None
---
--- Returns:
---  * the watchableObject
---
--- Notes:
---  * Only pauses notifications for this specific watchableObject -- if other watchers are set for the path watched by this watcher, they will still receive notifications if enabled and not paused.
        pause = function(self) self._active = false ; return self end,
--- hs.watchable:resume() -> watchableObject
--- Method
--- Resume notifications about the key-value pair(s) watched by this watchableObject which were previously paused.
---
--- Parameters:
---  * None
---
--- Returns:
---  * the watchableObject
---
--- Notes:
---  * Only resumes notifications for this specific watchableObject -- if other watchers are set for the path watched by this watcher are currently paused, they will remain paused.
        resume = function(self) self._active = true ; return self end,
--- hs.watchable:release() -> nil
--- Method
--- Removes the watchableObject so that key-value pairs watched by this object no longer generate notifications.
---
--- Parameters:
---  * None
---
--- Returns:
---  * nil
---
--- Notes:
---  * Only releases this specific watchableObject -- if other watchers are set for the path watched by this object, they remain bound.
        release = function(self)
            self._active = false
            if mt_object.__watchers[self._objPath][self._objKey] then -- may have already been removed by gc
                for _,v in pairs(mt_object.__watchers[self._objPath][self._objKey]) do
                    if v == self then mt_object.__watchers[self._objPath][self._objKey] = nil end
                end
            end
            setmetatable(self, nil)
            return nil
        end,
--- hs.watchable:callback([fn]) -> watchableObject | currentValue
--- Method
--- Change or remove the callback function for the watchableObject.
---
--- Parameters:
---  * `fn` - an optional function, or an explicit nil to remove, specifying the new callback function to receive notifications for this watchableObject
---
--- Returns:
---  * if a value is specified and is a function, an object with a __call metamethod, or an explicit nil, returns the watchableObject; otherwise returns the current callback.
---
--- Notes:
---  * see [hs.watchable.watch](#watch) for a description of the arguments the callback function should expect.
        callback = function(self, ...)
            local args = table.pack(...)
            local callback = args[1]
            if args.n == 0 then
                return self._callback
            elseif callback == nil then
                self._callback = nil
                return self
            elseif type(callback) == "function" or (getmetatable(callback) or {}).__call then
                self._callback = callback
                return self
            else
                error("callback must be a function or object with a __call metamethod", 2)
            end
        end,
--- hs.watchable:value([key]) -> currentValue
--- Method
--- Get the current value for the key-value pair being watched by the watchableObject
---
--- Parameters:
---  * `key` - if the watchableObject was defined with a key of "*", this argument is required and specifies the specific key of the watched table to retrieve the value for.  If a specific key was specified when the watchableObject was defined, this argument is ignored.
---
--- Returns:
---  * The current value for the key-value pair being watched by the watchableObject. May be nil.
        value = function(self, key)
            local lookupKey = self._objKey
            if lookupKey == "*" and key == nil then
                error("key required for watched path with wildcard key", 2)
            elseif lookupKey == "*" then
                lookupKey = key
            end
            local object = mt_object.__objects[self._objPath]
            return object and object[lookupKey]
        end,
--- hs.watchable:alwaysNotify([notify]) -> watchableObject | currentValue
--- Method
--- Get or set whether this watcher should be notified even when the new value is identical to the current value
---
--- Parameters:
---  * `notify` - an optional boolean specifying whether or not the watchableObject should trigger a callback when the value of the watched path is set to its current value (i.e. it is set, but doesn't actually change value)
---
--- Returns:
---  * if a boolean for `notify` is specified, returns the watchableObject; otherwise returns a boolean specifying whether or not a callback is initiated when the value of the path is set, but doesn't actually change value.
        alwaysNotify = function(self, ...)
            local args = table.pack(...)
            local notify = args[1]
            if args.n == 0 then
                return self._alwaysNotify
            elseif type(notify) == "boolean" then
                self._alwaysNotify = notify
                return self
            else
                error("notify must be a boolean", 2)
            end
        end,
--- hs.watchable:change([key], value) -> watchableObject
--- Method
--- Externally change the value of the key-value pair being watched by the watchableObject
---
--- Parameters:
---  * `key`   - if the watchableObject was defined with a key of "*", this argument is required and specifies the specific key of the watched table to change the value of.  If a specific key was specified when the watchableObject was defined, this argument must not be provided.
---  * `value` - the new value for the key.
---
--- Returns:
---  * the watchableObject
---
--- Notes:
---  * if external changes are not allowed for the specified path, this method generates an error
        change = function(self, ...)
            local args = table.pack(...)
            local key, value
            if args.n == 1 then
                key, value = nil, args[1]
            elseif args.n == 2 then
                key, value = args[1], args[2]
            else
                error("value or key, value arguments expected", 2)
            end
            local lookupKey = self._objKey
            if lookupKey == "*" and key == nil then
                error("key required for watched path with wildcard key", 2)
            elseif lookupKey == "*" then
                lookupKey = key
            end
            local object = mt_object.__objects[self._objPath]
            if object and mt_object.__canChange[object] then
                object[lookupKey] = value
            else
                error("external changes disallowed for watched path " .. self._objPath, 2)
            end
        end,
    },
    __gc = function(self) self.release(self) end,
    __tostring = function(self) return USERDATA_TAG .. ".watcher for path " .. self._path end,
}

-- Public interface ------------------------------------------------------

--- hs.watchable.new(path, [externalChanges]) -> table
--- Constructor
--- Creates a table that can be watched by other modules for key changes
---
--- Parameters:
---  * `path`            - the global name for this internal table that external code can refer to the table as.
---  * `externalChanges` - an optional boolean, default false, specifying whether external code can make changes to keys within this table (bi-directional communication).
---
--- Returns:
---  * a table with metamethods which will notify external code which is registered to watch this table for key-value changes.
---
--- Notes:
---  * This constructor is used by code which wishes to share state information which other code may register to watch.
---
---  * You may specify any string name as a path, but it must be unique -- an error will occur if the path name has already been registered.
---  * All key-value pairs stored within this table are potentially watchable by external code -- if you wish to keep some data private, do not store it in this table.
---  * `externalChanges` will apply to *all* keys within this table -- if you wish to only allow some keys to be externally modifiable, you will need to register separate paths.
---  * If external changes are enabled, you will need to register your own watcher with [hs.watchable.watch](#watch) if action is required when external changes occur.
module.new = function(path, allowChange)
    allowChange = allowChange or false
    if type(path) ~= "string" then error ("path must be a string", 2) end
    if mt_object.__objects[path] then
        error(path .. " already registered", 2)
    end
    local self = setmetatable({}, mt_object)
    mt_object.__objects[path] = self
    mt_object.__objects[self] = path
    mt_object.__canChange[self] = allowChange
    mt_object.__values[self] = {}
    return self
end

--- hs.watchable.watch(path, [key], callback) -> watchableObject
--- Constructor
--- Creates a watcher that will be invoked when the specified key in the specified path is modified.
---
--- Parameters:
---  * `path`     - a string specifying the path to watch.  If `key` is not provided, then this should be a string of the form "path.key" where the key will be identified as the string after the last "."
---  * `key`      - if provided, a string specifying the specific key within the path to watch.
---  * `callback` - an optional function which will be invoked when changes occur to the key specified within the path.  The function should expect the following arguments:
---    * `watcher` - the watcher object itself
---    * `path`    - the path being watched
---    * `key`     - the specific key within the path which invoked this callback
---    * `old`     - the old value for this key, may be nil
---    * `new`     - the new value for this key, may be nil
---
--- Returns:
---  * a watchableObject
---
--- Notes:
---  * This constructor is used by code which wishes to watch state information which is being shared by other code.
---
---  * The callback function is invoked after the new value has already been set -- the callback is a "didChange" notification, not a "willChange" notification.
---
---  * If the key (specified as a separate argument or as the final component of path) is "*", then all key-value pair changes that occur for the table specified by the path will invoke a callback.  This is a shortcut for watching an entire table, rather than just a specific key-value pair of the table.
---  * It is possible to register a watcher for a path that has not been registered with [hs.watchable.new](#new) yet. Retrieving the current value with [hs.watchable:value](#value) in such a case will return nil.
module.watch = function(path, key, callback)
    if type(path) ~= "string" then error ("path must be a string", 2) end
    if type(key) == "function" or type(key) == "nil" or (getmetatable(key) or {}).__call then
        callback = key
        local objPath, objKey = path:match("^(.+)%.([^%.]+)$")
        if not (objPath and objKey) then error ("malformed path; must be of the form 'path.key' or path and key must be separate arguments", 2) end
        path = objPath
        key = objKey
    end
    if not (type(callback) == "function" or type(callback) == "nil" or (getmetatable(callback) or {}).__call) then error ("callback must be a function, object with a __call metamethod, or nil", 2) end

    local objPath, objKey = path, key

    local self = setmetatable({
        _path = objPath .. "." .. objKey,
        _objKey = objKey,
        _objPath = objPath,
        _alwaysNotify = false,
        _active = true,
        _callback = callback,
    }, mt_watcher)

    if not mt_object.__watchers[objPath] then mt_object.__watchers[objPath] = {} end
    if not mt_object.__watchers[objPath][objKey] then mt_object.__watchers[objPath][objKey] = setmetatable({}, {__mode = "v"}) end
    table.insert(mt_object.__watchers[objPath][objKey], self)

    return self
end

-- Return Module Object --------------------------------------------------

-- for debugging, may remove in the future
setmetatable(module, {
    __index = function(_, key)
        if key == "_debug" then
            return {
                mt_object  = mt_object,
                mt_watcher = mt_watcher,
            }
        else
            return nil
        end
    end,
})

return module
