-- A GUI front-end for the workflow plugin.
--[====[

gui/workflow
============
Bind to a key (the example config uses Alt-W), and activate with a job selected
in a workshop in :kbd:`q` mode.

.. image:: /docs/images/workflow.png

This script provides a simple interface to constraints managed by `workflow`.
When active, it displays a list of all constraints applicable to the
current job, and their current status.

A constraint specifies a certain range to be compared against either individual
*item* or whole *stack* count, an item type and optionally a material. When the
current count is below the lower bound of the range, the job is resumed; if it
is above or equal to the top bound, it will be suspended. Within the range, the
specific constraint has no effect on the job; others may still affect it.

Pressing :kbd:`i` switches the current constraint between counting stacks or items.
Pressing :kbd:`r` lets you input the range directly;
:kbd:`e`, :kbd:`r`, :kbd:`d`, :kbd:`f` adjust the
bounds by 5, 10, or 20 depending on the direction and the :kbd:`i` setting (counting
items and expanding the range each gives a 2x bonus).

Pressing :kbd:`a` produces a list of possible outputs of this job as guessed by
workflow, and lets you create a new constraint by choosing one as template. If you
don't see the choice you want in the list, it likely means you have to adjust
the job material first using `job` ``item-material`` or `gui/workshop-job`,
as described in the `workflow` documentation. In this manner, this feature
can be used for troubleshooting jobs that don't match the right constraints.

.. image:: /docs/images/workflow-new1.png

If you select one of the outputs with :kbd:`Enter`, the matching constraint is simply
added to the list. If you use :kbd:`Shift`:kbd:`Enter`, the interface proceeds to the
next dialog, which allows you to edit the suggested constraint parameters to
suit your need, and set the item count range.

.. image:: /docs/images/workflow-new2.png

Pressing :kbd:`s` (or, with the example config, Alt-W in the :kbd:`z` stocks screen)
opens the overall status screen:

.. image:: /docs/images/workflow-status.png

This screen shows all currently existing workflow constraints, and allows
monitoring and/or changing them from one screen. The constraint list can
be filtered by typing text in the field below.

The color of the stock level number indicates how "healthy" the stock level
is, based on current count and trend. Bright green is very good, green is good,
red is bad, bright red is very bad.

The limit number is also color-coded. Red means that there are currently no
workshops producing that item (i.e. no jobs). If it's yellow, that means the
production has been delayed, possibly due to lack of input materials.

The chart on the right is a plot of the last 14 days (28 half day plots) worth
of stock history for the selected item, with the rightmost point representing
the current stock value. The bright green dashed line is the target
limit (maximum) and the dark green line is that minus the gap (minimum).

]====]
local utils = require 'utils'
local gui = require 'gui'
local guidm = require 'gui.dwarfmode'
local guimat = require 'gui.materials'
local widgets = require 'gui.widgets'
local dlg = require 'gui.dialogs'

local workflow = require 'plugins.workflow'

function check_enabled(cb)
    if workflow.isEnabled() then
        return cb()
    else
        dlg.showYesNoPrompt(
            'Enable Plugin',
            { 'The workflow plugin is not enabled currently.', NEWLINE, NEWLINE,
              'Press ', { key = 'MENU_CONFIRM' }, ' to enable it.' },
            COLOR_YELLOW,
            function()
                workflow.setEnabled(true)
                return cb()
            end
        )
    end
end

function check_repeat(job, cb)
    if job.flags['repeat'] then
        return cb()
    else
        dlg.showYesNoPrompt(
            'Not Repeat Job',
            { 'Workflow only tracks repeating jobs.', NEWLINE, NEWLINE,
              'Press ', { key = 'MENU_CONFIRM' }, ' to make this one repeat.' },
            COLOR_YELLOW,
            function()
                job.flags['repeat'] = true
                return cb()
            end
        )
    end
end

function describe_item_type(iobj)
    local itemline = 'any item'
    if iobj.is_craft then
        itemline = 'any craft'
    elseif iobj.item_type >= 0 then
        itemline = df.item_type.attrs[iobj.item_type].caption or iobj.item_type
        local subtype = iobj.item_subtype or -1
        local def = dfhack.items.getSubtypeDef(iobj.item_type, subtype)
        local count = dfhack.items.getSubtypeCount(iobj.item_type, subtype)
        if def then
            itemline = def.name
        elseif count >= 0 then
            itemline = 'any '..itemline
        end
    end
    return itemline
