local _ENV = mkmodule('plugins.spectate')

local argparse = require('argparse')
local dlg = require('gui.dialogs')
local gui = require('gui')
local json = require('json')
local overlay = require('plugins.overlay')
local utils = require('utils')
local widgets = require('gui.widgets')

-- settings starting with 'tooltip-' are not passed to the C++ plugin
local lua_only_settings_prefix = 'tooltip-'

-- how many lines the text following unit is allowed to be moved down to avoid overlapping
local max_banner_y_offset = 4

local function get_default_state()
    return {
        ['auto-unpause']=false,
        ['cinematic-action']=true,
        ['follow-seconds']=10,
        ['include-animals']=false,
        ['include-hostiles']=false,
        ['include-visitors']=false,
        ['include-wildlife']=false,
        ['prefer-conflict']=true,
        ['prefer-new-arrivals']=true,
        ['prefer-nicknamed']=true,
        ['tooltip-follow']=true,
        ['tooltip-follow-blink-milliseconds']=3000,
        ['tooltip-follow-hold-to-show']='none', -- one of none, ctrl, alt, or shift
        ['tooltip-follow-job']=true,
        ['tooltip-follow-activity']=true,
        ['tooltip-follow-job-shortenings'] = {
            ["Store item in stockpile"] = "Store item",
        },
        ['tooltip-follow-name']=false,
        ['tooltip-follow-stress']=true,
        ['tooltip-follow-stress-levels']={
            ["0"] = true, -- Miserable
            ["1"] = true,
            ["2"] = false,
            ["3"] = false,
            ["4"] = false,
            ["5"] = true,
            ["6"] = true, -- Ecstatic
        },
        ['tooltip-hover']=true,
        ['tooltip-hover-job']=true,
        ['tooltip-hover-activity']=true,
        ['tooltip-hover-name']=true,
        ['tooltip-hover-stress']=true,
        ['tooltip-hover-stress-levels']={
            ["0"] = true, -- Miserable
            ["1"] = true,
            ["2"] = false,
            ["3"] = false,
            ["4"] = false,
            ["5"] = true,
            ["6"] = true, -- Ecstatic
        },
        ['tooltip-stress-levels']={
            -- keep in mind, the text will look differently with game's font
            -- colors are same as in ASCII mode, but for then middle (3), which is GREY instead of WHITE
            ["0"] = {text = "=C", pen = COLOR_RED,        name = "Miserable"},
            ["1"] = {text = ":C", pen = COLOR_LIGHTRED,   name = "Unhappy"},
            ["2"] = {text = ":(", pen = COLOR_YELLOW,     name = "Displeased"},
            ["3"] = {text = ":]", pen = COLOR_GREY,       name = "Content"},
            ["4"] = {text = ":)", pen = COLOR_GREEN,      name = "Pleased"},
            ["5"] = {text = ":D", pen = COLOR_LIGHTGREEN, name = "Happy"},
            ["6"] = {text = "=D", pen = COLOR_LIGHTCYAN,  name = "Ecstatic"},
        }
    }
end

local function load_state()
    local state = get_default_state()
    local config_file = json.open('dfhack-config/spectate.json')
    for key in pairs(config_file.data) do
        if state[key] == nil then
            config_file.data[key] = nil
        end
    end
    utils.assign(state, config_file.data)
    config_file.data = state
    return config_file.data,
           function() config_file:write() end
end

local config, save_state = load_state()

-- called by gui/spectate
function get_config_elem(name, key)
    local elem = config[name]
    if elem == nil then return end
    if type(elem) == 'table' then
        return elem[key]
    end
    return elem
end

function refresh_cpp_config()
    for name,value in pairs(config) do
        if not name:startswith(lua_only_settings_prefix) then
            if type(value) == 'boolean' then
                value = value and 1 or 0
            end
            spectate_setSetting(name, value)
        end
    end
end

function show_squads_warning()
    local message = {
        'Cannot start spectate mode while the squads panel is open. Spectate',
        'automatically disengages when you open the squads panel.',
        '',
        'Please close the squads panel before enabling spectate mode.',
    }
    dlg.showMessage("Spectate", table.concat(message, '\n'))
