--- === hs.ipc ===
---
--- Provides Hammerspoon with the ability to create both local and remote message ports for inter-process communication.
---
--- The most common use of this module is to provide support for the command line tool `hs` which can be added to your terminal shell environment with [hs.ipc.cliInstall](#cliInstall).  The command line tool will not work unless the `hs.ipc` module is loaded first, so it is recommended that you add `require("hs.ipc")` to your Hammerspoon `init.lua` file (usually located at ~/.hammerspoon/init.lua) so that it is always available when Hammerspoon is running.
---
--- This module is based heavily on code from Mjolnir.

local USERDATA_TAG = "hs.ipc"
local module       = require("hs.libipc")

local timer    = require("hs.timer")
local settings = require("hs.settings")
local log      = require("hs.logger").new(USERDATA_TAG, (settings.get(USERDATA_TAG .. ".logLevel") or "warning"))
local json     = require("hs.json")
local fnutils  = require("hs.fnutils")

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

local MSG_ID = {
    REGISTER   = 100,   -- register an instance with the v2 cli
    UNREGISTER = 200,   -- unregister an instance with the v2 cli

    LEGACYCHK  = 900,   -- query to test if we are the v2 ipc or not (v1 will ignore the id and evaluate)
    COMMAND    = 500,   -- a command from the user from a v2 cli
    QUERY      = 501,   -- an internal query from the v2 cli

    LEGACY     =  0,    -- v1 cli/ipc only used the 0 msgID

    ERROR      = -1,    -- result was an error
    OUTPUT     =  1,    -- print output
    RETURN     =  2,    -- result
    CONSOLE    =  3,    -- cloned console output
}

-- stop printReplacement from being reentrant
-- otherwise errors might cascade into lots of recursive prints
-- hammerspoon is single threaded, thus this does not need a semaphore
-- otherwise we'll have to deal with a potential race condition
module.insidePrintInstances = {}

module.print_enter = function(instance)
  val = module.insidePrintInstances[instance] or 0
  module.insidePrintInstances[instance] = val + 1
end

module.print_exit = function(instance)
  -- make sure instance exists
  if module.insidePrintInstances[instance] then
    module.insidePrintInstances[instance] = module.insidePrintInstances[instance] - 1
    -- make sure to delete the entry from the table to avoid
    -- growing forever
    if module.insidePrintInstances[instance] == 0 then
      module.insidePrintInstances[instance] = nil
    end
  end
end

module.print_inside = function(instance)
  -- return true if we are already inside printReplacement
  val = module.insidePrintInstances[instance]
  return val and val > 0
end
  
local originalPrint = print
local printReplacement = function(...)
    originalPrint(...)
    for id,v in pairs(module.__registeredCLIInstances) do
        if v._cli.console and v.print and not v._cli.quietMode then
          if module.print_inside(id) then
            log.w(string.format("Instance of [%s] already recursing, refusing request.", id))
          else
            module.print_enter(id)
            --            v.print(...)
            -- make it more obvious what is console output versus the command line's
            local things = table.pack(...)
            local stdout = (things.n > 0) and tostring(things[1]) or ""
            for i = 2, things.n do
              stdout = stdout .. "\t" .. tostring(things[i])
            end
            v._cli.remote:sendMessage(stdout .. "\n", MSG_ID.CONSOLE)
            module.print_exit(id)
          end
        end
    end
end
print = printReplacement -- luacheck: ignore

local originalReload = hs.reload
local reloadReplacement = function(...)
    hs._reloadTriggered = true
    return originalReload(...)
end
hs.reload = reloadReplacement

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

--- hs.ipc.cliColors([colors]) -> table
--- Function
--- Get or set the terminal escape codes used to produce colorized output in the `hs` command line tool
---
--- Parameters:
---  * colors - an optional table or explicit nil specifying the colors to use when colorizing output for the command line tool.  If you specify an explicit nil, the colors will revert to their defaults.  If you specify a table it must contain one or more of the following keys with the terminal key sequence as a string for the value:
---    * initial - this color is used for the initial tagline when starting the command line tool and for output to the Hammerspoon console that is redirected to the instance.  Defaults to "\27[35m" (foreground magenta).
---    * input   - this color is used for input typed in by the user into the cli instance.  Defaults to "\27[33m" (foreground yellow).
---    * output  - this color is used for output generated by the commands executed within the instance and the results returned.  Defaults to "\27[36m" (foreground cyan).
---    * error   - this color is used for lua errors generated by the commands executed within the instance.  Defaults to "\27[31m" (foreground red).
---
--- Returns:
---  * a table describing the colors used when colorizing output in the `hs` command line tool.
---
--- Notes:
---  * For a brief intro into terminal colors, you can visit a web site like this one [http://jafrog.com/2013/11/23/colors-in-terminal.html](http://jafrog.com/2013/11/23/colors-in-terminal.html)
---  * Lua doesn't support octal escapes in it's strings, so use `\x1b` or `\27` to indicate the `escape` character e.g. `ipc.cliSetColors{ initial = "", input = "\27[33m", output = "\27[38;5;11m" }`
---
---  * Changes made with this function are saved with `hs.settings` with the following labels and will persist through a reload or restart of Hammerspoon: "ipc.cli.color_initial", "ipc.cli.color_input", "ipc.cli.color_output", and "ipc.cli.color_error"
module.cliColors = function(...)
    local args = table.pack(...)
    if args.n > 0 then
        if type(args[1]) == "nil" then
            settings.clear("ipc.cli.color_initial")
            settings.clear("ipc.cli.color_input")
            settings.clear("ipc.cli.color_output")
            settings.clear("ipc.cli.color_error")
        else
            local colors = args[1]
            if colors.initial then settings.set("ipc.cli.color_initial", colors.initial) end
            if colors.input   then settings.set("ipc.cli.color_input",   colors.input)   end
            if colors.output  then settings.set("ipc.cli.color_output",  colors.output)  end
            if colors.error   then settings.set("ipc.cli.color_error",   colors.error)   end
        end
    end
    local colors = {}
    colors.initial = settings.get("ipc.cli.color_initial") or "\27[35m" ;
    colors.input   = settings.get("ipc.cli.color_input")   or "\27[33m" ;
    colors.output  = settings.get("ipc.cli.color_output")  or "\27[36m" ;
    colors.error   = settings.get("ipc.cli.color_error")   or "\27[31m" ;
    return setmetatable(colors, {
        __tostring = function(self)
            local res = ""
            for k,v in fnutils.sortByKeys(self) do res = res .. string.format("%-7s = %q\n", k, v) end
            return res
        end,
    })
end

--- hs.ipc.cliGetColors() -> table
--- Deprecated
--- See [hs.ipc.cliColors](#cliColors).
local seenDeprecatedGetColors = false
module.cliGetColors = function()
    if not seenDeprecatedGetColors then
        seenDeprecatedGetColors = true
        log.w("hs.ipc.cliGetColors is deprecated and may be removed in the future.  Please use hs.ipc.cliColors instead.")
    end
    return module.cliColors()
end

--- hs.ipc.cliSetColors(table) -> table
--- Deprecated
--- See [hs.ipc.cliColors](#cliColors).
local seenDeprecatedSetColors = false
module.cliSetColors = function(colors)
    if not seenDeprecatedSetColors then
        seenDeprecatedSetColors = true
        log.w("hs.ipc.cliSetColors is deprecated and may be removed in the future.  Please use hs.ipc.cliColors instead.")
    end
    return module.cliColors(colors)
end

--- hs.ipc.cliResetColors()
--- Deprecated
--- See [hs.ipc.cliColors](#cliColors).
local seenDeprecatedResetColors = false
module.cliResetColors = function()
    if not seenDeprecatedResetColors then
        seenDeprecatedResetColors = true
        log.w("hs.ipc.cliResetColors is deprecated and may be removed in the future.  Please use hs.ipc.cliColors instead.")
    end
    return module.cliColors(nil)
end

--- hs.ipc.cliSaveHistory([state]) -> boolean
--- Function
--- Get or set whether or not the command line tool saves a history of the commands you type.
---
--- Parameters:
---  * state - an optional boolean (default false) specifying whether or not a history of the commands you type into the command line tool should be saved between sessions.
---
--- Returns:
---  * the current, possibly changed, value
---
--- Notes:
---  * If this is enabled, your history is saved in `hs.configDir .. ".cli.history"`, which is usually "~/.hammerspoon/.cli.history".
---  * If you have multiple invocations of the command line tool running at the same time, only the history of the last one cleanly exited is saved; this is a limitation of the readline wrapper Apple has provided for libedit and at present no workaround is known.
---
---  * Changes made with this function are saved with `hs.settings` with the label "ipc.cli.saveHistory" and will persist through a reload or restart of Hammerspoon.
module.cliSaveHistory = function(...)
    local args = table.pack(...)
    if args.n > 0 then
        settings.set("ipc.cli.saveHistory", args[1] and true or nil)
    end
    return settings.get("ipc.cli.saveHistory") or false
end

--- hs.ipc.cliSaveHistorySize([size]) -> number
--- Function
--- Get or set whether the maximum number of commands saved when command line tool history saving is enabled.
---
--- Parameters:
---  * size - an optional integer (default 1000) specifying the maximum number of commands to save when [hs.ipc.cliSaveHistory](#cliSaveHistory) is set to true.
---
--- Returns:
---  * the current, possibly changed, value
---
--- Notes:
---  * When [hs.ipc.cliSaveHistory](#cliSaveHistory) is enabled, your history is saved in `hs.configDir .. ".cli.history"`, which is usually "~/.hammerspoon/.cli.history".
---  * If you have multiple invocations of the command line tool running at the same time, only the history of the last one cleanly exited is saved; this is a limitation of the readline wrapper Apple has provided for libedit and at present no workaround is known.
---
---  * Changes made with this function are saved with `hs.settings` with the label "ipc.cli.historyLimit" and will persist through a reload or restart of Hammerspoon.
module.cliSaveHistorySize = function(...)
    local args = table.pack(...)
    if args.n > 0 then
        settings.set("ipc.cli.historyLimit", math.tointeger(args[1]) or nil)
    end
    return settings.get("ipc.cli.historyLimit") or 1000
end

--- hs.ipc.cliStatus([path][,silent]) -> bool
--- Function
--- Gets the status of the `hs` command line tool
---
--- Parameters:
---  * path - An optional string containing a path to look for the `hs` tool. Defaults to `/usr/local`
---  * silent - An optional boolean indicating whether or not to print errors to the Hammerspoon Console
---
--- Returns:
---  * A boolean, true if the `hs` command line tool is correctly installed, otherwise false
module.cliStatus = function(p, s)
    local path = p or "/usr/local"
    local mod_path = hs.processInfo["frameworksPath"]
    local resource_path = hs.processInfo["resourcePath"]
    local frameworks_path = hs.processInfo["frameworksPath"]

    local silent = s or false

    local bin_file = os.execute("[ -f \""..path.."/bin/hs\" ]")
    local man_file = os.execute("[ -f \""..path.."/share/man/man1/hs.1\" ]")
    local bin_link = os.execute("[ -L \""..path.."/bin/hs\" ]")
    local man_link = os.execute("[ -L \""..path.."/share/man/man1/hs.1\" ]")
    local bin_ours = os.execute("[ \""..path.."/bin/hs\" -ef \""..frameworks_path.."/hs/hs\" ]")
    local man_ours = os.execute("[ \""..path.."/share/man/man1/hs.1\" -ef \""..resource_path.."/man/hs.man\" ]")

    local result = bin_file and man_file and bin_link and man_link and bin_ours and man_ours or false
    local broken = false

    if not bin_ours and bin_file then
        if not silent then
            print([[cli installation problem: 'hs' is not ours.]])
        end
        broken = true
    end
    if not man_ours and man_file then
        if not silent then
            print([[cli installation problem: 'hs.1' is not ours.]])
        end
        broken = true
    end
    if bin_file and not bin_link then
        if not silent then
            print([[cli installation problem: 'hs' is an independent file won't be updated when Hammerspoon is.]])
        end
        broken = true
    end
    if not bin_file and bin_link then
        if not silent then
            print([[cli installation problem: 'hs' is a dangling link.]])
        end
        broken = true
    end
    if man_file and not man_link then
        if not silent then
            print([[cli installation problem: man page for 'hs.1' is an independent file and won't be updated when Hammerspoon is.]])
        end
        broken = true
    end
    if not man_file and man_link then
        if not silent then
            print([[cli installation problem: man page for 'hs.1' is a dangling link.]])
        end
        broken = true
    end
    if ((bin_file and bin_link) and not (man_file and man_link)) or ((man_file and man_link) and not (bin_file and bin_link)) then
        if not silent then
            print([[cli installation problem: incomplete installation of 'hs' and 'hs.1'.]])
        end
        broken = true
    end

    return broken and "broken" or result
end

--- hs.ipc.cliInstall([path][,silent]) -> bool
--- Function
--- Installs the `hs` command line tool
---
--- Parameters:
---  * path - An optional string containing a path to install the tool in. Defaults to `/usr/local`
---  * silent - An optional boolean indicating whether or not to print errors to the Hammerspoon Console
---
--- Returns:
---  * A boolean, true if the tool was successfully installed, otherwise false
---
--- Notes:
---  * If this function fails, it is likely that you have some old/broken symlinks. You can use `hs.ipc.cliUninstall()` to forcibly tidy them up
---  * You may need to pre-create `/usr/local/bin` and `/usr/local/share/man/man1` in a terminal using sudo, and adjust permissions so your login user can write to them
module.cliInstall = function(p, s)
    local path = p or "/usr/local"
    local silent = s or false
    if module.cliStatus(path, true) == false then
        local mod_path = hs.processInfo["frameworksPath"]
        local resource_path = hs.processInfo["resourcePath"]
        os.execute("ln -s \""..mod_path.."/hs/hs\" \""..path.."/bin/\"")
        os.execute("ln -s \""..resource_path.."/man/hs.man\" \""..path.."/share/man/man1/hs.1\"")
    end
    return module.cliStatus(path, silent)
end

--- hs.ipc.cliUninstall([path][,silent]) -> bool
--- Function
--- Uninstalls the `hs` command line tool
---
--- Parameters:
---  * path - An optional string containing a path to remove the tool from. Defaults to `/usr/local`
---  * silent - An optional boolean indicating whether or not to print errors to the Hammerspoon Console
---
--- Returns:
---  * A boolean, true if the tool was successfully removed, otherwise false
---
--- Notes:
---  * This function used to be very conservative and refuse to remove symlinks it wasn't sure about, but now it will unconditionally remove whatever it finds at `path/bin/hs` and `path/share/man/man1/hs.1`. This is more likely to be useful in situations where this command is actually needed (please open an Issue on GitHub if you disagree!)
module.cliUninstall = function(p, s)
    local path = p or "/usr/local"
    local silent = s or false
    os.execute("rm \""..path.."/bin/hs\"")
    os.execute("rm \""..path.."/share/man/man1/hs.1\"")
    return not module.cliStatus(path, silent)
end

module.__registeredCLIInstances = {}
-- cleanup in case someone goes away without saying goodbye
module.__registeredInstanceCleanup = timer.doEvery(60, function()
    for k, v in pairs(module.__registeredCLIInstances) do
        if v._cli.remote and not v._cli.remote:isValid() then
            log.df("pruning %s; message port is no longer valid", k)
            v._cli.remote:delete()
            module.__registeredCLIInstances[k] = nil
        elseif not v._cli.remote then
            module.__registeredCLIInstances[k] = nil
        end
    end
end)

module.__defaultHandler = function(_, msgID, msg)
    if msgID == MSG_ID.LEGACYCHK then
        -- the message sent will be a mathematical equation; the original ipc will evaluate it because it ignored
        -- the msgid.  We send back a version string instead
        return "version:2.0a"
    elseif msgID == MSG_ID.REGISTER then      -- registering a new instance
        local instanceID, arguments = msg:match("^([%w-]+)\0(.*)$")
        if not instanceID then instanceID, arguments = msg, nil end
        local scriptArguments = nil
        local quietMode       = false
        local console         = "none"
        if arguments then
            arguments = json.decode(arguments)
            scriptArguments = {}
            local seenSeparator = false
            for i, v in ipairs(arguments) do
                if i > 1 and (v == "--" or v:match("^~") or v:match("^%.?/")) then seenSeparator = true end
                if seenSeparator then
                    table.insert(scriptArguments, v)
                else
                    if v == "-q" then quietMode = true end
                    if v == "-C" then console = "mirror" end
                    if v == "-P" then console = "legacy" end
                end
            end
            if #scriptArguments == 0 then scriptArguments = arguments end
        end
        log.df("registering %s", instanceID)

        module.__registeredCLIInstances[instanceID] = setmetatable({
            _cli = {
                remote    = module.remotePort(instanceID),
                console   = console,
                _args     = arguments,
                args      = scriptArguments,
                quietMode = quietMode,
            },
            print  = function(...)
                local parent = module.__registeredCLIInstances[instanceID]._cli
                if parent.quietMode then return end
                local things = table.pack(...)
                local stdout = (things.n > 0) and tostring(things[1]) or ""
                for i = 2, things.n do
                    stdout = stdout .. "\t" .. tostring(things[i])
                end
                module.__registeredCLIInstances[instanceID]._cli.remote:sendMessage(stdout .. "\n", MSG_ID.OUTPUT)
                if type(parent.console) == "nil" then
                    originalPrint(...)
                end
            end,
        }, {
            __index    = _G,
           __newindex = function(_, key, value)
               _G[key] = value
           end,
        })
    elseif msgID == MSG_ID.UNREGISTER then  -- unregistering an instance
        log.df("unregistering %s", msg)
        module.__registeredCLIInstances[msg]._cli.remote:delete()
        module.__registeredCLIInstances[msg] = nil
    elseif msgID == MSG_ID.COMMAND or msgID == MSG_ID.QUERY then
        local instanceID, code = msg:match("^([%w-]*)\0(.*)$")
--        print(msg, instanceID, code)
        if instanceID then

            if hs._consoleInputPreparser then
              if type(hs._consoleInputPreparser) == "function" then
                local status, code2 = pcall(hs._consoleInputPreparser, code)
                if status then
                  code = code2
                else
                  hs.luaSkinLog.ef("console preparse error: %s", code2)
                end
              else
                  hs.luaSkinLog.e("console preparser must be a function or nil")
              end
            end

            local fnEnv = module.__registeredCLIInstances[instanceID]
            local fn, err = load("return " .. code, "return " .. code, "bt", fnEnv)
            if not fn then fn, err = load(code, code, "bt", fnEnv) end
            local results = fn and table.pack(pcall(fn)) or { false, err, n = 2 }

            local str = (results.n > 1) and tostring(results[2]) or ""
            for i = 3, results.n do
                str = str .. "\t" .. tostring(results[i])
            end

            if #str > 0 then str = str .. "\n" end
            if msgID == MSG_ID.COMMAND then
                if not hs._reloadTriggered  then
                    fnEnv._cli.remote:sendMessage(str, results[1] and MSG_ID.RETURN or MSG_ID.ERROR)
                end
                return results[1] and "ok" or "error"
            else
                return str
            end
        else
            log.ef("unexpected message received: %s", msg)
        end
    elseif msgID == MSG_ID.LEGACY then
        log.df("in legacy handler")
        local _, str = (msg:sub(1,1) == "r"), msg:sub(2)

        if hs._consoleInputPreparser then
          if type(hs._consoleInputPreparser) == "function" then
            local status, s2 = pcall(hs._consoleInputPreparser, str)
            if status then
              str = s2
            else
              hs.luaSkinLog.ef("console preparse error: %s", s2)
            end
          else
              hs.luaSkinLog.e("console preparser must be a function or nil")
          end
        end

        local originalprint = print
        local fakestdout = ""
        print = function(...) -- luacheck: ignore
            originalprint(...)
            local things = table.pack(...)
            for i = 1, things.n do
                if i > 1 then fakestdout = fakestdout .. "\t" end
                fakestdout = fakestdout .. tostring(things[i])
            end
            fakestdout = fakestdout .. "\n"
        end

--        local fn = raw and rawhandler or module.handler
        local fn = function(ss)
            local fn, err = load("return " .. ss)
            if not fn then fn, err = load(ss) end
            if fn then return fn() else return err end
        end

        local results = table.pack(pcall(function() return fn(str) end))

        str = ""
        for i = 2, results.n do
            if i > 2 then str = str .. "\t" end
            str = str .. tostring(results[i])
        end

        print = originalprint -- luacheck: ignore
        return fakestdout .. str
    else
        log.ef("unexpected message id received: %d, %s", msgID, msg)
    end
end

module.__default = module.localPort("Hammerspoon", module.__defaultHandler)

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

return setmetatable(module, {
    __index = function(self, key)
        if key == "handler" then
            log.e("Setting a specialized handler is no longer supported in hs.ipc. Use `hs.ipc.localPort` to setup your own message port for handling custom requests.")
            return nil
        else
            return rawget(self, key) -- probably not necessary, but...
        end
    end,
    __newindex = function(self, key, value)
        if key == "handler" then
            log.e("Setting a specialized handler is no longer supported in hs.ipc. Use `hs.ipc.localPort` to setup your own message port for handling custom requests.")
        else
            rawset(self, key, value)
        end
    end,
})