end

function is_caste_mat(iobj)
    return dfhack.items.isCasteMaterial(iobj.item_type or -1)
end

function describe_material(iobj)
    local matflags = utils.list_bitfield_flags(iobj.mat_mask)
    if #matflags > 0 then
        matflags = 'any '..table.concat(matflags, '/')
    else
        matflags = nil
    end

    if is_caste_mat(iobj) then
        return 'no material'
    elseif (iobj.mat_type or -1) >= 0 then
        local info = dfhack.matinfo.decode(iobj.mat_type, iobj.mat_index)
        local matline
        if info then
            matline = info:toString()
        else
            matline = iobj.mat_type..':'..iobj.mat_index
        end
        return matline, matflags
    else
        return matflags or 'any material'
    end
end

function current_stock(iobj)
    if iobj.goal_by_count then
        return iobj.cur_count
    else
        return iobj.cur_amount
    end
end

function if_by_count(iobj,bc,ba)
    if iobj.goal_by_count then
        return bc
    else
        return ba
    end
end

function compute_trend(history,field)
    local count = #history
    if count == 0 then
        return 0
    end
    local sumX,sumY,sumXY,sumXX = 0,0,0,0
    for i,v in ipairs(history) do
        sumX = sumX + i
        sumY = sumY + v[field]
        sumXY = sumXY + i*v[field]
        sumXX = sumXX + i*i
    end
    return (count * sumXY - sumX * sumY) / (count * sumXX - sumX * sumX)
end

------------------------
-- RANGE EDITOR GROUP --
------------------------

local null_cons = { goal_value = 0, goal_gap = 0, goal_by_count = false }

RangeEditor = defclass(RangeEditor, widgets.Label)

RangeEditor.ATTRS {
    get_cb = DEFAULT_NIL,
    save_cb = DEFAULT_NIL,
    keys = {
        count = 'CUSTOM_SHIFT_I',
        modify = 'CUSTOM_SHIFT_R',
        min_dec = 'BUILDING_TRIGGER_MIN_SIZE_DOWN',
        min_inc = 'BUILDING_TRIGGER_MIN_SIZE_UP',
        max_dec = 'BUILDING_TRIGGER_MAX_SIZE_DOWN',
        max_inc = 'BUILDING_TRIGGER_MAX_SIZE_UP',
    }
}

function RangeEditor:init(args)
    self:setText{
        { key = self.keys.count,
            text = function()
            local cons = self.get_cb() or null_cons
            if cons.goal_by_count then
                return ': Count stacks  '
            else
                return ': Count items   '
            end
            end,
            on_activate = self:callback('onChangeUnit') },
        { key = self.keys.modify, text = ': Range',
            on_activate = self:callback('onEditRange') },
            NEWLINE, '  ',
        { key = self.keys.min_dec,
            on_activate = self:callback('onIncRange', 'goal_gap', 2) },
        { key = self.keys.min_inc,
            on_activate = self:callback('onIncRange', 'goal_gap', -1) },
        { text = function()
            local cons = self.get_cb() or null_cons
            return string.format(': Min %-4d ', cons.goal_value - cons.goal_gap)
            end },
        { key = self.keys.max_dec,
            on_activate = self:callback('onIncRange', 'goal_value', -1) },
        { key = self.keys.max_inc,
            on_activate = self:callback('onIncRange', 'goal_value', 2) },
        { text = function()
            local cons = self.get_cb() or null_cons
            return string.format(': Max %-4d', cons.goal_value)
            end },
    }
end

function RangeEditor:onChangeUnit()
    local cons = self.get_cb()
    cons.goal_by_count = not cons.goal_by_count
    self.save_cb(cons)
end

function RangeEditor:onEditRange()
    local cons = self.get_cb()
    dlg.showInputPrompt(
        'Input Range',
        'Enter the new constraint range:',
        COLOR_WHITE,
        (cons.goal_value-cons.goal_gap)..'-'..cons.goal_value,
        function(text)
            local maxv = tonumber(string.match(text, '^%s*(%d+)%s*$'))
            if maxv then
                cons.goal_value = maxv
                return self.save_cb(cons)
            end
            local minv,maxv = string.match(text, '^%s*(%d+)-(%d+)%s*$')
            minv = tonumber(minv)
            maxv = tonumber(maxv)
            if minv and maxv and minv ~= maxv then
                cons.goal_value = math.max(minv,maxv)
                cons.goal_gap = math.abs(maxv-minv)
                return self.save_cb(cons)
            end
            dlg.showMessage('Invalid Range', 'This range is invalid: '..text, COLOR_LIGHTRED)
        end
    )
