local _ENV = mkmodule('plugins.sort.info')

local gui = require('gui')
local overlay = require('plugins.overlay')
local widgets = require('gui.widgets')
local utils = require('utils')

local info = df.global.game.main_interface.info
local creatures = info.creatures
local justice = info.justice
local objects = info.artifacts
local tasks = info.jobs
local work_details = info.labor.work_details

local state = {}

-- these sort functions attempt to match the vanilla info panel sort behavior, which
-- is not quite the same as the rest of DFHack. For example, in other DFHack sorts,
-- we'd always sort by name descending as a secondary sort. To match vanilla sorting,
-- if the primary sort is ascending, the secondary name sort will also be ascending.
--
-- also note that vanilla sorts are not stable, so there might still be some jitter
-- if the player clicks one of the vanilla sort widgets after searching
local function sort_by_name_desc(a, b)
    return a.sort_name < b.sort_name
end

local function sort_by_name_asc(a, b)
    return a.sort_name > b.sort_name
end

local function sort_by_prof_desc(a, b)
    if a.profession_list_order1 == b.profession_list_order1 then
        return sort_by_name_desc(a, b)
    end
    return a.profession_list_order1 < b.profession_list_order1
end

local function sort_by_prof_asc(a, b)
    if a.profession_list_order1 == b.profession_list_order1 then
        return sort_by_name_asc(a, b)
    end
    return a.profession_list_order1 > b.profession_list_order1
end

local function sort_by_job_name_desc(a, b)
    if a.job_sort_name == b.job_sort_name then
        return sort_by_name_desc(a, b)
    end
    return a.job_sort_name < b.job_sort_name
end

local function sort_by_job_name_asc(a, b)
    if a.job_sort_name == b.job_sort_name then
        -- use descending tertiary sort for visual stability
        return sort_by_name_desc(a, b)
    end
    return a.job_sort_name > b.job_sort_name
end

local function sort_by_job_desc(a, b)
    if not not a.jb == not not b.jb then
        return sort_by_job_name_desc(a, b)
    end
    return not not a.jb
end

local function sort_by_job_asc(a, b)
    if not not a.jb == not not b.jb then
        return sort_by_job_name_asc(a, b)
    end
    return not not b.jb
end

local function sort_by_stress_desc(a, b)
    if a.stress == b.stress then
        return sort_by_name_desc(a, b)
    end
    return a.stress > b.stress
end

local function sort_by_stress_asc(a, b)
    if a.stress == b.stress then
        return sort_by_name_asc(a, b)
    end
    return a.stress < b.stress
end

local function get_sort()
    if creatures.sorting_cit_job then
        return creatures.sorting_cit_job_is_ascending and sort_by_job_asc or sort_by_job_desc
    elseif creatures.sorting_cit_stress then
        return creatures.sorting_cit_stress_is_ascending and sort_by_stress_asc or sort_by_stress_desc
    elseif creatures.sorting_cit_nameprof_doing_prof then
        return creatures.sorting_cit_nameprof_is_ascending and sort_by_prof_asc or sort_by_prof_desc
    else
        return creatures.sorting_cit_nameprof_is_ascending and sort_by_name_asc or sort_by_name_desc
    end
end

local function copy_to_lua_table(vec)
    local tab = {}
    for k,v in ipairs(vec) do
        tab[k+1] = v
    end
    return tab
end

local function general_search(vec, get_search_key_fn, get_sort_fn, matches_filters_fn, data, filter, incremental)
    if not data.saved_original then
        data.saved_original = copy_to_lua_table(vec)
    elseif not incremental then
        vec:assign(data.saved_original)
    end
    if matches_filters_fn ~= DEFAULT_NIL or filter ~= '' then
        local search_tokens = filter:split()
        for idx = #vec-1,0,-1 do
            local search_key = get_search_key_fn(vec[idx])
            if (search_key and not utils.search_text(search_key, search_tokens)) or
                (matches_filters_fn ~= DEFAULT_NIL and not matches_filters_fn(vec[idx]))
            then
                vec:erase(idx)
            end
        end
    end
    data.saved_visible = copy_to_lua_table(vec)
    if get_sort_fn then
        table.sort(data.saved_visible, get_sort_fn())
        vec:assign(data.saved_visible)
    end
