local _ENV = mkmodule('plugins.stockflow')

local gui = require "gui"

reaction_list = reaction_list or {}
saved_orders = saved_orders or {}
jobs_to_create = jobs_to_create or {}

triggers = {
    {filled = false, divisor = 1, name = "Per empty space"},
    {filled = true,  divisor = 1, name = "Per stored item"},
    {filled = false, divisor = 2, name = "Per two empty spaces"},
    {filled = true,  divisor = 2, name = "Per two stored items"},
    {filled = false, divisor = 3, name = "Per three empty spaces"},
    {filled = true,  divisor = 3, name = "Per three stored items"},
    {filled = false, divisor = 4, name = "Per four empty spaces"},
    {filled = true,  divisor = 4, name = "Per four stored items"},
    {name = "Never"},
}

entry_ints = {
    stockpile_id = 1,
    order_number = 2,
    trigger_number = 3,
}

FirstRow = 3
CenterCol = 38
ExtraLines = 9

-- Populate the reaction and stockpile order lists.
-- To be called whenever a world is loaded.
function initialize_world()
    -- Clear old reactions, just in case.
    clear_caches()
    reaction_list = collect_reactions()
    saved_orders = collect_orders()
end

-- Clear all caches.
-- Called when a world is loaded, or when the plugin is disabled.
function clear_caches()
    -- Free the C++ objects in the reaction list.
    for _, value in ipairs(reaction_list) do
        value.order:delete()
    end
    reaction_list = {}
    saved_orders = {}
    jobs_to_create = {}
end

function trigger_name(cache)
    local trigger = triggers[cache.entry.ints[entry_ints.trigger_number]]
    return trigger and trigger.name or "Never"
end

function list_orders()
    local listed = false
    for _, spec in pairs(saved_orders) do
        local num = spec.stockpile.stockpile_number
        local name = spec.entry.value
        local trigger = trigger_name(spec)
        print("Stockpile #"..num, name, trigger)
        listed = true
    end

    if not listed then
        print("No manager jobs have been set for your stockpiles.")
        print("Use j in a stockpile menu to create one...")
    end
end

-- Save the stockpile jobs for later creation.
-- Called when the bookkeeper starts updating stockpile records.
function start_bookkeeping()
    local result = {}
    for reaction_id, quantity in pairs(check_stockpiles()) do
        local amount = order_quantity(reaction_list[reaction_id].order, quantity)
        if amount > 0 then
            result[reaction_id] = amount
        end
    end

    jobs_to_create = result
end

-- Insert any saved jobs.
-- Called when the bookkeeper finishes updating stockpile records.
function finish_bookkeeping()
    for reaction, amount in pairs(jobs_to_create) do
        create_orders(reaction_list[reaction].order, amount)
    end

    jobs_to_create = {}
end

function stockpile_settings(sp)
    local order = saved_orders[sp.id]
    if not order then
        return "No job selected", ""
    end

    return order.entry.value, trigger_name(order)
end