end

function RangeEditor:onIncRange(field, delta)
    local cons = self.get_cb()
    if not cons.goal_by_count then
        delta = delta * 2
    end
    cons[field] = math.max(1, cons[field] + delta*5)
    self.save_cb(cons)
end

---------------------------
-- NEW CONSTRAINT DIALOG --
---------------------------

NewConstraint = defclass(NewConstraint, gui.FramedScreen)

NewConstraint.focus_path = 'workflow/new'

NewConstraint.ATTRS {
    frame_style = gui.GREY_LINE_FRAME,
    frame_title = 'New workflow constraint',
    frame_width = 39,
    frame_height = 20,
    frame_inset = 1,
    constraint = DEFAULT_NIL,
    on_submit = DEFAULT_NIL,
}

function NewConstraint:init(args)
    self.constraint = args.constraint or { item_type = -1 }
    rawset_default(self.constraint, { goal_value = 10, goal_gap = 5, goal_by_count = false })

    local matlist = {}
    local matsel = 1
    local matmask = self.constraint.mat_mask

    for i,v in ipairs(df.dfhack_material_category) do
        if v and v ~= 'wood2' then
            table.insert(matlist, { icon = self:callback('isMatSelected', v), text = v })
            if matmask and matmask[v] and matsel == 1 then
                matsel = #matlist
            end
        end
    end

    self:addviews{
        widgets.Label{
            frame = { l = 0, t = 0 },
            text = 'Items matching:'
        },
        widgets.Label{
            frame = { l = 1, t = 2, w = 26 },
            text = {
                'Type: ',
                { pen = function()
                    if self:isValid() then return COLOR_LIGHTCYAN else return COLOR_LIGHTRED end
                  end,
                  text = function()
                    if self:isValid() then
                        return describe_item_type(self.constraint)
                    else
                        return 'item not set'
                    end
                  end },
                NEWLINE, '  ',
                { key = 'CUSTOM_T', text = ': Select, ',
                  on_activate = self:callback('chooseType') },
                { key = 'CUSTOM_SHIFT_C', text = ': Crafts',
                  on_activate = self:callback('chooseCrafts') },
                NEWLINE, NEWLINE,
                'Material: ',
                { pen = COLOR_LIGHTCYAN,
                  text = function() return describe_material(self.constraint) end },
                NEWLINE, '  ',
                { key = 'CUSTOM_P', text = ': Specific',
                  on_activate = self:callback('chooseMaterial') },
                NEWLINE, NEWLINE,
                'Other:',
                NEWLINE, ' ',
                { key = 'D_MILITARY_SUPPLIES_WATER_DOWN',
                  on_activate = self:callback('incQuality', -1) },
                { key = 'D_MILITARY_SUPPLIES_WATER_UP', key_sep = ': ',
                  text = function()
                    return df.item_quality[self.constraint.min_quality or 0]..' quality'
                  end,
                  on_activate = self:callback('incQuality', 1) },
                NEWLINE, '  ',
                { key = 'CUSTOM_L', key_sep = ': ',
                  text = function()
                    if self.constraint.is_local then
                        return 'Locally made only'
                    else
                        return 'Include foreign'
                    end
                  end,
                  on_activate = self:callback('toggleLocal') },
            }
        },
        widgets.Label{
            frame = { l = 0, t = 14 },
            text = {
                'Desired range: ',
                { pen = COLOR_LIGHTCYAN,
                  text = function()
                    local cons = self.constraint
                    local goal = (cons.goal_value-cons.goal_gap)..'-'..cons.goal_value
                    if cons.goal_by_count then
                        return goal .. ' stacks'
                    else
                        return goal .. ' items'
                    end
                  end },
            }
        },
        RangeEditor{
            frame = { l = 1, t = 16 },
            get_cb = self:cb_getfield('constraint'),
            save_cb = self:callback('onRangeChange'),
        },
        widgets.Label{
            frame = { l = 30, t = 0 },
            text = 'Mat class'
        },
        widgets.List{
            view_id = 'matlist',
            frame = { l = 30, t = 2, w = 9, h = 18 },
            scroll_keys = widgets.STANDARDSCROLL,
            choices = matlist,
            selected = matsel,
            on_submit = self:callback('onToggleMatclass')
        },
        widgets.Label{
            frame = { l = 0, b = 0, w = 29 },
            text = {
                { key = 'LEAVESCREEN', text = ': Cancel, ',
                  on_activate = self:callback('dismiss') },
                { key = 'MENU_CONFIRM', key_sep = ': ',
                  enabled = self:callback('isValid'),
                  text = function()
                    if self.is_existing then return 'Update' else return 'Create new' end
                  end,
                  on_activate = function()
                    self:dismiss()
                    if self.on_submit then
                        self.on_submit(self.constraint)
                    end
                  end },
            }
        },
    }