end

-- add dynamically allocated elements that were not visible at the time of
-- closure back to the vector so they can be cleaned up when it is next initialized
local function cri_unitst_cleanup(vec, data)
    if not data.saved_visible or not data.saved_original then return end
    for _,elem in ipairs(data.saved_original) do
        if not utils.linear_index(data.saved_visible, elem) then
            vec:insert('#', elem)
        end
    end
end

local function get_unit_search_key(unit)
    return ('%s %s %s'):format(
        dfhack.units.getReadableName(unit),  -- last name is in english
        dfhack.units.getProfessionName(unit),
        dfhack.TranslateName(unit.name, false, true))  -- get untranslated last name
end

local function make_cri_unitst_handlers(vec, sort_fn)
    return {
        search_fn=curry(general_search, vec,
            function(elem)
                return ('%s %s'):format(
                    elem.un and get_unit_search_key(elem.un) or '',
                    elem.job_sort_name)
            end,
            sort_fn),
        cleanup_fn=curry(cri_unitst_cleanup, vec),
    }
end

local function overall_training_search(matches_filters_fn, data, filter, incremental)
    general_search(creatures.atk_index, function(elem)
            local raw = df.creature_raw.find(elem)
            if not raw then return end
            return raw.name[1]
        end, nil, matches_filters_fn, data, filter, incremental)
end

local function assign_trainer_search(matches_filters_fn, data, filter, incremental)
    general_search(creatures.trainer, function(elem)
            if not elem then return end
            return ('%s %s'):format(dfhack.TranslateName(elem.name), dfhack.units.getProfessionName(elem))
        end, nil, matches_filters_fn, data, filter, incremental)
end

local function work_details_search(matches_filters_fn, data, filter, incremental)
    if work_details.selected_work_detail_index ~= data.selected then
        data.saved_original = nil
        data.selected = work_details.selected_work_detail_index
    end
    general_search(work_details.assignable_unit, get_unit_search_key,
        nil, matches_filters_fn, data, filter, incremental)
end

-- independent implementation of search algorithm since we need to
-- keep two vectors in sync
local function interrogating_search(matches_filters_fn, data, filter, incremental)
    local vec, flags_vec = justice.interrogation_list, justice.interrogation_list_flag
    if not data.saved_original then
        data.saved_original = copy_to_lua_table(vec)
        data.saved_flags = copy_to_lua_table(flags_vec)
        data.saved_idx_map = {}
        for idx, unit in ipairs(data.saved_original) do
            data.saved_idx_map[unit.id] = idx  -- 1-based idx
        end
    else  -- sync flag changes to saved vector
        for idx, unit in ipairs(vec) do  -- 0-based idx
            data.saved_flags[data.saved_idx_map[unit.id]] = flags_vec[idx]
        end
    end

    if not incremental then
        vec:assign(data.saved_original)
        flags_vec:assign(data.saved_flags)
    end

    if matches_filters_fn ~= DEFAULT_NIL or filter ~= '' then
        local search_tokens = filter:split()
        for idx = #vec-1,0,-1 do
            local search_key = get_unit_search_key(vec[idx])
            if (search_key and not utils.search_text(search_key, search_tokens)) or
                (matches_filters_fn ~= DEFAULT_NIL and not matches_filters_fn(vec[idx], idx))
            then
                vec:erase(idx)
                flags_vec:erase(idx)
            end
        end
    end
end

local function convicting_search(matches_filters_fn, data, filter, incremental)
    general_search(justice.conviction_list, get_unit_search_key,
        nil, matches_filters_fn, data, filter, incremental)
end

