local module = {}

--- === hs.alert ===
---
--- Simple on-screen alerts

local drawing = require("hs.drawing")
local timer   = require("hs.timer")
local screen  = require("hs.screen")
local uuid    = require"hs.host".uuid
local stext   = require"hs.styledtext".new

local stextMT = hs.getObjectMetatable("hs.styledtext")

module._visibleAlerts = {}

--- hs.alert.defaultStyle[]
--- Variable
--- A table defining the default visual style for the alerts generated by this module.
---
--- The following may be specified in this table (any other key is ignored):
---  * Keys which affect the alert rectangle:
---    * fillColor   - a table as defined by the `hs.drawing.color` module to specify the background color for the alert, defaults to { white = 0, alpha = 0.75 }.
---    * strokeColor - a table as defined by the `hs.drawing.color` module to specify the outline color for the alert, defaults to { white = 1, alpha = 1 }.
---    * strokeWidth - a number specifying the width of the outline for the alert, defaults to 2
---    * radius      - a number specifying the radius used for the rounded corners of the alert box, defaults to 27
---
---  * Keys which affect the text of the alert when the message is a string (note that these keys will be ignored if the message being displayed is already an `hs.styledtext` object):
---    * textColor   - a table as defined by the `hs.drawing.color` module to specify the message text color for the alert, defaults to { white = 1, alpha = 1 }.
---    * textFont    - a string specifying the font to be used for the alert text, defaults to ".AppleSystemUIFont" which is a symbolic name representing the systems default user interface font.
---    * textSize    - a number specifying the font size to be used for the alert text, defaults to 27.
---    * textStyle   - an optional table, defaults to `nil`, specifying that a string message should be converted to an `hs.styledtext` object using the style elements specified in this table.  This table should conform to the key-value pairs as described in the documentation for the `hs.styledtext` module.  If this table does not contain a `font` key-value pair, one will be constructed from the `textFont` and `textSize` keys (or their defaults); likewise, if this table does not contain a `color` key-value pair, one will be constructed from the `textColor` key (or its default).
---    * padding     - the number of pixels to reserve around each side of the text and/or image, defaults to textSize/2
---    * atScreenEdge   - 0: screen center (default); 1: top edge; 2: bottom edge . Note when atScreenEdge>0, the latest alert will overlay above the previous ones if multiple alerts visible on same edge; and when atScreenEdge=0, latest alert will show below previous visible ones without overlap.
---    * fadeInDuration  - a number in seconds specifying the fade in duration of the alert, defaults to 0.15
---    * fadeOutDuration - a number in seconds specifying the fade out duration of the alert, defaults to 0.15
---
--- If you modify these values directly, it will affect all future alerts generated by this module.  To adjust one of these properties for a single alert, use the optional `style` argument to the [hs.alert.show](#show) function.
module.defaultStyle = {
    strokeWidth  = 2,
    strokeColor = { white = 1, alpha = 1 },
    fillColor   = { white = 0, alpha = 0.75 },
    textColor = { white = 1, alpha = 1 },
    textFont  = ".AppleSystemUIFont",
    textSize  = 27,
    radius = 27,
    atScreenEdge = 0,
    fadeInDuration = 0.15,
    fadeOutDuration = 0.15,
    padding = nil,
}

local purgeAlert = function(UUID, duration)
    duration = math.max(duration, 0.0) or 0.15
    local indexToRemove
    for i,v in ipairs(module._visibleAlerts) do
        if v.UUID == UUID then
            if v.timer then v.timer:stop() end
            for i2,v2 in ipairs(v.drawings) do
                v2:hide(duration)
                if duration > 0.0 then
                    timer.doAfter(duration, function() 
                       v2:hide()
                       v2 = nil
                    end)
                end
                v.drawings[i2] = nil
            end
            indexToRemove = i
            break
        end
    end
    if indexToRemove then
        table.remove(module._visibleAlerts, indexToRemove)
    end
end

local showAlert = function(message, image, style, screenObj, duration)
    local thisAlertStyle = {}
    for k,v in pairs(module.defaultStyle) do thisAlertStyle[k] = v end
    if type(style) == "table" then
        for k,v in pairs(style) do thisAlertStyle[k] = v end
    end

    local textSize  = thisAlertStyle.textSize
    local textFont  = thisAlertStyle.textFont
    local textColor = thisAlertStyle.textColor

    if type(thisAlertStyle.textStyle) == "table" and getmetatable(message) ~= stextMT then
        if not thisAlertStyle.textStyle.font then
            thisAlertStyle.textStyle.font = { name = textFont, size = textSize }
        end
        if not thisAlertStyle.textStyle.color then
            thisAlertStyle.textStyle.color = textColor
        end
        textSize  = thisAlertStyle.textStyle.font.size
        textFont  = thisAlertStyle.textStyle.font.name
        textColor = thisAlertStyle.textStyle.color
        message   = stext(message, thisAlertStyle.textStyle)
--        print(finspect(message:asTable()))
    end

    local screenFrame = screenObj.fullFrame and screenObj:fullFrame() or screenObj:frame()

    local absoluteTop = screenFrame.y + (screenFrame.h * (1 - 1 / 1.55) + 55) -- mimic module behavior for inverted rect
    if thisAlertStyle.atScreenEdge > 0 then
        absoluteTop = screenFrame.y -- this is for atScreenEdge = 1, and atScreenEdge = 2 case will be handled later
    else
        if #module._visibleAlerts > 0 then
            -- we're looking for the latest on the same screen
            for i = #module._visibleAlerts, 1, -1 do
                if screenObj == module._visibleAlerts[i].screen and module._visibleAlerts[i].atScreenEdge == 0 then
                    absoluteTop = module._visibleAlerts[i].frame.y + module._visibleAlerts[i].frame.h + 3
                    break
                end
            end
        end
    end

    if absoluteTop > (screenFrame.y + screenFrame.h) then
        absoluteTop = screenFrame.y
    end

    local alertEntry = {
        drawings = {},
        screen = screenObj,
        atScreenEdge = thisAlertStyle.atScreenEdge
    }
    local UUID = uuid()
    alertEntry.UUID = UUID

    local padding = thisAlertStyle.padding or textSize/2
    local strokeWidth = thisAlertStyle.strokeWidth -- strokeWidth should be used to adjust position and padding.

    -- If no message is specified, don't reserve space for it
    local textFrame
    if message == "" then
        textFrame = {h = 0, w = 0}
    else
        textFrame = drawing.getTextDrawingSize(message, { font = textFont, size = textSize })
        textFrame.w = math.ceil(textFrame.w) -- drawing.getTextDrawingSize may return a float value, and use it directly could cause some display problem, the last character of a line may disappear.
    end

    -- Define the size of the drawing frame
    local drawingFrame = {
        h = textFrame.h + padding * 2 + strokeWidth,
        w = textFrame.w + padding * 2 + strokeWidth,
    }
    if image then
        -- Increase the size to make room for the image
        drawingFrame.w = drawingFrame.w + image:size().w
        drawingFrame.h = math.max(drawingFrame.h, image:size().h + padding * 2 + strokeWidth)
        if message ~= "" then
          --Add space for padding between the image and the message
          drawingFrame.w = drawingFrame.w + padding
        end
    end

    -- Use the size to set the position
    drawingFrame.x = screenFrame.x + (screenFrame.w - drawingFrame.w) / 2

    if thisAlertStyle.atScreenEdge == 2 then
        drawingFrame.y = screenFrame.y + screenFrame.h - drawingFrame.h
    else
        drawingFrame.y = absoluteTop
    end

    table.insert(alertEntry.drawings, drawing.rectangle(drawingFrame)
                                            :setStroke(true)
                                            :setStrokeWidth(thisAlertStyle.strokeWidth)
                                            :setStrokeColor(thisAlertStyle.strokeColor)
                                            :setFill(true)
                                            :setFillColor(thisAlertStyle.fillColor)
                                            :setRoundedRectRadii(thisAlertStyle.radius, thisAlertStyle.radius)
                                            :show(thisAlertStyle.fadeInDuration)
    )

    -- Constraints for placing the text
    local textMinX = drawingFrame.x
    local textMaxWidth = drawingFrame.w

    if image then
        local iconFrame = {
          x = drawingFrame.x + padding + strokeWidth / 2,
          y = drawingFrame.y + (drawingFrame.h - image:size().h) / 2,
          h = image:size().h,
          w = image:size().w
        }
        table.insert(alertEntry.drawings, drawing.image(iconFrame, image)
                                                :orderAbove(alertEntry.drawings[1])
                                                :show(thisAlertStyle.fadeInDuration)
        )

        -- Shrink the space the text can draw in to account for the image
        textMinX = textMinX + iconFrame.w + padding
        textMaxWidth = textMaxWidth - iconFrame.w - padding
    end

    -- Draw the text in the center of the remaining space
    textFrame.x = textMinX + (textMaxWidth - textFrame.w) / 2
    textFrame.y = drawingFrame.y + (drawingFrame.h - textFrame.h) / 2
    table.insert(alertEntry.drawings, drawing.text(textFrame, message)
                                            :setTextFont(textFont)
                                            :setTextSize(textSize)
                                            :setTextColor(textColor)
                                            :orderAbove(alertEntry.drawings[1])
                                            :show(thisAlertStyle.fadeInDuration)
    )
    alertEntry.frame = drawingFrame

    table.insert(module._visibleAlerts, alertEntry)
    if type(duration) == "number" then
        alertEntry.timer = timer.doAfter(duration, function()
            purgeAlert(UUID, thisAlertStyle.fadeOutDuration)
        end)
    end
    return UUID
end

--- hs.alert.showWithImage(str, image, [style], [screen], [seconds]) -> uuid
--- Function
--- Shows an image and a message in large words briefly in the middle of the screen; does tostring() on its argument for convenience.
---
--- Parameters:
---  * str     - The string or `hs.styledtext` object to display in the alert
---  * image   - The image to display in the alert
---  * style   - an optional table containing one or more of the keys specified in [hs.alert.defaultStyle](#defaultStyle).  If `str` is already an `hs.styledtext` object, this argument is ignored.
---  * screen  - an optional `hs.screen` or `hs.window` userdata object specifying the screen (monitor) or window to display the alert on.  Defaults to `hs.screen.mainScreen()` which corresponds to the screen with the currently focused window.
---  * seconds - The number of seconds to display the alert. Defaults to 2.  If seconds is specified and is not a number, displays the alert indefinitely.
---
--- Returns:
---  * a string identifier for the alert.
---
--- Notes:
---  * The optional parameters are parsed in the order presented as follows:
---    * if the argument is a table and `style` has not previously been set, then the table is assigned to `style`
---    * if the argument is a userdata and `screen` has not previously been set, then the userdata is assigned to `screen`
---    * if `duration` has not been set, then it is assigned the value of the argument
---    * if all of these conditions fail for a given argument, then an error is returned
---  * The reason for this logic is to support the creation of persistent alerts as was previously handled by the module: If you specify a non-number value for `seconds` you will need to store the string identifier returned by this function so that you can close it manually with `hs.alert.closeSpecific` when the alert should be removed.
---  * Any style element which is not specified in the `style` argument table will use the value currently defined in the [hs.alert.defaultStyle](#defaultStyle) table.
module.showWithImage = function(message, image, ...)
    local style, screenObj, duration
    for i,v in ipairs(table.pack(...)) do
        if type(v) == "table" and not style then
            style = v
        elseif type(v) == "userdata" and not screenObj then
            screenObj = v
        elseif type(duration) == "nil" then
            duration = v
        else
            error("unexpected type " .. type(v) .. " found for argument " .. tostring(i + 1), 2)
        end
    end
    if getmetatable(message) ~= stextMT then
        message = tostring(message)
    end
    duration  = duration or 2.0
    screenObj = screenObj or screen.mainScreen()
    return showAlert(message, image, style, screenObj, duration)
end

--- hs.alert.show(str, [style], [screen], [seconds]) -> uuid
--- Function
--- Shows a message in large words briefly in the middle of the screen; does tostring() on its argument for convenience.
---
--- Parameters:
---  * str     - The string or `hs.styledtext` object to display in the alert
---  * style   - an optional table containing one or more of the keys specified in [hs.alert.defaultStyle](#defaultStyle).  If `str` is already an `hs.styledtext` object, this argument is ignored.
---  * screen  - an optional `hs.screen` userdata object specifying the screen (monitor) to display the alert on.  Defaults to `hs.screen.mainScreen()` which corresponds to the screen with the currently focused window.
---  * seconds - The number of seconds to display the alert. Defaults to 2.  If seconds is specified and is not a number, displays the alert indefinitely.
---
--- Returns:
---  * a string identifier for the alert.
---
--- Notes:
---  * For convenience, you can call this function as `hs.alert(...)`
---  * This function effectively calls `hs.alert.showWithImage(msg, nil, ...)`. As such, all the same rules apply regarding argument processing
module.show = function(message, ...)
    return module.showWithImage(message, nil, ...)
end


--- hs.alert.closeAll([seconds])
--- Function
--- Closes all alerts currently open on the screen
---
--- Parameters:
---  * seconds - Optional number specifying the fade out duration. Defaults to `fadeOutDuration` value currently defined in the [hs.alert.defaultStyle](#defaultStyle)
---
--- Returns:
---  * None
module.closeAll = function(duration)
    duration = duration and math.max(duration, 0.0) or module.defaultStyle.fadeOutDuration
    while (#module._visibleAlerts > 0) do
        purgeAlert(module._visibleAlerts[#module._visibleAlerts].UUID, duration)
    end
end

--- hs.alert.closeSpecific(uuid, [seconds])
--- Function
--- Closes the alert with the specified identifier
---
--- Parameters:
---  * uuid    - the identifier of the alert to close
---  * seconds - Optional number specifying the fade out duration. Defaults to `fadeOutDuration` value currently defined in the [hs.alert.defaultStyle](#defaultStyle)
---
--- Returns:
---  * None
---
--- Notes:
---  * Use this function to close an alert which is indefinite or close an alert with a long duration early.
module.closeSpecific = function(UUID, duration)
    duration = duration and math.max(duration, 0.0) or module.defaultStyle.fadeOutDuration
    purgeAlert(UUID, duration)
end

return setmetatable(module, { __call = function(_, ...) return module.show(...) end })