end

function NewConstraint:postinit()
    self:onChange()
end

function NewConstraint:isValid()
    return self.constraint.item_type >= 0 or self.constraint.is_craft
end

function NewConstraint:onChange()
    local token = workflow.constraintToToken(self.constraint)
    local out

    if self:isValid() then
        out = workflow.findConstraint(token)
    end

    if out then
        self.constraint = out
        self.is_existing = true
    else
        self.constraint.token = token
        self.is_existing = false
    end
end

function NewConstraint:chooseType()
    guimat.ItemTypeDialog{
        prompt = 'Please select a new item type',
        hide_none = true,
        on_select = function(itype,isub)
            local cons = self.constraint
            cons.item_type = itype
            cons.item_subtype = isub
            cons.is_craft = nil
            self:onChange()
        end
    }:show()
end

function NewConstraint:chooseCrafts()
    local cons = self.constraint
    cons.item_type = -1
    cons.item_subtype = -1
    cons.is_craft = true
    self:onChange()
end

function NewConstraint:chooseMaterial()
    local cons = self.constraint
    guimat.MaterialDialog{
        prompt = 'Please select a new material',
        none_caption = 'any material',
        frame_width = 37,
        on_select = function(mat_type, mat_index)
            local cons = self.constraint
            cons.mat_type = mat_type
            cons.mat_index = mat_index
            cons.mat_mask = nil
            self:onChange()
        end
    }:show()
end

function NewConstraint:incQuality(diff)
    local cons = self.constraint
    local nq = (cons.min_quality or 0) + diff
    if nq < 0 then
        nq = df.item_quality.Masterful
    elseif nq > df.item_quality.Masterful then
        nq = 0
    end
    cons.min_quality = nq
    self:onChange()
end

function NewConstraint:toggleLocal()
    local cons = self.constraint
    cons.is_local = not cons.is_local
    self:onChange()
end

function NewConstraint:isMatSelected(token)
    if self.constraint.mat_mask and self.constraint.mat_mask[token] then
        return { ch = '\xfb', fg = COLOR_LIGHTGREEN }
    else
        return nil
    end
end

function NewConstraint:onToggleMatclass(idx,obj)
    local cons = self.constraint
    if cons.mat_mask and cons.mat_mask[obj.text] then
        cons.mat_mask[obj.text] = false
    else
        cons.mat_mask = cons.mat_mask or {}
        cons.mat_mask[obj.text] = true
        cons.mat_type = -1
        cons.mat_index = -1
    end
    self:onChange()
end

function NewConstraint:onRangeChange()
    local cons = self.constraint
    cons.goal_gap = math.max(1, math.min(cons.goal_gap, cons.goal_value-1))
end

------------------------------
-- CONSTRAINT HISTORY GRAPH --
------------------------------

HistoryGraph = defclass(HistoryGraph, widgets.Widget)

HistoryGraph.ATTRS {
    frame_inset = 1,
    history_pen = COLOR_CYAN,
}

function HistoryGraph:init(info)
end

function HistoryGraph:setData(history, bars)
    self.history = history or {}
    self.bars = bars or {}

    local maxval = 1
    for i,v in ipairs(self.history) do
        maxval = math.max(maxval, v)
    end
    for i,v in ipairs(self.bars) do
        maxval = math.max(maxval, v.value)
    end
    self.max_value = maxval
end