local HANDLERS = {
    CITIZEN=make_cri_unitst_handlers(creatures.cri_unit.CITIZEN, get_sort),
    PET=make_cri_unitst_handlers(creatures.cri_unit.PET, get_sort),
    OTHER=make_cri_unitst_handlers(creatures.cri_unit.OTHER, get_sort),
    DECEASED=make_cri_unitst_handlers(creatures.cri_unit.DECEASED, get_sort),
    PET_OT={search_fn=overall_training_search},
    PET_AT={search_fn=assign_trainer_search},
    JOBS=make_cri_unitst_handlers(tasks.cri_job),
    WORK_DETAILS={search_fn=work_details_search},
    INTERROGATING={search_fn=interrogating_search},
    CONVICTING={search_fn=convicting_search},
}
for idx,name in ipairs(df.artifacts_mode_type) do
    if idx < 0 then goto continue end
    HANDLERS[name] = {
        search_fn=curry(general_search, objects.list[idx],
            function(elem)
                return ('%s %s'):format(dfhack.TranslateName(elem.name), dfhack.TranslateName(elem.name, true))
            end, nil)
    }
    ::continue::
end

local function get_key()
    if info.current_mode == df.info_interface_mode_type.JUSTICE then
        if justice.interrogating then
            return 'INTERROGATING'
        elseif justice.convicting then
            return 'CONVICTING'
        end
    elseif info.current_mode == df.info_interface_mode_type.CREATURES then
        if creatures.current_mode == df.unit_list_mode_type.PET then
            if creatures.showing_overall_training then
                return 'PET_OT'
            elseif creatures.adding_trainer then
                return 'PET_AT'
            end
        end
        return df.unit_list_mode_type[creatures.current_mode]
    elseif info.current_mode == df.info_interface_mode_type.JOBS then
        return 'JOBS'
    elseif info.current_mode == df.info_interface_mode_type.ARTIFACTS then
        return df.artifacts_mode_type[objects.mode]
    elseif info.current_mode == df.info_interface_mode_type.LABOR then
        if info.labor.mode == df.labor_mode_type.WORK_DETAILS then
            return 'WORK_DETAILS'
        end
    end
end

local function check_context(self, key_ctx)
    local key = get_key()
    if state[key_ctx] ~= key then
        state[key_ctx] = key
        local prev_text = key and ensure_key(state, key).prev_text or ''
        self.subviews.search:setText(prev_text)
    end
end

local function do_search(matches_filters_fn, text, force_full_search)
    if not force_full_search and not next(state) and text == '' then return end
    -- the EditField state is guaranteed to be consistent with the current
    -- context since when clicking to switch tabs, onRenderBody is always called
    -- before this text_input callback, even if a key is pressed before the next
    -- graphical frame would otherwise be printed. if this ever becomes untrue,
    -- then we can add an on_char handler to the EditField that also calls
    -- check_context.
    local key = get_key()
    if not key then return end
    local prev_text = ensure_key(state, key).prev_text
    -- some screens reset their contents between context switches; regardless
    -- a switch back to the context should results in an incremental search
    local incremental = not force_full_search and prev_text and text:startswith(prev_text)
    HANDLERS[key].search_fn(matches_filters_fn, state[key], text, incremental)
    state[key].prev_text = text
end

local function on_update(self)
    if self.overlay_onupdate_max_freq_seconds == 0 and
        not dfhack.gui.matchFocusString('dwarfmode/Info', dfhack.gui.getDFViewscreen(true))
    then
        for k,v in pairs(state) do
            local cleanup_fn = safe_index(HANDLERS, k, 'cleanup_fn')
            if cleanup_fn then cleanup_fn(v) end
        end
        state = {}
        self.subviews.search:setText('')
        self.subviews.search:setFocus(false)
        self.overlay_onupdate_max_freq_seconds = 60
    end
end

local function on_input(self, clazz, keys)
    if keys._MOUSE_R and self.subviews.search.focus and self:get_handled_key() then
        self.subviews.search:setFocus(false)
        return true
    end
    return clazz.super.onInput(self, keys)
end

local function is_interrogate_or_convict()
    local key = get_key()
    return key == 'INTERROGATING' or key == 'CONVICTING'
end

-- ----------------------
-- InfoOverlay
--

InfoOverlay = defclass(InfoOverlay, overlay.OverlayWidget)
InfoOverlay.ATTRS{
    default_pos={x=64, y=8},
    default_enabled=true,
    viewscreens='dwarfmode/Info',
    hotspot=true,
    overlay_onupdate_max_freq_seconds=0,
    frame={w=40, h=4},
}