end

-----------------------------
-- commandline interface

local function pairsByKeys(t, f)
    local a = {}
    for n in pairs(t) do table.insert(a, n) end
    table.sort(a, f)
    local i = 0      -- iterator variable
    local iter = function ()   -- iterator function
        i = i + 1
        if a[i] == nil then return nil
        else return a[i], t[a[i]]
        end
    end
    return iter
end

-- no recursion protection, but it shouldn't be needed for a config...
local function print_table(t, indent)
    indent = indent or ''
    for key, value in pairsByKeys(t) do
        if type(value) == 'table' then
            print(indent .. key .. ':')
            print_table(value, indent .. '  ')
        else
            print(indent .. key .. ': ' .. tostring(value))
        end
    end
end

local function print_status()
    print('spectate is:', isEnabled() and 'enabled' or 'disabled')
    print()
    print('settings:')

    print_table(config, '  ')
end

local function do_toggle()
    if isEnabled() then
        dfhack.run_command('disable', 'spectate')
    else
        dfhack.run_command('enable', 'spectate')
    end
end

local function set_setting(args)
    local n = #args
    if n == 0 then
        qerror('missing key')
    end

    local cfg = config
    local v
    for i = 1, n do
        v = cfg[args[i]]
        if v == nil then
            -- probably an unknown option, but we may allow adding new keys
            break
        elseif type(v) == 'table' then
            if i == n then
                -- arrived at the very last argument, but have a table
                qerror('missing value for ' .. table.concat(args, '/', 1, i))
            end
            cfg = v
        else
            -- arrived at something that's not a table
            if i == n-1 then
                -- if there is exactly 1 argument left, we're good
                break
            elseif i == n then
                qerror('missing value for ' .. table.concat(args, '/', 1, i))
            else -- i < n-1 then
                qerror('too many arguments for ' .. table.concat(args, '/', 1, i))
            end
        end
    end
    if v == nil then
        if n == 3 and args[1] == 'tooltip-follow-job-shortenings' then
            -- user should be able to add new shortenings, but not other things
        else
            qerror('unknown option: ' .. table.concat(args, '/', 1, i))
        end
    end

    local path = table.concat(args, '/', 1, n-1)
    local key = args[n-1]
    local value = args[n]
    local entry_type = type(cfg[key])
    if entry_type == 'table' then
        -- here just in case, is already checked in the loop above
        qerror('missing value for ' .. path)
    elseif entry_type == 'boolean' then
        if value == 'toggle' then
            value = not cfg[key]
        else
            value = argparse.boolean(value, path)
        end
    elseif entry_type == 'number' then
        if path == 'follow-seconds' then
            value = argparse.positiveInt(value, path)
        else
            value = argparse.nonnegativeInt(value, path)
        end
    end

    cfg[key] = value

    if n == 2 and not key:startswith(lua_only_settings_prefix) then
        if type(value) == 'boolean' then
            value = value and 1 or 0
        end
        spectate_setSetting(key, value)
    end

    save_state()
end

local function set_overlay(value)
    value = argparse.boolean(value, name)
    dfhack.run_command('overlay', value and 'enable' or 'disable', 'spectate.tooltip')
end