function HistoryGraph:onRenderFrame(dc,rect)
    dc:fill(rect.x1,rect.y1,rect.x1,rect.y2,{ch='\xb3', fg=COLOR_BROWN})
    dc:fill(rect.x1,rect.y2,rect.x2,rect.y2,{ch='\xc4', fg=COLOR_BROWN})
    dc:seek(rect.x1,rect.y1):char('\x1e', COLOR_BROWN)
    dc:seek(rect.x1,rect.y2):char('\xc5', COLOR_BROWN)
    dc:seek(rect.x2,rect.y2):char('\x10', COLOR_BROWN)
    dc:seek(rect.x1,rect.y2-1):char('0', COLOR_BROWN)
end

function HistoryGraph:onRenderBody(dc)
    local coeff = (dc.height-1)/self.max_value

    for i,v in ipairs(self.bars) do
        local y = dc.height-1-math.floor(0.5 + coeff*v.value)
        dc:fill(0,y,dc.width-1,y,v.pen or {ch='-', fg=COLOR_GREEN})
    end

    local xbase = dc.width-1-#self.history
    for i,v in ipairs(self.history) do
        local x = xbase + i
        local y = dc.height-1-math.floor(0.5 + coeff*v)
        dc:seek(x,y):char('*', self.history_pen)
    end
end

------------------------------
-- GLOBAL CONSTRAINT SCREEN --
------------------------------

ConstraintList = defclass(ConstraintList, gui.FramedScreen)

ConstraintList.focus_path = 'workflow/list'

ConstraintList.ATTRS {
    frame_title = 'Workflow Status',
    frame_inset = 0,
    frame_background = COLOR_BLACK,
    frame_style = gui.BOUNDARY_FRAME,
}

function ConstraintList:init(args)
    local fwidth_cb = self:cb_getfield('fwidth')

    self.fwidth = 20
    self.sort_by_severity = false

    self:addviews{
        widgets.Panel{
            frame = { l = 0, r = 31 },
            frame_inset = 1,
            on_layout = function(body)
                self.fwidth = body.width - (12+1+1+7+1+1+1+7)
            end,
            subviews = {
                widgets.Label{
                    frame = { l = 0, t = 0 },
                    text_pen = COLOR_CYAN,
                    text = {
                        { text = 'Item', width = 12 }, ' ',
                        { text = 'Material etc', width = fwidth_cb }, ' ',
                        { text = 'Stock / Limit' },
                    }
                },
                widgets.FilteredList{
                    view_id = 'list',
                    frame = { t = 2, b = 2 },
                    edit_below = true,
                    not_found_label = 'No matching constraints',
                    edit_pen = COLOR_LIGHTCYAN,
                    text_pen = { fg = COLOR_GREY, bg = COLOR_BLACK },
                    cursor_pen = { fg = COLOR_WHITE, bg = COLOR_GREEN },
                    on_select = self:callback('onSelectConstraint'),
                },
                widgets.Label{
                    frame = { b = 0, h = 1 },
                    text = {
                        { key = 'CUSTOM_SHIFT_A', text = ': Add',
                          on_activate = self:callback('onNewConstraint') }, ', ',
                        { key = 'CUSTOM_SHIFT_X', text = ': Delete',
                          on_activate = self:callback('onDeleteConstraint') }, ', ',
                        { key = 'CUSTOM_SHIFT_O', text = ': Severity Order',
                          on_activate = self:callback('onSwitchSort'),
                          pen = function()
                            if self.sort_by_severity then
                              return COLOR_LIGHTCYAN
                            else
                              return COLOR_WHITE
                            end
                          end },
                    }
                }
            }
        },
        widgets.Panel{
            frame = { w = 30, r = 0, h = 6, t = 0 },
            frame_inset = 1,
            subviews = {
                widgets.Label{
                    frame = { l = 0, t = 0 },
                    enabled = self:callback('isAnySelected'),
                    text = {
                        { text = function()
                            local cur = self:getCurConstraint()
                            if cur then
                                return string.format(
                                    'Currently %d (%d in use)',
                                    current_stock(cur),
                                    if_by_count(cur, cur.cur_in_use_count, cur.cur_in_use_amount)
                                )
                            else
                                return 'No constraint selected'
                            end
                          end }
                    }
                },
                RangeEditor{
                    frame = { l = 0, t = 2 },
                    enabled = self:callback('isAnySelected'),
                    get_cb = self:callback('getCurConstraint'),
                    save_cb = self:callback('saveConstraint'),
                    keys = {
                        count = 'CUSTOM_SHIFT_I',
                        modify = 'CUSTOM_SHIFT_R',
                        min_dec = 'SECONDSCROLL_PAGEUP',
                        min_inc = 'SECONDSCROLL_PAGEDOWN',
                        max_dec = 'SECONDSCROLL_UP',
                        max_inc = 'SECONDSCROLL_DOWN',
                    }
                },
            }
        },
        widgets.Widget{
            active = false,
            frame = { w = 1, r = 30 },
            frame_background = gui.BOUNDARY_FRAME.frame_pen,
        },
        widgets.Widget{
            active = false,
            frame = { w = 30, r = 0, h = 1, t = 6 },
            frame_background = gui.BOUNDARY_FRAME.frame_pen,
        },
        HistoryGraph{
            view_id = 'graph',
            frame = { w = 30, r = 0, t = 7, b = 0 },
        }
    }

    self:initListChoices(nil, args.select_token)