-- Toggle the trigger condition for a stockpile.
function toggle_trigger(sp)
    local saved = saved_orders[sp.id]
    if saved then
        saved.entry.ints[entry_ints.trigger_number] = (saved.entry.ints[entry_ints.trigger_number] % #triggers) + 1
        saved.entry:save()
    end
end

function collect_orders()
    local result = {}
    local entries = dfhack.persistent.get_all("stockflow/entry", true)
    if entries then
        for _, entry in ipairs(entries) do
            local spid = entry.ints[entry_ints.stockpile_id]
            local stockpile = df.building.find(spid)
            if stockpile then
                local order_number = entry.ints[entry_ints.order_number]
                if reaction_list[order_number] and entry.value == reaction_list[order_number].name then
                    result[spid] = {
                        stockpile = stockpile,
                        entry = entry,
                    }
                else
                    -- Todo: Search reaction_list for the name.
                    -- This can happen when loading an old save in a new version.
                    -- It's even possible that the reaction has been removed.
                    local found = false
                    for number, reaction in ipairs(reaction_list) do
                        if reaction.name == entry.value then
                            print("Adjusting stockflow entry for stockpile #"..stockpile.stockpile_number..": "..entry.value.." ("..order_number.." => "..number..")")
                            entry.ints[entry_ints.order_number] = number
                            entry:save()
                            result[spid] = {
                                stockpile = stockpile,
                                entry = entry,
                            }

                            found = true
                            break
                        end
                    end

                    if not found then
                        print("Unmatched stockflow entry for stockpile #"..stockpile.stockpile_number..": "..entry.value.." ("..order_number..")")
                    end
                end
            else
                -- The stockpile no longer exists.
                -- Perhaps it has been deleted, or perhaps this is a different fortress.
                -- print("Missing stockflow pile "..spid)
                entry:delete()
            end
        end
    end

    return result
end

-- Choose an order that the stockpile should add to the manager queue.
function select_order(stockpile)
    screen:reset(stockpile)
    screen:show()
end

function reaction_entry(reactions, job_type, values, name)
    if not job_type then
        -- Perhaps df.job_type.something returned nil for an unknown job type.
        -- We could warn about it; in any case, don't add it to the list.
        return
    end

    local order = df.manager_order:new()
    -- These defaults differ from the newly created order's.
    order:assign{
        job_type = job_type,
        item_type = -1,
        item_subtype = -1,
        mat_type = -1,
        mat_index = -1,
    }

    if values then
        -- Override default attributes.
        order:assign(values)
    end

    table.insert(reactions, {
        name = name or df.job_type.attrs[job_type].caption,
        order = order,
    })
end

function resource_reactions(reactions, job_type, mat_info, keys, items, options)
    local values = {}
    for key, value in pairs(mat_info.management) do
        values[key] = value
    end

    for _, itemid in ipairs(keys) do
        local itemdef = items[itemid]
        local start = options.verb or mat_info.verb or "Make"
        if options.adjective then
            start = start.." "..itemdef.adjective
        end

        if (not options.permissible) or options.permissible(itemdef) then
            local item_name = " "..itemdef[options.name_field or "name"]
            if options.capitalize then
                item_name = string.gsub(item_name, " .", string.upper)
            end

            values.item_subtype = itemid
            reaction_entry(reactions, job_type, values, start.." "..mat_info.adjective..item_name)
        end
    end
end

function material_reactions(reactions, itemtypes, mat_info)
    -- Expects a list of {job_type, verb, item_name} tuples.
    for _, row in ipairs(itemtypes) do
        local line = row[2].." "..mat_info.adjective
        if row[3] then
            line = line.." "..row[3]
        end

        reaction_entry(reactions, row[1], mat_info.management, line)
    end
end

function clothing_reactions(reactions, mat_info, filter)
    local resources = df.historical_entity.find(df.global.ui.civ_id).resources
    local itemdefs = df.global.world.raws.itemdefs
    local job_types = df.job_type
    resource_reactions(reactions, job_types.MakeArmor,  mat_info, resources.armor_type,  itemdefs.armor,  {permissible = filter})
    resource_reactions(reactions, job_types.MakePants,  mat_info, resources.pants_type,  itemdefs.pants,  {permissible = filter})
    resource_reactions(reactions, job_types.MakeGloves, mat_info, resources.gloves_type, itemdefs.gloves, {permissible = filter})
    resource_reactions(reactions, job_types.MakeHelm,   mat_info, resources.helm_type,   itemdefs.helms,  {permissible = filter})
    resource_reactions(reactions, job_types.MakeShoes,  mat_info, resources.shoes_type,  itemdefs.shoes,  {permissible = filter})
end

-- Find the reaction types that should be listed in the management interface.
function collect_reactions()
    -- The sequence here tries to match the native manager screen.
    -- It should also be possible to collect the sequence from somewhere native,
    -- but I currently can only find it while the job selection screen is active.
    -- Even that list doesn't seem to include their names.
    local result = {}

    -- Caching the enumeration might not be important, but saves lookups.
    local job_types = df.job_type

    local materials = {
        rock = {
            adjective = "rock",
            management = {mat_type = 0},
        },
    }

    for _, name in ipairs{"wood", "cloth", "leather", "silk", "yarn", "bone", "shell", "tooth", "horn", "pearl"} do
        materials[name] = {
            adjective = name,
            management = {material_category = {[name] = true}},
        }
    end

    materials.wood.adjective = "wooden"
    materials.tooth.adjective = "ivory/tooth"
    materials.leather.clothing_flag = "LEATHER"
    materials.shell.short = true
    materials.pearl.short = true

    -- Collection and Entrapment
    reaction_entry(result, job_types.CollectWebs)
    reaction_entry(result, job_types.CollectSand)
    reaction_entry(result, job_types.CollectClay)
    reaction_entry(result, job_types.CatchLiveLandAnimal)
    reaction_entry(result, job_types.CatchLiveFish)

    -- Cutting, encrusting, and metal extraction.
    local rock_types = df.global.world.raws.inorganics
    for rock_id = #rock_types-1, 0, -1 do
        local material = rock_types[rock_id].material
        local rock_name = material.state_adj.Solid
        if material.flags.IS_STONE or material.flags.IS_GEM then
            reaction_entry(result, job_types.CutGems, {
                mat_type = 0,
                mat_index = rock_id,
            }, "Cut "..rock_name)

            reaction_entry(result, job_types.EncrustWithGems, {
                mat_type = 0,
                mat_index = rock_id,
                item_category = {finished_goods = true},
            }, "Encrust Finished Goods With "..rock_name)

            reaction_entry(result, job_types.EncrustWithGems, {
                mat_type = 0,
                mat_index = rock_id,
                item_category = {furniture = true},
            }, "Encrust Furniture With "..rock_name)

            reaction_entry(result, job_types.EncrustWithGems, {
                mat_type = 0,
                mat_index = rock_id,
                item_category = {ammo = true},
            }, "Encrust Ammo With "..rock_name)
        end

        if #rock_types[rock_id].metal_ore.mat_index > 0 then
            reaction_entry(result, job_types.SmeltOre, {mat_type = 0, mat_index = rock_id}, "Smelt "..rock_name.." Ore")
        end

        if #rock_types[rock_id].thread_metal.mat_index > 0 then
            reaction_entry(result, job_types.ExtractMetalStrands, {mat_type = 0, mat_index = rock_id})
        end
    end

    -- Glass cutting and encrusting, with different job numbers.
    -- We could search the entire table, but glass is less subject to raws.
    local glass_types = df.global.world.raws.mat_table.builtin
    local glasses = {}
    for glass_id = 3, 5 do
        local material = glass_types[glass_id]
        local glass_name = material.state_adj.Solid
        if material.flags.IS_GLASS then
            -- For future use.
            table.insert(glasses, {
                adjective = glass_name,
                management = {mat_type = glass_id},
            })

            reaction_entry(result, job_types.CutGlass, {mat_type = glass_id}, "Cut "..glass_name)

            reaction_entry(result, job_types.EncrustWithGlass, {
                mat_type = glass_id,
                item_category = {finished_goods = true},
            }, "Encrust Finished Goods With "..glass_name)

            reaction_entry(result, job_types.EncrustWithGlass, {
                mat_type = glass_id,
                item_category = {furniture = true},
            }, "Encrust Furniture With "..glass_name)

            reaction_entry(result, job_types.EncrustWithGlass, {
                mat_type = glass_id,
                item_category = {ammo = true},
            }, "Encrust Ammo With "..glass_name)
        end
    end

    -- Dyeing
    reaction_entry(result, job_types.DyeThread)
    reaction_entry(result, job_types.DyeCloth)

    -- Sew Image
    local cloth_mats = {materials.cloth, materials.silk, materials.yarn, materials.leather}
    for _, material in ipairs(cloth_mats) do
        material_reactions(result, {{job_types.SewImage, "Sew", "Image"}}, material)
        material.cloth = true
    end

    for _, spec in ipairs{materials.bone, materials.shell, materials.tooth, materials.horn, materials.pearl} do
        material_reactions(result, {{job_types.DecorateWith, "Decorate With"}}, spec)
    end

    reaction_entry(result, job_types.MakeTotem)
    reaction_entry(result, job_types.ButcherAnimal)
    reaction_entry(result, job_types.MillPlants)
    reaction_entry(result, job_types.MakePotashFromLye)
    reaction_entry(result, job_types.MakePotashFromAsh)

    -- Kitchen
    reaction_entry(result, job_types.PrepareMeal, {mat_type = 2}, "Prepare Easy Meal")
    reaction_entry(result, job_types.PrepareMeal, {mat_type = 3}, "Prepare Fine Meal")
    reaction_entry(result, job_types.PrepareMeal, {mat_type = 4}, "Prepare Lavish Meal")

    -- Brew Drink
    reaction_entry(result, job_types.BrewDrink)

    -- Weaving
    reaction_entry(result, job_types.WeaveCloth, {material_category = {plant = true}}, "Weave Thread into Cloth")
    reaction_entry(result, job_types.WeaveCloth, {material_category = {silk = true}}, "Weave Thread into Silk")
    reaction_entry(result, job_types.WeaveCloth, {material_category = {yarn = true}}, "Weave Yarn into Cloth")

    -- Extracts, farmer's workshop, and wood burning
    reaction_entry(result, job_types.ExtractFromPlants)
    reaction_entry(result, job_types.ExtractFromRawFish)
    reaction_entry(result, job_types.ExtractFromLandAnimal)
    reaction_entry(result, job_types.PrepareRawFish)
    reaction_entry(result, job_types.MakeCheese)
    reaction_entry(result, job_types.MilkCreature)
    reaction_entry(result, job_types.ShearCreature)
    reaction_entry(result, job_types.SpinThread, {material_category = {strand = true}})
    reaction_entry(result, job_types.MakeLye)
    reaction_entry(result, job_types.ProcessPlants)
    reaction_entry(result, job_types.ProcessPlantsBag)
    reaction_entry(result, job_types.ProcessPlantsVial)
    reaction_entry(result, job_types.ProcessPlantsBarrel)
    reaction_entry(result, job_types.MakeCharcoal)
    reaction_entry(result, job_types.MakeAsh)

    -- Reactions defined in the raws.
    -- Not all reactions are allowed to the civilization.
    -- That includes "Make sharp rock" by default.
    local entity = df.historical_entity.find(df.global.ui.civ_id)
    if not entity then
        -- No global civilization; arena mode?
        -- Anyway, skip remaining reactions, since many depend on the civ.
        return result
    end

    for _, reaction_id in ipairs(entity.entity_raw.workshops.permitted_reaction_id) do
        local reaction = df.global.world.raws.reactions.reactions[reaction_id]
        local name = string.gsub(reaction.name, "^.", string.upper)
        reaction_entry(result, job_types.CustomReaction, {reaction_name = reaction.code}, name)
    end

    -- Reactions generated by the game.
    for _, reaction in ipairs(df.global.world.raws.reactions.reactions) do
        if reaction.source_enid == entity.id then
            local name = string.gsub(reaction.name, "^.", string.upper)
            reaction_entry(result, job_types.CustomReaction, {reaction_name = reaction.code}, name)
        end
    end

    -- Metal forging
    local itemdefs = df.global.world.raws.itemdefs
    for rock_id = 0, #rock_types - 1 do
        local material = rock_types[rock_id].material
        local rock_name = material.state_adj.Solid
        local mat_flags = {
            adjective = rock_name,
            management = {mat_type = 0, mat_index = rock_id},
            verb = "Forge",
        }

        if material.flags.IS_METAL then
            reaction_entry(result, job_types.StudWith, mat_flags.management, "Stud With "..rock_name)

            if material.flags.ITEMS_WEAPON then
                -- Todo: Are these really the right flags to check?
                resource_reactions(result, job_types.MakeWeapon, mat_flags, entity.resources.weapon_type, itemdefs.weapons, {
                    permissible = (function(itemdef) return itemdef.skill_ranged == -1 end),
                })

                -- Is this entirely disconnected from the entity?
                material_reactions(result, {{job_types.MakeBallistaArrowHead, "Forge", "Ballista Arrow Head"}}, mat_flags)

                resource_reactions(result, job_types.MakeTrapComponent, mat_flags, entity.resources.trapcomp_type, itemdefs.trapcomps, {
                    adjective = true,
                })

                resource_reactions(result, job_types.AssembleSiegeAmmo, mat_flags, entity.resources.siegeammo_type, itemdefs.siege_ammo, {
                    verb = "Assemble",
                })
            end

            if material.flags.ITEMS_WEAPON_RANGED then
                resource_reactions(result, job_types.MakeWeapon, mat_flags, entity.resources.weapon_type, itemdefs.weapons, {
                    permissible = (function(itemdef) return itemdef.skill_ranged >= 0 end),
                })
            end

            if material.flags.ITEMS_DIGGER then
                -- Todo: Ranged or training digging weapons?
                resource_reactions(result, job_types.MakeWeapon, mat_flags, entity.resources.digger_type, itemdefs.weapons, {
                })
            end

            if material.flags.ITEMS_AMMO then
                resource_reactions(result, job_types.MakeAmmo, mat_flags, entity.resources.ammo_type, itemdefs.ammo, {
                    name_field = "name_plural",
                })
            end

            if material.flags.ITEMS_ANVIL then
                material_reactions(result, {{job_types.ForgeAnvil, "Forge", "Anvil"}}, mat_flags)
            end

            if material.flags.ITEMS_ARMOR then
                local metalclothing = (function(itemdef) return itemdef.props.flags.METAL end)
                clothing_reactions(result, mat_flags, metalclothing)
                resource_reactions(result, job_types.MakeShield, mat_flags, entity.resources.shield_type, itemdefs.shields, {
                })
            end

            if material.flags.ITEMS_SOFT then
                local metalclothing = (function(itemdef) return itemdef.props.flags.SOFT and not itemdef.props.flags.METAL end)
                clothing_reactions(result, mat_flags, metalclothing)
            end

            resource_reactions(result, job_types.MakeTool, mat_flags, entity.resources.tool_type, itemdefs.tools, {
                permissible = (function(itemdef) return ((material.flags.ITEMS_HARD and itemdef.flags.HARD_MAT) or (material.flags.ITEMS_METAL and itemdef.flags.METAL_MAT)) and not itemdef.flags.NO_DEFAULT_JOB end),
                capitalize = true,
            })

            if material.flags.ITEMS_HARD then
                material_reactions(result, {
                    {job_types.ConstructDoor, "Construct", "Door"},
                    {job_types.ConstructFloodgate, "Construct", "Floodgate"},
                    {job_types.ConstructHatchCover, "Construct", "Hatch Cover"},
                    {job_types.ConstructGrate, "Construct", "Grate"},
                    {job_types.ConstructThrone, "Construct", "Throne"},
                    {job_types.ConstructCoffin, "Construct", "Sarcophagus"},
                    {job_types.ConstructTable, "Construct", "Table"},
                    {job_types.ConstructSplint, "Construct", "Splint"},
                    {job_types.ConstructCrutch, "Construct", "Crutch"},
                    {job_types.ConstructArmorStand, "Construct", "Armor Stand"},
                    {job_types.ConstructWeaponRack, "Construct", "Weapon Rack"},
                    {job_types.ConstructCabinet, "Construct", "Cabinet"},
                    {job_types.MakeGoblet, "Forge", "Goblet"},
                    {job_types.MakeInstrument, "Forge", "Instrument"},
                    {job_types.MakeToy, "Forge", "Toy"},
                    {job_types.ConstructStatue, "Construct", "Statue"},
                    {job_types.ConstructBlocks, "Construct", "Blocks"},
                    {job_types.MakeAnimalTrap, "Forge", "Animal Trap"},
                    {job_types.MakeBarrel, "Forge", "Barrel"},
                    {job_types.MakeBucket, "Forge", "Bucket"},
                    {job_types.ConstructBin, "Construct", "Bin"},
                    {job_types.MakePipeSection, "Forge", "Pipe Section"},
                    {job_types.MakeCage, "Forge", "Cage"},
                    {job_types.MintCoins, "Mint", "Coins"},
                    {job_types.ConstructChest, "Construct", "Chest"},
                    {job_types.MakeFlask, "Forge", "Flask"},
                    {job_types.MakeChain, "Forge", "Chain"},
                    {job_types.MakeCrafts, "Make", "Crafts"},
                    {job_types.MakeFigurine, "Make", "Figurine"},
                    {job_types.MakeAmulet, "Make", "Amulet"},
                    {job_types.MakeScepter, "Make", "Scepter"},
                    {job_types.MakeCrown, "Make", "Crown"},
                    {job_types.MakeRing, "Make", "Ring"},
                    {job_types.MakeEarring, "Make", "Earring"},
                    {job_types.MakeBracelet, "Make", "Bracelet"},
                    {job_types.MakeGem, "Make Large", "Gem"},
                    {job_types.ConstructMechanisms, "Construct", "Mechanisms"},
                }, mat_flags)
            end

            if material.flags.ITEMS_SOFT then
                material_reactions(result, {
                    {job_types.MakeBackpack, "Make", "Backpack"},
                    {job_types.MakeQuiver, "Make", "Quiver"},
                    {job_types.ConstructCatapultParts, "Construct", "Catapult Parts"},
                    {job_types.ConstructBallistaParts, "Construct", "Ballista Parts"},
                }, mat_flags)
            end
        end
    end

    -- Traction Bench
    reaction_entry(result, job_types.ConstructTractionBench)

    -- Non-metal weapons
    resource_reactions(result, job_types.MakeWeapon, materials.wood, entity.resources.weapon_type, itemdefs.weapons, {
        permissible = (function(itemdef) return itemdef.skill_ranged >= 0 end),
    })

    resource_reactions(result, job_types.MakeWeapon, materials.wood, entity.resources.training_weapon_type, itemdefs.weapons, {
    })

    resource_reactions(result, job_types.MakeWeapon, materials.bone, entity.resources.weapon_type, itemdefs.weapons, {
        permissible = (function(itemdef) return itemdef.skill_ranged >= 0 end),
    })

    resource_reactions(result, job_types.MakeWeapon, materials.rock, entity.resources.weapon_type, itemdefs.weapons, {
        permissible = (function(itemdef) return itemdef.flags.CAN_STONE end),
    })

    -- Wooden items
    -- Closely related to the ITEMS_HARD list.
    material_reactions(result, {
        {job_types.ConstructDoor, "Construct", "Door"},
        {job_types.ConstructFloodgate, "Construct", "Floodgate"},
        {job_types.ConstructHatchCover, "Construct", "Hatch Cover"},
        {job_types.ConstructGrate, "Construct", "Grate"},
        {job_types.ConstructThrone, "Construct", "Chair"},
        {job_types.ConstructCoffin, "Construct", "Casket"},
        {job_types.ConstructTable, "Construct", "Table"},
        {job_types.ConstructArmorStand, "Construct", "Armor Stand"},
        {job_types.ConstructWeaponRack, "Construct", "Weapon Rack"},
        {job_types.ConstructCabinet, "Construct", "Cabinet"},
        {job_types.MakeGoblet, "Make", "Cup"},
        {job_types.MakeInstrument, "Make", "Instrument"},
    }, materials.wood)

    resource_reactions(result, job_types.MakeTool, materials.wood, entity.resources.tool_type, itemdefs.tools, {
        -- permissible = (function(itemdef) return itemdef.flags.WOOD_MAT and not itemdef.flags.NO_DEFAULT_JOB end),
        permissible = (function(itemdef) return not itemdef.flags.NO_DEFAULT_JOB end),
        capitalize = true,
    })

    material_reactions(result, {
        {job_types.MakeToy, "Make", "Toy"},
        {job_types.ConstructBlocks, "Construct", "Blocks"},
        {job_types.ConstructSplint, "Construct", "Splint"},
        {job_types.ConstructCrutch, "Construct", "Crutch"},
        {job_types.MakeAnimalTrap, "Make", "Animal Trap"},
        {job_types.MakeBarrel, "Make", "Barrel"},
        {job_types.MakeBucket, "Make", "Bucket"},
        {job_types.ConstructBin, "Construct", "Bin"},
        {job_types.MakeCage, "Make", "Cage"},
        {job_types.MakePipeSection, "Make", "Pipe Section"},
    }, materials.wood)

    resource_reactions(result, job_types.MakeTrapComponent, materials.wood, entity.resources.trapcomp_type, itemdefs.trapcomps, {
        permissible = (function(itemdef) return itemdef.flags.WOOD end),
        adjective = true,
    })

    -- Rock items
    material_reactions(result, {
        {job_types.ConstructDoor, "Construct", "Door"},
        {job_types.ConstructFloodgate, "Construct", "Floodgate"},
        {job_types.ConstructHatchCover, "Construct", "Hatch Cover"},
        {job_types.ConstructGrate, "Construct", "Grate"},
        {job_types.ConstructThrone, "Construct", "Throne"},
        {job_types.ConstructCoffin, "Construct", "Coffin"},
        {job_types.ConstructTable, "Construct", "Table"},
        {job_types.ConstructArmorStand, "Construct", "Armor Stand"},
        {job_types.ConstructWeaponRack, "Construct", "Weapon Rack"},
        {job_types.ConstructCabinet, "Construct", "Cabinet"},
        {job_types.MakeGoblet, "Make", "Mug"},
        {job_types.MakeInstrument, "Make", "Instrument"},
    }, materials.rock)

    resource_reactions(result, job_types.MakeTool, materials.rock, entity.resources.tool_type, itemdefs.tools, {
        permissible = (function(itemdef) return itemdef.flags.HARD_MAT end),
        capitalize = true,
    })

    material_reactions(result, {
        {job_types.MakeToy, "Make", "Toy"},
        {job_types.ConstructQuern, "Construct", "Quern"},
        {job_types.ConstructMillstone, "Construct", "Millstone"},
        {job_types.ConstructSlab, "Construct", "Slab"},
        {job_types.ConstructStatue, "Construct", "Statue"},
        {job_types.ConstructBlocks, "Construct", "Blocks"},
    }, materials.rock)

    -- Glass items
    for _, mat_info in ipairs(glasses) do
        material_reactions(result, {
            {job_types.ConstructDoor, "Construct", "Portal"},
            {job_types.ConstructFloodgate, "Construct", "Floodgate"},
            {job_types.ConstructHatchCover, "Construct", "Hatch Cover"},
            {job_types.ConstructGrate, "Construct", "Grate"},
            {job_types.ConstructThrone, "Construct", "Throne"},
            {job_types.ConstructCoffin, "Construct", "Coffin"},
            {job_types.ConstructTable, "Construct", "Table"},
            {job_types.ConstructArmorStand, "Construct", "Armor Stand"},
            {job_types.ConstructWeaponRack, "Construct", "Weapon Rack"},
            {job_types.ConstructCabinet, "Construct", "Cabinet"},
            {job_types.MakeGoblet, "Make", "Goblet"},
            {job_types.MakeInstrument, "Make", "Instrument"},
        }, mat_info)

        resource_reactions(result, job_types.MakeTool, mat_info, entity.resources.tool_type, itemdefs.tools, {
            permissible = (function(itemdef) return itemdef.flags.HARD_MAT end),
            capitalize = true,
        })

        material_reactions(result, {
            {job_types.MakeToy, "Make", "Toy"},
            {job_types.ConstructStatue, "Construct", "Statue"},
            {job_types.ConstructBlocks, "Construct", "Blocks"},
            {job_types.MakeCage, "Make", "Terrarium"},
            {job_types.MakePipeSection, "Make", "Tube"},
        }, mat_info)

        resource_reactions(result, job_types.MakeTrapComponent, mat_info, entity.resources.trapcomp_type, itemdefs.trapcomps, {
            adjective = true,
        })
    end

    -- Bed, specified as wooden.
    reaction_entry(result, job_types.ConstructBed, materials.wood.management)

    -- Windows
    for _, mat_info in ipairs(glasses) do
        material_reactions(result, {
            {job_types.MakeWindow, "Make", "Window"},
        }, mat_info)
    end

    -- Rock Mechanisms
    reaction_entry(result, job_types.ConstructMechanisms, materials.rock.management)

    resource_reactions(result, job_types.AssembleSiegeAmmo, materials.wood, entity.resources.siegeammo_type, itemdefs.siege_ammo, {
        verb = "Assemble",
    })

    for _, mat_info in ipairs(glasses) do
        material_reactions(result, {
            {job_types.MakeRawGlass, "Make Raw", nil},
        }, mat_info)
    end

    material_reactions(result, {
        {job_types.MakeBackpack, "Make", "Backpack"},
        {job_types.MakeQuiver, "Make", "Quiver"},
    }, materials.leather)

    for _, material in ipairs(cloth_mats) do
        clothing_reactions(result, material, (function(itemdef) return itemdef.props.flags[material.clothing_flag or "SOFT"] end))
    end

    -- Boxes, Bags, and Ropes
    local boxmats = {
        {mats = {materials.wood}, box = "Chest"},
        {mats = {materials.rock}, box = "Coffer"},
        {mats = glasses, box = "Box", flask = "Vial"},
        {mats = {materials.cloth}, box = "Bag", chain = "Rope"},
        {mats = {materials.leather}, box = "Bag", flask = "Waterskin"},
        {mats = {materials.silk, materials.yarn}, box = "Bag", chain = "Rope"},
    }
    for _, boxmat in ipairs(boxmats) do
        for _, mat in ipairs(boxmat.mats) do
            material_reactions(result, {{job_types.ConstructChest, "Construct", boxmat.box}}, mat)
            if boxmat.chain then
                material_reactions(result, {{job_types.MakeChain, "Make", boxmat.chain}}, mat)
            end
            if boxmat.flask then
                material_reactions(result, {{job_types.MakeFlask, "Make", boxmat.flask}}, mat)
            end
        end
    end

    -- Crafts
    for _, mat in ipairs{
        materials.wood,
        materials.rock,
        materials.cloth,
        materials.leather,
        materials.shell,
        materials.bone,
        materials.silk,
        materials.tooth,
        materials.horn,
        materials.pearl,
        materials.yarn,
    } do
        material_reactions(result, {
            {job_types.MakeCrafts, "Make", "Crafts"},
            {job_types.MakeAmulet, "Make", "Amulet"},
            {job_types.MakeBracelet, "Make", "Bracelet"},
            {job_types.MakeEarring, "Make", "Earring"},
        }, mat)

        if not mat.cloth then
            material_reactions(result, {
                {job_types.MakeCrown, "Make", "Crown"},
                {job_types.MakeFigurine, "Make", "Figurine"},
                {job_types.MakeRing, "Make", "Ring"},
                {job_types.MakeGem, "Make Large", "Gem"},
            }, mat)

            if not mat.short then
                material_reactions(result, {
                    {job_types.MakeScepter, "Make", "Scepter"},
                }, mat)
            end
        end
    end

    -- Siege engine parts
    reaction_entry(result, job_types.ConstructCatapultParts, materials.wood.management)
    reaction_entry(result, job_types.ConstructBallistaParts, materials.wood.management)

    for _, mat in ipairs{materials.wood, materials.bone} do
        resource_reactions(result, job_types.MakeAmmo, mat, entity.resources.ammo_type, itemdefs.ammo, {
            name_field = "name_plural",
        })
    end

    -- BARRED and SCALED as flag names don't quite seem to fit, here.
    clothing_reactions(result, materials.bone, (function(itemdef) return itemdef.props.flags.BARRED end))
    clothing_reactions(result, materials.shell, (function(itemdef) return itemdef.props.flags.SCALED end))

    for _, mat in ipairs{materials.wood, materials.leather} do
        resource_reactions(result, job_types.MakeShield, mat, entity.resources.shield_type, itemdefs.shields, {})
    end

    -- Melt a Metal Object
    reaction_entry(result, job_types.MeltMetalObject)

    return result
end

screen = gui.FramedScreen {
    frame_title = "Select Stockpile Order",
}

function screen:onRenderBody(dc)
    -- Emulates the built-in manager screen.

    if not (self.page_size == self.frame_rect.height - ExtraLines) then
        -- The screen size has changed.
        self:refilter()
    end

    -- Top instruction line.
    dc:seek(1, 1):string("Type in parts of the name to narrow your search.  ", COLOR_WHITE)
    dc:key("LEAVESCREEN"):string(" to abort.", COLOR_WHITE)

    -- Search term, if any.
    dc:seek(1, FirstRow + self.page_size + 1):string(self.search_string, COLOR_LIGHTCYAN)

    -- Bottom instruction line.
    dc:seek(1, FirstRow + self.page_size + 2)
    dc:key("STANDARDSCROLL_UP"):key("STANDARDSCROLL_DOWN")
    dc:key("STANDARDSCROLL_PAGEUP"):key("STANDARDSCROLL_PAGEDOWN")
    dc:key("STANDARDSCROLL_LEFT"):key("STANDARDSCROLL_RIGHT")
    dc:string(": Select", COLOR_WHITE)

    dc:seek(CenterCol, FirstRow + self.page_size + 2)
    dc:key("SETUPGAME_SAVE_PROFILE_ABORT"):string(": No order", COLOR_WHITE)

    -- Reaction lines.
    for _, item in ipairs(self.displayed) do
        dc:seek(item.x, item.y):string(item.name, item.color)
    end
end

function screen:onInput(keys)
    if keys.LEAVESCREEN then
        self:dismiss()
    elseif keys.SELECT then
        self:dismiss()
        local selected = self.reactions[self.position]
        if selected then
            store_order(self.stockpile, selected.index)
        end
    elseif keys.SETUPGAME_SAVE_PROFILE_ABORT then
        self:dismiss()
        clear_order(self.stockpile)
    elseif keys.STANDARDSCROLL_UP then
        self.position = self.position - 1
    elseif keys.STANDARDSCROLL_DOWN then
        self.position = self.position + 1
    elseif keys.STANDARDSCROLL_LEFT then
        if self.position == 1 then
            -- Move from the very first item to the very last item.
            self.position = #self.reactions
        elseif self.position < self.page_size then
            -- On the first column, move to the very first item.
            self.position = 1
        else
            -- Move to the same position on the previous column.
            self.position = self.position - self.page_size
        end
    elseif keys.STANDARDSCROLL_RIGHT then
        if self.position == #self.reactions then
            -- Move from the very last item to the very first item.
            self.position = 1
        else
            -- Move to the same position on the next column.
            self.position = self.position + self.page_size
            if self.position > #self.reactions then
                -- If that's past the end, move to the very last item.
                self.position = #self.reactions
            end
        end
    elseif keys.STANDARDSCROLL_PAGEUP then
        if self.position == 1 then
            -- Move from the very first item to the very last item.
            self.position = #self.reactions
        elseif self.position < self.page_size*2 then
            -- On the first page, move to the very first item.
            self.position = 1
        else
            -- Move to the same position on the previous page.
            self.position = self.position - self.page_size*2
        end
    elseif keys.STANDARDSCROLL_PAGEDOWN then
        if self.position == #self.reactions then
            -- Move from the very last item to the very first item.
            self.position = 1
        else
            -- Move to the same position on the next page.
            self.position = self.position + self.page_size*2
            if self.position > #self.reactions then
                -- If that's past the end, move to the very last item.
                self.position = #self.reactions
            end
        end
    elseif keys.STRING_A000 then
        -- This seems like an odd way to check for Backspace.
        self.search_string = string.sub(self.search_string, 1, -2)
        self.position = 1
    elseif keys._STRING and keys._STRING >= 32 then
        -- This interface only accepts letters and spaces.
        local char = string.char(keys._STRING)
        if char == " " or string.find(char, "^%a") then
            self.search_string = self.search_string .. string.upper(char)
            self.position = 1
        end
    end

    self:refilter()
end

function screen:reset(stockpile)
    self.stockpile = stockpile
    self.search_string = ""
    self.position = 1
    self:refilter()
end

function matchall(haystack, needles)
    for _, needle in ipairs(needles) do
        if not string.find(haystack, needle) then
            return false
        end
    end

    return true
end

function splitstring(full, pattern)
    local last = string.len(full)
    local result = {}
    local n = 1
    while n <= last do
        local start, stop = string.find(full, pattern, n)
        if not start then
            result[#result+1] = string.sub(full, n)
            break
        elseif start > n then
            result[#result+1] = string.sub(full, n, start - 1)
        end

        if stop < n then
            -- The pattern matches an empty string.
            -- Avoid an infinite loop.
            break
        end

        n = stop + 1
    end

    return result
end

function screen:refilter()
    -- Determine which rows to show, and in which colors.
    -- Todo: The official one now has three categories of search results:
    -- * Cyan: Contains at least one exact word from the search terms
    -- * Yellow: At least one word starts with at least one search term
    -- * Grey: Each search term is found in the middle of a word
    self.page_size = self.frame_rect.height - ExtraLines

    local filtered = {}
    local needles = splitstring(self.search_string, " ")
    for key, value in ipairs(reaction_list) do
        if matchall(string.upper(value.name), needles) then
            filtered[#filtered+1] = {
                index = key,
                name = value.name
            }
        end
    end

    if self.position < 1 then
        self.position = #filtered
    elseif self.position > #filtered then
        self.position = 1
    end

    local start = 1
    while self.position >= start + self.page_size*2 do
        start = start + self.page_size*2
    end

    local displayed = {}
    for n = 0, self.page_size*2 - 1 do
        local item = filtered[start + n]
        if not item then
            break
        end
        local name = item.name

        local x = 1
        local y = FirstRow + n
        if n >= self.page_size then
            x = CenterCol
            y = y - self.page_size
            name = " "..name
        end

        local color = COLOR_CYAN
        if start + n == self.position then
            color = COLOR_LIGHTCYAN
        end

        displayed[n + 1] = {
            x = x,
            y = y,
            name = name,
            color = color,
        }
    end

    self.reactions = filtered
    self.displayed = displayed
end

function clear_order(stockpile)
    local saved = saved_orders[stockpile.id]
    if saved then
        saved.entry:delete()
        saved_orders[stockpile.id] = nil
    end
end

function store_order(stockpile, order_number)
    local name = reaction_list[order_number].name
    -- print("Setting stockpile #"..stockpile.stockpile_number.." to "..name.." (#"..order_number..")")
    local saved = saved_orders[stockpile.id]
    if saved then
        saved.entry.value = name
        saved.entry.ints[entry_ints.order_number] = order_number
        saved.entry:save()
    else
        saved_orders[stockpile.id] = {
            stockpile = stockpile,
            entry = dfhack.persistent.save{
                key = "stockflow/entry/"..stockpile.id,
                value = name,
                ints = {
                    stockpile.id,
                    order_number,
                    1,
                },
            },
        }
    end
end

-- Compare the job specification of two orders.
function orders_match(a, b)
    local fields = {
        "job_type",
        "item_subtype",
        "reaction_name",
        "mat_type",
        "mat_index",
    }

    for _, fieldname in ipairs(fields) do
        if a[fieldname] ~= b[fieldname] then
            return false
        end
    end

    local subtables = {
        "item_category",
        "material_category",
    }

    for _, fieldname in ipairs(subtables) do
        local aa = a[fieldname]
        local bb = b[fieldname]
        for key, value in ipairs(aa) do
            if bb[key] ~= value then
                return false
            end
        end
    end

    return true
end

-- Reduce the quantity by the number of matching orders in the queue.
function order_quantity(order, quantity)
    local amount = quantity
    for _, managed in ipairs(df.global.world.manager_orders) do
        if orders_match(order, managed) then
            amount = amount - managed.amount_left
            if amount < 0 then
                return 0
            end
        end
    end

    return amount
end

-- Place a new copy of the order onto the manager's queue.
function create_orders(order, amount)
    local new_order = order:new()
    amount = math.floor(amount)
    new_order.amount_left = amount
    new_order.amount_total = amount
    -- Todo: Create in a validated state if the fortress is small enough?
    new_order.status.validated = false
    new_order.status.active = false
    new_order.id = df.global.world.manager_order_next_id
    df.global.world.manager_order_next_id = df.global.world.manager_order_next_id + 1
    df.global.world.manager_orders:insert('#', new_order)
end

function countContents(container, settings)
    local total = 0
    for _, item in ipairs(dfhack.items.getContainedItems(container)) do
        if item.flags.container then
            -- Recursively count the total of items contained.
            -- Not likely to go more than two levels deep.
            local subtotal = countContents(item, settings)
            if subtotal > 0 then
                -- Ignore the inner container itself;
                -- generally, only the contained items matter.
                total = total + subtotal
            elseif matches_stockpile(item, settings) then
                -- The container may or may not be empty,
                -- but is stockpiled as a container itself.
                total = total + 1
            end
        elseif matches_stockpile(item, settings) then
            total = total + 1
        end
    end

    return total
end

function check_stockpiles(verbose)
    local result = {}
    for _, spec in pairs(saved_orders) do
        local trigger = triggers[spec.entry.ints[entry_ints.trigger_number]]
        if trigger and trigger.divisor then
            local reaction = spec.entry.ints[entry_ints.order_number]
            local filled, empty = check_pile(spec.stockpile, verbose)
            local amount = math.floor((trigger.filled and filled or empty) / trigger.divisor)
            result[reaction] = (result[reaction] or 0) + amount
        end
    end

    return result
end

function check_pile(sp, verbose)
    local numspaces = 0
    local filled = 0
    local empty = 0
    for y = sp.y1, sp.y2 do
        for x = sp.x1, sp.x2 do
            if dfhack.buildings.containsTile(sp, x, y) then
                numspaces = numspaces + 1
                local designation, occupancy = dfhack.maps.getTileFlags(x, y, sp.z)
                if not designation.liquid_type then
                    if not occupancy.item then
                        empty = empty + 1
                    end
                end
            end
        end
    end

    for _, item in ipairs(dfhack.buildings.getStockpileContents(sp)) do
        if item:isAssignedToThisStockpile(sp.id) then
            -- This is a bin or barrel associated with the stockpile.
            filled = filled + countContents(item, sp.settings)
        elseif matches_stockpile(item, sp.settings) then
            filled = filled + 1
        end
    end

    if verbose then
        print("Stockpile #"..sp.stockpile_number,
            string.format("%3d spaces", numspaces),
            string.format("%4d items", filled),
            string.format("%4d empty spaces", empty))
    end

    return filled, empty
end

function matches_stockpile(item, settings)
    -- Check whether the item matches the stockpile.
    -- FIXME: This is starting to look like a whole lot of work.
    if df.item_barst:is_instance(item) then
        return settings.flags.bars_blocks
    elseif df.item_blocksst:is_instance(item) then
        return settings.flags.bars_blocks
    elseif df.item_smallgemst:is_instance(item) then
        return settings.flags.gems
    elseif df.item_boulderst:is_instance(item) then
        return settings.flags.stone
    elseif df.item_woodst:is_instance(item) then
        return settings.flags.wood
    elseif df.item_seedsst:is_instance(item) then
        return settings.flags.food
    elseif df.item_meatst:is_instance(item) then
        return settings.flags.food
    elseif df.item_plantst:is_instance(item) then
        return settings.flags.food
    elseif df.item_cheesest:is_instance(item) then
        return settings.flags.food
    elseif df.item_globst:is_instance(item) then
        return settings.flags.food
    elseif df.item_fishst:is_instance(item) then
        return settings.flags.food
    elseif df.item_fish_rawst:is_instance(item) then
        return settings.flags.food
    elseif df.item_foodst:is_instance(item) then
        return settings.flags.food
    elseif df.item_drinkst:is_instance(item) then
        return settings.flags.food
    elseif df.item_eggst:is_instance(item) then
        return settings.flags.food
    elseif df.item_skin_tannedst:is_instance(item) then
        return settings.flags.leather
    elseif df.item_remainsst:is_instance(item) then
        return settings.flags.refuse
    elseif df.item_verminst:is_instance(item) then
        return settings.flags.animals
    elseif df.item_petst:is_instance(item) then
        return settings.flags.animals
    elseif df.item_threadst:is_instance(item) then
        return settings.flags.cloth
    end

    return true
end

return _ENV