function InfoOverlay:init()
    self:addviews{
        widgets.BannerPanel{
            view_id='panel',
            frame={l=0, t=0, r=0, h=1},
            visible=self:callback('get_handled_key'),
            subviews={
                widgets.EditField{
                    view_id='search',
                    frame={l=1, t=0, r=1},
                    label_text="Search: ",
                    key='CUSTOM_ALT_S',
                    on_change=function(text) do_search(DEFAULT_NIL, text) end,
                },
            },
        },
    }
end

function InfoOverlay:overlay_onupdate()
    on_update(self)
end

function InfoOverlay:get_handled_key()
    return not is_interrogate_or_convict() and get_key() or nil
end

local function resize_overlay(self)
    local sw = dfhack.screen.getWindowSize()
    local overlay_width = math.min(40, sw-(self.frame_rect.x1 + 30))
    if overlay_width ~= self.frame.w then
        self.frame.w = overlay_width
        return true
    end
end

local function is_tabs_in_two_rows()
    return dfhack.screen.readTile(64, 6, false).ch == 0
end

local function get_panel_offsets()
    local tabs_in_two_rows = is_tabs_in_two_rows()
    local shift_right = info.current_mode == df.info_interface_mode_type.ARTIFACTS or
        info.current_mode == df.info_interface_mode_type.LABOR
    local l_offset = (not tabs_in_two_rows and shift_right) and 4 or 0
    local t_offset = 1
    if tabs_in_two_rows then
        t_offset = shift_right and 0 or 3
    end
    if info.current_mode == df.info_interface_mode_type.JOBS then
        t_offset = t_offset - 1
    end
    return l_offset, t_offset
end

function InfoOverlay:updateFrames()
    local ret = resize_overlay(self)
    local l, t = get_panel_offsets()
    local frame = self.subviews.panel.frame
    if (frame.l == l and frame.t == t) then return ret end
    frame.l, frame.t = l, t
    return true
end

function InfoOverlay:onRenderBody(dc)
    if next(state) then
        check_context(self, InfoOverlay)
    end
    if self:updateFrames() then
        self:updateLayout()
    end
    if self.refresh_search then
        self.refresh_search = nil
        do_search(DEFAULT_NIL, self.subviews.search.text)
    end
    self.overlay_onupdate_max_freq_seconds = 0
    InfoOverlay.super.onRenderBody(self, dc)
end

function InfoOverlay:onInput(keys)
    if keys._MOUSE_L and get_key() == 'WORK_DETAILS' then
        self.refresh_search = true
    end
    return on_input(self, InfoOverlay, keys)
end

-- ----------------------
-- InterrogationOverlay
--

InterrogationOverlay = defclass(InterrogationOverlay, overlay.OverlayWidget)
InterrogationOverlay.ATTRS{
    default_pos={x=47, y=10},
    default_enabled=true,
    viewscreens='dwarfmode/Info/JUSTICE',
    frame={w=27, h=9},
    hotspot=true,
    overlay_onupdate_max_freq_seconds=0,
}

function InterrogationOverlay:overlay_onupdate()
    on_update(self)
end

function InterrogationOverlay:get_handled_key()
    return is_interrogate_or_convict() and get_key() or nil
end