end

function stock_trend_color(cons)
    local stock = current_stock(cons)
    if stock >= cons.goal_value - cons.goal_gap then
        return COLOR_LIGHTGREEN, 0
    elseif stock <= cons.goal_gap then
        return COLOR_LIGHTRED, 4
    elseif stock >= cons.goal_value - 2*cons.goal_gap then
        return COLOR_GREEN, 1
    elseif stock <= 2*cons.goal_gap then
        return COLOR_RED, 3
    else
        local trend = if_by_count(cons, cons.trend_count, cons.trend_amount)
        if trend > 0.3 then
            return COLOR_GREEN, 1
        elseif trend < -0.3 then
            return COLOR_RED, 3
        else
            return COLOR_GREY, 2
        end
    end
end

function ConstraintList:initListChoices(clist, sel_token)
    clist = clist or workflow.listConstraints(nil, true)

    local fwidth_cb = self:cb_getfield('fwidth')
    local choices = {}

    for i,cons in ipairs(clist) do
        cons.trend_count = compute_trend(cons.history, 'cur_count')
        cons.trend_amount = compute_trend(cons.history, 'cur_amount')

        local itemstr = describe_item_type(cons)
        local matstr,matflagstr = describe_material(cons)
        if matflagstr then
            matstr = matflagstr .. ' ' .. matstr
        end

        if cons.min_quality > 0 or cons.is_local then
            local lst = {}
            if cons.is_local then
                table.insert(lst, 'local')
            end
            if cons.min_quality > 0 then
                table.insert(lst, string.lower(df.item_quality[cons.min_quality]))
            end
            matstr = matstr .. ' ('..table.concat(lst,',')..')'
        end

        local goal_color = COLOR_GREY
        if #cons.jobs == 0 then
            goal_color = COLOR_RED
        elseif cons.is_delayed then
            goal_color = COLOR_YELLOW
        end

        table.insert(choices, {
            text = {
                { text = itemstr, width = 12, pad_char = ' ' }, ' ',
                { text = matstr, width = fwidth_cb, pad_char = ' ' }, ' ',
                { text = curry(current_stock,cons), width = 7, rjustify = true,
                  pen = function() return { fg = stock_trend_color(cons) } end },
                { text = curry(if_by_count,cons,'S','I'), gap = 1,
                  pen = { fg = COLOR_GREY } },
                { text = function() return cons.goal_value end, gap = 1,
                  pen = { fg = goal_color } }
            },
            severity = select(2, stock_trend_color(cons)),
            search_key = itemstr .. ' | ' .. matstr,
            token = cons.token,
            obj = cons
        })
    end

    self:setChoices(choices, sel_token)
end

function ConstraintList:isAnySelected()
    return self.subviews.list:getSelected() ~= nil
end

function ConstraintList:getCurConstraint()
    local selidx,selobj = self.subviews.list:getSelected()
    if selobj then return selobj.obj end
end

function ConstraintList:onSwitchSort()
    self.sort_by_severity = not self.sort_by_severity
    self:setChoices(self.subviews.list:getChoices())
end