function parse_commandline(args)
    local command = table.remove(args, 1)
    if not command or command == 'status' then
        print_status()
    elseif command == 'toggle' then
        if #args == 0 then
            do_toggle()
        else
            args[#args+1] = 'toggle'
            set_setting(args)
        end
    elseif command == 'set' then
        set_setting(args)
    elseif command == 'overlay' then
        if #args == 0 then qerror('missing option') end
        set_overlay(args[1])
    else
        return false
    end

    return true
end

-----------------------------
-- info functions

local function GetUnitStress(unit, stress_levels)
    local stressCat = dfhack.units.getStressCategory(unit)
    if stressCat > 6 then stressCat = 6 end
    stressCat = tostring(stressCat)
    if not stress_levels[stressCat] then return end

    local level_cfg = config['tooltip-stress-levels'][stressCat]
    return {text=level_cfg.text, pen=level_cfg.pen}
end

local function GetUnitName(unit)
    return dfhack.units.getReadableName(unit)
end

local function GetUnitJob(unit)
    local job = unit.job.current_job
    return job and dfhack.job.getName(job)
end

-- there is no equivalent of `dfhack.job.GetName` yet
-- this function is by no means a replacement, it's
-- here to show something at least
local activityNames = {
    [df.activity_entry_type.TrainingSession] = "Training Session",
    [df.activity_entry_type.IndividualSkillDrill] = "Individual Skill Drill",
    [df.activity_entry_type.FillServiceOrder] = "Fill Service Order",
    [df.activity_entry_type.StoreObject] = "Store Object",
    -- other types are single words
}
local function activity_GetName(activity)
    local t = activity.type
    local n = df.activity_entry_type[t]
    n = activityNames[n] or n
    return {text = n, pen = COLOR_LIGHTGREEN}
end

local function GetUnitActivity(unit)
    local activity = dfhack.units.getMainSocialActivity(unit)
    return activity and activity_GetName(activity)
end

local function GetRelevantSettings(key)
    return config['tooltip-' .. key .. '-name'],
           config['tooltip-' .. key .. '-job'],
           config['tooltip-' .. key .. '-activity'],
           config['tooltip-' .. key .. '-stress'],
           config['tooltip-' .. key .. '-stress-levels'],
           config['tooltip-' .. key .. '-job-shortenings']
end

local function GetUnitInfoText(unit, settings_group_name)
    local show_name, show_job, show_activity, show_stress, stress_levels, job_shortenings = GetRelevantSettings(settings_group_name)

    local stress = show_stress and GetUnitStress(unit, stress_levels) or nil
    local name = show_name and GetUnitName(unit) or nil
    local job = show_job and GetUnitJob(unit) or nil
    if job_shortenings then job = job_shortenings[job] or job end
    local activity = show_activity and GetUnitActivity(unit) or nil

    local job_or_activity = job or activity

    local txt = {}
    if stress then
        txt[#txt+1] = stress
        if name or job_or_activity then txt[#txt+1] = ' ' end
    end
    if name then
        txt[#txt+1] = name
    end
    if job_or_activity then
        if name then txt[#txt+1] = ": " end
        txt[#txt+1] = job_or_activity
    end

    return txt
end

local function unit_filter(unit)
    return not dfhack.units.isHidden(unit)
end

local function GetHoverText(pos)
    if not pos then return end

    local txt = {}
    local units = dfhack.units.getUnitsInBox(pos, pos, unit_filter) or {}

    for _,unit in ipairs(units) do
        local info = GetUnitInfoText(unit, 'hover')
        if not next(info) then goto continue end

        for _,t in ipairs(info) do
            txt[#txt+1] = t
        end
        txt[#txt+1] = NEWLINE

        ::continue::
    end

    return txt
end

-----------------------------
-- TooltipOverlay

TooltipOverlay = defclass(TooltipOverlay, overlay.OverlayWidget)
TooltipOverlay.ATTRS{
    desc='Adds info tooltips that follow units or appear when you hover the mouse.',
    default_pos={x=1,y=1},
    fullscreen=true,
    viewscreens='dwarfmode/Default',
}

function TooltipOverlay:init()
    self:addviews{MouseTooltip{view_id = 'tooltip'}}
end

function TooltipOverlay:preUpdateLayout(parent_rect)
    -- this is required, otherwise there is no room to draw child widgets in
    self.frame.w = parent_rect.width
    self.frame.h = parent_rect.height
end

function TooltipOverlay:render(dc)
    self:render_unit_banners(dc)
    TooltipOverlay.super.render(self, dc)
end

-- map coordinates -> interface layer coordinates
local function GetScreenCoordinates(map_coord)
    -- -> map viewport offset
    local vp = df.global.world.viewport
    local vp_Coord = vp.corner
    local map_offset_by_vp = {
        x = map_coord.x - vp_Coord.x,
        y = map_coord.y - vp_Coord.y,
        z = map_coord.z - vp_Coord.z,
    }

    if not dfhack.screen.inGraphicsMode() then
        return map_offset_by_vp
    else
        -- -> pixel offset
        local gps = df.global.gps
        local map_tile_pixels = gps.viewport_zoom_factor // 4;
        local screen_coord_px = {
            x = map_tile_pixels * map_offset_by_vp.x,
            y = map_tile_pixels * map_offset_by_vp.y,
        }
        -- -> interface layer coordinates
        local screen_coord_text = {
            x = math.ceil( screen_coord_px.x / gps.tile_pixel_x ),
            y = math.ceil( screen_coord_px.y / gps.tile_pixel_y ),
        }

        return screen_coord_text
    end
end

local function GetString(tokens)
    local sb = {}
    for _, tok in ipairs(tokens) do
        if type(tok) == "string" then
            sb[#sb+1] = tok
        else -- must be a table token
            sb[#sb+1] = tok.text
        end
    end
    if not next(sb) then return nil end
    return table.concat(sb)
end

function TooltipOverlay:render_unit_banners(dc)
    if not config['tooltip-follow'] then return end

    local hold_to_show = config['tooltip-follow-hold-to-show']
    if hold_to_show and hold_to_show ~= 'none' then
        if not dfhack.internal.getModifiers()[hold_to_show] then
            return
        end
    else
        local blink_duration = config['tooltip-follow-blink-milliseconds']
        if blink_duration > 0 and not gui.blink_visible(blink_duration) then
            return
        end

        if not dfhack.screen.inGraphicsMode() and not gui.blink_visible(500) then
            return
        end
    end

    local vp = df.global.world.viewport
    local topleft = vp.corner
    local width = vp.max_x
    local height = vp.max_y
    local bottomright = {x = topleft.x + width, y = topleft.y + height, z = topleft.z}

    local units = dfhack.units.getUnitsInBox(topleft, bottomright, unit_filter)
    if not units or #units == 0 then return end

    local oneTileOffset = GetScreenCoordinates({x = topleft.x + 1, y = topleft.y + 1, z = topleft.z + 0})
    local pen = COLOR_WHITE

    local _, screenHeight = dfhack.screen:getWindowSize()

    local used_tiles = {}
    -- reverse order yields better offsets for overlapping texts
    for i = #units, 1, -1 do
        local unit = units[i]

        local posX, posY, posZ = dfhack.units.getPosition(unit)
        if not posX then goto continue end
        local pos = xyz2pos(posX, posY, posZ)

        local info = GetUnitInfoText(unit, 'follow')
        if not info or not next(info) then goto continue end

        local str = GetString(info)
        if not str then goto continue end

        local scrPos = GetScreenCoordinates(pos)
        local y = scrPos.y - 1 -- subtract 1 to move the text over the heads
        local x = scrPos.x + oneTileOffset.x - 1 -- subtract 1 to move the text inside the map tile

        -- do not write anything in the top rows, where DF's interface is.
        -- todo: use precise rectangles
        if y < 4 then goto continue end

        -- to resolve overlaps, we'll mark every coordinate we write anything in,
        -- and then check if the new tooltip will overwrite any used coordinate.
        -- if it will, try the next row, to a maximum offset of 4.
        local row
        local dy = 0
        -- todo: search for the "best" offset instead, f.e. max `usedAt` value, with `-1` the best
        local usedAt = -1
        for yOffset = 0, max_banner_y_offset do
            dy = yOffset

            row = used_tiles[y + dy]
            if not row then
                row = {}
                used_tiles[y + dy] = row
            end

            usedAt = -1
            for j = 0, #str - 1 do
                if row[x + j] then
                    usedAt = j
                    break
                end
            end

            if usedAt == -1 then break end
        end -- for dy
        -- if other text starts at the same position, or even 2 to the right,
        -- we can't place any useful information, and will ignore it instead.
        if 0 <= usedAt and usedAt <= 2 then goto continue end

        -- do not write anything over DF's interface
        -- todo: use precise rectangles
        if y + dy > screenHeight - 4 then goto continue end

        dc:seek(x, y + dy)
        local ix = 0
        for _, tok in ipairs(info) do
            local s
            if type(tok) == "string" then
                dc:pen(pen)
                s = tok
            else
                dc:pen(tok.pen)
                s = tok.text
            end

            -- in case there isn't enough space, cut the text off
            local len = #s
            if usedAt > 0 and ix + len + 1 >= usedAt then
                -- last position we can write is `usedAt - len - ix - 1`
                -- we want to replace it with an `_`, so we need another `- 1`
                s = s:sub(1, usedAt - len - ix - 1 - 1) .. '_'

                dc:string(s)
                break -- nothing more will fit
            else
                dc:string(s)
            end

            ix = ix + len
        end

        -- mark coordinates as used
        for j = 0, #str - 1 do
            row[x + j] = true
        end

        ::continue::
    end
end

-- MouseTooltip is an almost copy&paste of the DimensionsTooltip
MouseTooltip = defclass(MouseTooltip, widgets.ResizingPanel)

MouseTooltip.ATTRS{
    frame_style=gui.FRAME_THIN,
    frame_background=gui.CLEAR_PEN,
    no_force_pause_badge=true,
    auto_width=true,
    display_offset={x=3, y=3},
}

function MouseTooltip:init()
    ensure_key(self, 'frame').w = 17
    self.frame.h = 4

    self.label = widgets.Label{
        frame={t=0},
        auto_width=true,
    }

    self:addviews{
        widgets.Panel{
            -- set minimum size for tooltip frame so the DFHack frame badge fits
            frame={t=0, l=0, w=7, h=2},
        },
        self.label,
    }
end

function MouseTooltip:render(dc)
    if not config['tooltip-hover'] then return end

    local x, y = dfhack.screen.getMousePos()
    if not x then return end

    local pos = dfhack.gui.getMousePos()
    local text = GetHoverText(pos)
    if not text or not next(text) then return end
    self.label:setText(text)

    local sw, sh = dfhack.screen.getWindowSize()
    local frame_width = math.max(9, self.label:getTextWidth() + 2)
    self.frame.l = math.min(x + self.display_offset.x, sw - frame_width)
    self.frame.t = math.min(y + self.display_offset.y, sh - self.frame.h)
    self:updateLayout()
    MouseTooltip.super.render(self, dc)
end

-----------------------------
-- FollowPanelOverlay

local plotinfo = df.global.plotinfo
local mi = df.global.game.main_interface

local function follow_panel_is_visible()
    return plotinfo.follow_unit > -1 and
        mi.current_hover == -1 and
        not mi.hover_instructions_on and
        not mi.current_hover_alert
end

FollowPanelOverlay = defclass(FollowPanelOverlay, overlay.OverlayWidget)
FollowPanelOverlay.ATTRS{
    desc='Adds spectate widgets to the vanilla follow panel.',
    default_pos={x=6,y=-5},
    viewscreens='dwarfmode/Default',
    default_enabled=true,
    frame={w=29, h=1},
    visible=follow_panel_is_visible,
}

function FollowPanelOverlay:init()
    self:addviews{
        widgets.Label{
            frame={l=0, t=0, w=3},
            text=(' %s '):format(string.char(27)),
            on_click=spectate_followPrev,
        },
        widgets.Label{
            frame={l=5, t=0, w=3},
            text=(' %s '):format(string.char(26)),
            on_click=spectate_followNext,
        },
        widgets.Label{
            frame={l=10, t=0, w=14},
            text={
                ' spectate:',
                {text=function() return isEnabled() and ' on ' or 'off ' end,
                 pen=function() return isEnabled() and COLOR_GREEN or COLOR_LIGHTRED end},
            },
            on_click=function() dfhack.run_command(isEnabled() and 'disable' or 'enable', 'spectate') end,
        },
        widgets.ConfigureButton{
            frame={l=26, t=0},
            on_click=function() dfhack.run_script('gui/spectate') end,
        }
    }
end

function FollowPanelOverlay:onInput(keys)
    if keys.KEYBOARD_CURSOR_LEFT then
        spectate_followPrev()
        return true
    elseif keys.KEYBOARD_CURSOR_RIGHT then
        spectate_followNext()
        return true
    end
    return FollowPanelOverlay.super.onInput(self, keys)
end

OVERLAY_WIDGETS = {
    followpanel=FollowPanelOverlay,
    tooltip=TooltipOverlay,
}

return _ENV