function InterrogationOverlay:init()
    self:addviews{
        widgets.Panel{
            view_id='panel',
            frame={l=0, t=4, h=5, r=0},
            frame_background=gui.CLEAR_PEN,
            frame_style=gui.FRAME_MEDIUM,
            visible=is_interrogate_or_convict,
            subviews={
                widgets.EditField{
                    view_id='search',
                    frame={l=0, t=0, r=0},
                    label_text="Search: ",
                    key='CUSTOM_ALT_S',
                    on_change=function(text) do_search(self:callback('matches_filters'), text) end,
                },
                widgets.ToggleHotkeyLabel{
                    view_id='include_interviewed',
                    frame={l=0, t=1, w=23},
                    key='CUSTOM_SHIFT_I',
                    label='Interviewed:',
                    options={
                        {label='Include', value=true, pen=COLOR_GREEN},
                        {label='Exclude', value=false, pen=COLOR_RED},
                    },
                    visible=function() return justice.interrogating end,
                    on_change=function()
                        do_search(self:callback('matches_filters'), self.subviews.search.text, true)
                    end,
                },
                widgets.CycleHotkeyLabel{
                    view_id='subset',
                    frame={l=0, t=2, w=28},
                    key='CUSTOM_SHIFT_F',
                    label='Show:',
                    options={
                        {label='All', value='all', pen=COLOR_GREEN},
                        {label='Risky visitors', value='risky', pen=COLOR_RED},
                        {label='Other visitors', value='visitors', pen=COLOR_LIGHTRED},
                        {label='Residents', value='residents', pen=COLOR_YELLOW},
                        {label='Citizens', value='citizens', pen=COLOR_CYAN},
                        {label='Animals', value='animals', pen=COLOR_BLUE},
                        {label='Deceased or missing', value='deceased', pen=COLOR_MAGENTA},
                        {label='Others', value='others', pen=COLOR_GRAY},
                    },
                    on_change=function()
                        do_search(self:callback('matches_filters'), self.subviews.search.text, true)
                    end,
                },
            },
        },
    }
end

local RISKY_PROFESSIONS = utils.invert{
    df.profession.THIEF,
    df.profession.MASTER_THIEF,
    df.profession.CRIMINAL,
}

local function is_risky(unit)
    if RISKY_PROFESSIONS[unit.profession] or RISKY_PROFESSIONS[unit.profession2] then
        return true
    end
    if dfhack.units.getReadableName(unit):endswith('necromancer') then return true end
    return not dfhack.units.isAlive(unit)  -- detect intelligent undead
end

function InterrogationOverlay:matches_filters(unit, idx)
    if justice.interrogating then
        local include_interviewed = self.subviews.include_interviewed:getOptionValue()
        if not include_interviewed and justice.interrogation_list_flag[idx] == 2 then
            return false
        end
    end
    local subset = self.subviews.subset:getOptionValue()
    if subset == 'all' then
        return true
    elseif dfhack.units.isDead(unit) or not dfhack.units.isActive(unit) then
        return subset == 'deceased'
    elseif dfhack.units.isVisiting(unit) then
        local risky = is_risky(unit)
        return (subset == 'risky' and risky) or (subset == 'visitors' and not risky)
    elseif dfhack.units.isAnimal(unit) then
        return subset == 'animals'
    elseif dfhack.units.isCitizen(unit) then
        return subset == 'citizens'
    elseif unit.flags2.roaming_wilderness_population_source then
        return subset == 'others'
    end
    return subset == 'residents'
end

function InterrogationOverlay:render(dc)
    local sw = dfhack.screen.getWindowSize()
    local info_panel_border = 31 -- from edges of panel to screen edges
    local info_panel_width = sw - info_panel_border
    local info_panel_center = info_panel_width // 2
    local panel_x_offset = (info_panel_center + 5) - self.frame_rect.x1
    local frame_w = math.min(panel_x_offset + 37, info_panel_width - 56)
    local panel_l = panel_x_offset
    local panel_t = is_tabs_in_two_rows() and 4 or 0

    if self.frame.w ~= frame_w or
        self.subviews.panel.frame.l ~= panel_l or
        self.subviews.panel.frame.t ~= panel_t
    then
        self.frame.w = frame_w
        self.subviews.panel.frame.l = panel_l
        self.subviews.panel.frame.t = panel_t
        self:updateLayout()
    end

    InterrogationOverlay.super.render(self, dc)
end

function InterrogationOverlay:onRenderBody(dc)
    if next(state) then
        check_context(self, InterrogationOverlay)
    else
        self.subviews.include_interviewed:setOption(true, false)
        self.subviews.subset:setOption('all')
    end
    self.overlay_onupdate_max_freq_seconds = 0
    InterrogationOverlay.super.onRenderBody(self, dc)
end

function InterrogationOverlay:onInput(keys)
    return on_input(self, InterrogationOverlay, keys)
end

return _ENV