function ConstraintList:setChoices(choices, sel_token)
    if self.sort_by_severity then
        table.sort(choices, function(a,b)
            return a.severity > b.severity
                or (a.severity == b.severity and
                    current_stock(a.obj)/a.obj.goal_value < current_stock(b.obj)/b.obj.goal_value)
        end)
    else
        table.sort(choices, function(a,b) return a.search_key < b.search_key end)
    end

    local selidx = nil
    if sel_token then
        selidx = utils.linear_index(choices, sel_token, 'token')
    end

    local list = self.subviews.list
    local filter = list:getFilter()

    list:setChoices(choices, selidx)

    if filter ~= '' then
        list:setFilter(filter, selidx)

        if selidx and list:getSelected() ~= selidx then
            list:setFilter('', selidx)
        end
    end
end

function ConstraintList:onInput(keys)
    if keys.LEAVESCREEN then
        self:dismiss()
    else
        ConstraintList.super.onInput(self, keys)
    end
end

function ConstraintList:onNewConstraint()
    NewConstraint{
        on_submit = self:callback('saveConstraint')
    }:show()
end

function ConstraintList:saveConstraint(cons)
    local out = workflow.setConstraint(cons.token, cons.goal_by_count, cons.goal_value, cons.goal_gap)
    self:initListChoices(nil, out.token)
end

function ConstraintList:onDeleteConstraint()
    local cons = self:getCurConstraint()
    dlg.showYesNoPrompt(
        'Delete Constraint',
        'Really delete the current constraint?',
        COLOR_YELLOW,
        function()
            workflow.deleteConstraint(cons.token)
            self:initListChoices()
        end
    )
end

function ConstraintList:onSelectConstraint(idx,item)
    local history, bars

    if item then
        local cons = item.obj
        local vfield = if_by_count(cons, 'cur_count', 'cur_amount')

        bars = {
            { value = cons.goal_value - cons.goal_gap, pen = {ch='-', fg=COLOR_GREEN} },
            { value = cons.goal_value, pen = {ch='-', fg=COLOR_LIGHTGREEN} },
        }

        history = {}
        for i,v in ipairs(cons.history or {}) do
            table.insert(history, v[vfield])
        end

        table.insert(history, cons[vfield])
    end

    self.subviews.graph:setData(history, bars)
end

-------------------------------
-- WORKSHOP JOB INFO OVERLAY --
-------------------------------

JobConstraints = defclass(JobConstraints, guidm.MenuOverlay)

JobConstraints.focus_path = 'workflow/job'

JobConstraints.ATTRS {
    job = DEFAULT_NIL,
    frame_inset = 1,
    frame_background = COLOR_BLACK,
}

function JobConstraints:init(args)
    self.building = dfhack.job.getHolder(self.job)

    self:addviews{
        widgets.Label{
            frame = { l = 0, t = 0 },
            text = {
                'Workflow Constraints'
            }
        },
        widgets.List{
            view_id = 'list',
            frame = { t = 2, b = 6 },
            row_height = 4,
            scroll_keys = widgets.SECONDSCROLL,
        },
        RangeEditor{
            frame = { l = 0, b = 3 },
            enabled = self:callback('isAnySelected'),
            get_cb = self:callback('getCurConstraint'),
            save_cb = self:callback('saveConstraint'),
        },
        widgets.Label{
            frame = { l = 0, b = 0 },
            text = {
                { key = 'CUSTOM_SHIFT_A', text = ': Add limit, ',
                  on_activate = self:callback('onNewConstraint') },
                { key = 'CUSTOM_SHIFT_X', text = ': Delete',
                  enabled = self:callback('isAnySelected'),
                  on_activate = self:callback('onDeleteConstraint') },
                NEWLINE, NEWLINE,
                { key = 'LEAVESCREEN', text = ': Back',
                  on_activate = self:callback('dismiss') },
                '       ',
                { key = 'CUSTOM_SHIFT_S', text = ': Status',
                  on_activate = function()
                    local sel = self:getCurConstraint()
                    ConstraintList{ select_token = (sel or {}).token }:show()
                  end }
            }
        },
    }

    self:initListChoices(args.clist)
end

function JobConstraints:onGetSelectedBuilding()
    return self.building
end

function JobConstraints:onGetSelectedJob()
    return self.job
end

function JobConstraints:initListChoices(clist, sel_token)
    clist = clist or workflow.listConstraints(self.job)

    local choices = {}

    for i,cons in ipairs(clist) do
        local goal = (cons.goal_value-cons.goal_gap)..'-'..cons.goal_value
        local curval
        if cons.goal_by_count then
            goal = goal .. ' stacks'
            curval = cons.cur_count
        else
            goal = goal .. ' items'
            curval = cons.cur_amount
        end
        local order_pen = COLOR_GREY
        if cons.request == 'resume' then
            order_pen = COLOR_GREEN
        elseif cons.request == 'suspend' then
            order_pen = COLOR_BLUE
        end
        local itemstr = describe_item_type(cons)
        if cons.min_quality > 0 or cons.is_local then
            local lst = {}
            if cons.is_local then
                table.insert(lst, 'local')
            end
            if cons.min_quality > 0 then
                table.insert(lst, string.lower(df.item_quality[cons.min_quality]))
            end
            itemstr = itemstr .. ' ('..table.concat(lst,',')..')'
        end
        local matstr,matflagstr = describe_material(cons)

        table.insert(choices, {
            text = {
                goal, ' ', { text = '(now '..curval..')', pen = order_pen }, NEWLINE,
                '  ', itemstr, NEWLINE, '  ', matstr, NEWLINE, '  ', (matflagstr or '')
            },
            token = cons.token,
            obj = cons
        })
    end

    local selidx = nil
    if sel_token then
        selidx = utils.linear_index(choices, sel_token, 'token')
    end

    self.subviews.list:setChoices(choices, selidx)
end

function JobConstraints:isAnySelected()
    return self.subviews.list:getSelected() ~= nil
end

function JobConstraints:getCurConstraint()
    local i,v = self.subviews.list:getSelected()
    if v then return v.obj end
end

function JobConstraints:saveConstraint(cons)
    local out = workflow.setConstraint(cons.token, cons.goal_by_count, cons.goal_value, cons.goal_gap)
    self:initListChoices(nil, out.token)
end

function JobConstraints:onNewConstraint()
    local outputs = workflow.listJobOutputs(self.job)
    if #outputs == 0 then
        dlg.showMessage('Unsupported', 'Workflow cannot guess the outputs of this job.', COLOR_LIGHTRED)
        return
    end

    local variants = workflow.listWeakenedConstraints(outputs)

    local choices = {}
    for i,cons in ipairs(variants) do
        local itemstr = describe_item_type(cons)
        local matstr,matflags = describe_material(cons)
        if matflags then
            matstr = matflags..' '..matstr
        end

        table.insert(choices, { text = itemstr..' of '..matstr, obj = cons })
    end

    dlg.ListBox{
        frame_title = 'Add limit',
        text = 'Select one of the possible outputs:',
        text_pen = COLOR_WHITE,
        choices = choices,
        on_select = function(idx,item)
            self:saveConstraint(item.obj)
        end,
        select2_hint = 'Advanced',
        on_select2 = function(idx,item)
            NewConstraint{
                constraint = item.obj,
                on_submit = self:callback('saveConstraint')
            }:show()
        end,
    }:show()
end

function JobConstraints:onDeleteConstraint()
    local cons = self:getCurConstraint()
    dlg.showYesNoPrompt(
        'Delete Constraint',
        'Really delete the current constraint?',
        COLOR_YELLOW,
        function()
            workflow.deleteConstraint(cons.token)
            self:initListChoices()
        end
    )
end

function JobConstraints:onInput(keys)
    if self:propagateMoveKeys(keys) then
        if df.global.world.selected_building ~= self.building then
            self:dismiss()
        end
    else
        JobConstraints.super.onInput(self, keys)
    end
end

local args = {...}

if args[1] == 'status' then
    check_enabled(function() ConstraintList{}:show() end)
else
    if not string.match(dfhack.gui.getCurFocus(), '^dwarfmode/QueryBuilding/Some/Workshop/Job') then
        qerror("This script requires a workshop job selected in the 'q' mode")
    end

    local job = dfhack.gui.getSelectedJob()

    check_enabled(function()
        check_repeat(job, function()
            local clist = workflow.listConstraints(job)
            if not clist then
                dlg.showMessage('Not Supported', 'This type of job is not supported by workflow.', COLOR_LIGHTRED)
                return
            end
            JobConstraints{ job = job, clist = clist }:show()
        end)
    end)
end
