/* Prevent channeling down into known open space.
Author:  Josh Cooper
Created: Aug. 4 2020
Updated: Dec. 8 2022
*/
/*
This skeletal logic has not been kept up-to-date since ~v0.5

 Enable plugin:
 -> build groups
 -> manage designations

 Unpause event:
 -> build groups
 -> manage designations

 Manage Designation(s):
 -> for each group in groups:
    -> does any tile in group have a group above
        -> Yes: set entire group to marker mode
        -> No: activate entire group (still checks is_safe_to_dig_down before activating each designation)

 Job started event:
 -> validate job type (channel)
 -> check pathing:
    -> Can: add job/worker to tracking
    -> Can: set tile to restricted
    -> Cannot: remove worker
    -> Cannot: insta-dig & delete job
    -> Cannot: set designation to Marker Mode (no insta-digging)

 OnUpdate:
 -> check worker location:
    -> CanFall: check if a fall would be safe:
        -> Safe: do nothing
        -> Unsafe: remove worker
        -> Unsafe: insta-dig & delete job (presumes the job is only accessible from directly on the tile)
        -> Unsafe: set designation to Marker Mode (no insta-digging)
 -> check tile occupancy:
    -> HasUnit: check if a fall would be safe:
        -> Safe: do nothing, let them fall
        -> Unsafe: remove worker for 1 tick (test if this "pauses" or cancels the job)
        -> Unsafe: Add feature to teleport unit?

 Job completed event:
 -> validate job type (channel)
 -> verify completion:
    -> IsOpenSpace: mark done
    -> IsOpenSpace: manage tile below
    -> NotOpenSpace: check for designation
        -> HasDesignation: do nothing
        -> NoDesignation: mark done (erases from group)
        -> NoDesignation: manage tile below
*/

#include <plugin.h>
#include <inlines.h>
#include <channel-manager.h>
#include <tile-cache.h>

#include <Debug.h>
#include <PluginManager.h>
#include <modules/EventManager.h>
#include <modules/Units.h>
#include <df/world.h>
#include <df/report.h>
#include <df/tile_traffic.h>
#include <df/block_square_event_designation_priorityst.h>

#include <cinttypes>
#include <unordered_map>
#include <unordered_set>

// Debugging
namespace DFHack {
    DBG_DECLARE(channelsafely, plugin, DebugCategory::LINFO);
    DBG_DECLARE(channelsafely, monitor, DebugCategory::LERROR);
    DBG_DECLARE(channelsafely, manager, DebugCategory::LERROR);
    DBG_DECLARE(channelsafely, groups, DebugCategory::LERROR);
    DBG_DECLARE(channelsafely, jobs, DebugCategory::LERROR);
}

DFHACK_PLUGIN("channel-safely");
DFHACK_PLUGIN_IS_ENABLED(enabled);
REQUIRE_GLOBAL(world);

namespace EM = EventManager;
using namespace DFHack;
using namespace EM::EventType;

int32_t mapx, mapy, mapz;
Configuration config;
PersistentDataItem psetting;
PersistentDataItem pfeature;
const std::string FCONFIG_KEY = std::string(plugin_name) + "/feature";
const std::string SCONFIG_KEY = std::string(plugin_name) + "/setting";

enum FeatureConfigData {
    VISION,
    MONITOR,
    RESURRECT,
    INSTADIG,
    RISKAVERSE
};

enum SettingConfigData {
    REFRESH_RATE,
    MONITOR_RATE,
    IGNORE_THRESH,
    FALL_THRESH
};

// dig-now.cpp
df::coord simulate_fall(const df::coord &pos) {
    if unlikely(!Maps::isValidTilePos(pos)) {
        ERR(plugin).print("Error: simulate_fall(" COORD ") - invalid coordinate\n", COORDARGS(pos));
        return {};
    }
    df::coord resting_pos(pos);

    while (Maps::ensureTileBlock(resting_pos)) {
        df::tiletype tt = *Maps::getTileType(resting_pos);
        df::tiletype_shape_basic basic_shape = tileShapeBasic(tileShape(tt));
        if (isWalkable(tt) && basic_shape != df::tiletype_shape_basic::Open)
            break;
        --resting_pos.z;
    }

    return resting_pos;
}

df::coord simulate_area_fall(const df::coord &pos) {
    df::coord neighbours[8]{};
    get_neighbours(pos, neighbours);
    df::coord lowest = simulate_fall(pos);
    for (auto p : neighbours) {
        if unlikely(!Maps::isValidTilePos(p)) continue;
        auto nlow = simulate_fall(p);
        if (nlow.z < lowest.z) {
            lowest = nlow;
        }
    }
    return lowest;
}

namespace CSP {
    std::unordered_map<df::unit*, int32_t> endangered_units;
    std::unordered_map<df::job*, int32_t> job_id_map;
    std::unordered_map<int32_t, df::job*> active_jobs;
    std::unordered_map<int32_t, df::unit*> active_workers;


    std::unordered_map<int32_t, df::coord> last_safe;
    std::unordered_set<df::coord> dignow_queue;

    void ClearData() {
        ChannelManager::Get().destroy_groups();
        dignow_queue.clear();
        last_safe.clear();
        endangered_units.clear();
        active_workers.clear();
        active_jobs.clear();
        job_id_map.clear();
    }

    void SaveSettings() {
        if (pfeature.isValid() && psetting.isValid()) {
            try {
                pfeature.ival(MONITOR) = config.monitoring;
                pfeature.ival(VISION) = config.require_vision;
                pfeature.ival(INSTADIG) = false; //config.insta_dig;
                pfeature.ival(RESURRECT) = config.resurrect;
                pfeature.ival(RISKAVERSE) = config.riskaverse;

                psetting.ival(REFRESH_RATE) = config.refresh_freq;
                psetting.ival(MONITOR_RATE) = config.monitor_freq;
                psetting.ival(IGNORE_THRESH) = config.ignore_threshold;
                psetting.ival(FALL_THRESH) = config.fall_threshold;
            } catch (std::exception &e) {
                ERR(plugin).print("%s\n", e.what());
            }
        }
    }

    void LoadSettings() {
        pfeature = World::GetPersistentData(FCONFIG_KEY);
        psetting = World::GetPersistentData(SCONFIG_KEY);

        if (!pfeature.isValid() || !psetting.isValid()) {
            pfeature = World::AddPersistentData(FCONFIG_KEY);
            psetting = World::AddPersistentData(SCONFIG_KEY);
            SaveSettings();
        } else {
            try {
                config.monitoring = pfeature.ival(MONITOR);
                config.require_vision = pfeature.ival(VISION);
                config.insta_dig = false; //pfeature.ival(INSTADIG);
                config.resurrect = pfeature.ival(RESURRECT);
                config.riskaverse = pfeature.ival(RISKAVERSE);

                config.ignore_threshold = psetting.ival(IGNORE_THRESH);
                config.fall_threshold = psetting.ival(FALL_THRESH);
                config.refresh_freq = psetting.ival(REFRESH_RATE);
                config.monitor_freq = psetting.ival(MONITOR_RATE);
            } catch (std::exception &e) {
                ERR(plugin).print("%s\n", e.what());
            }
        }
        active_workers.clear();
    }

    void UnpauseEvent(bool full_scan = false){
        CoreSuspender suspend; // we need exclusive access to df memory and this call stack doesn't already have a lock
        DEBUG(plugin).print("UnpauseEvent()\n");
        ChannelManager::Get().build_groups(full_scan);
        ChannelManager::Get().manage_groups();
        DEBUG(plugin).print("UnpauseEvent() exits\n");
    }

    void JobStartedEvent(color_ostream &out, void* j) {
        if (enabled && World::isFortressMode() && Maps::IsValid()) {
            TRACE(jobs).print("JobStartedEvent()\n");
            auto job = (df::job*) j;
            // validate job type
            if (ChannelManager::Get().exists(job->pos)) {
                DEBUG(jobs).print(" valid channel job:\n");
                df::unit* worker = Job::getWorker(job);
                // there is a valid worker (living citizen) on the job? right..
                if (worker && Units::isAlive(worker) && Units::isCitizen(worker)) {
                    ChannelManager::Get().jobs.mark_active(job->pos);
                    if (config.riskaverse) {
                        if (ChannelManager::Get().jobs.possible_cavein(job->pos)) {
                            cancel_job(job);
                            ChannelManager::Get().manage_one(job->pos, true, true);
                        } else {
                            ChannelManager::Get().manage_group(job->pos, true, false);
                        }
                    }
                    DEBUG(jobs).print("  valid worker:\n");
                    // track workers on jobs
                    df::coord &pos = job->pos;
                    TRACE(jobs).print(" -> Starting job at (" COORD ")\n", COORDARGS(pos));
                    if (config.monitoring || config.resurrect) {
                        job_id_map.emplace(job, job->id);
                        active_jobs.emplace(job->id, job);
                        active_workers[job->id] = worker;
                        if (config.resurrect) {
                            // this is the only place we can be 100% sure of "safety"
                            // (excluding deadly enemies that will have arrived)
                            last_safe[worker->id] = worker->pos;
                        }
                    }
                    // set tile to restricted
                    TRACE(jobs).print("   setting job tile to restricted\n");
                    Maps::getTileDesignation(job->pos)->bits.traffic = df::tile_traffic::Restricted;
                }
            }
            TRACE(jobs).print(" <- JobStartedEvent() exits normally\n");
        }
    }

    void JobCompletedEvent(color_ostream &out, void* j) {
        if (enabled && World::isFortressMode() && Maps::IsValid()) {
            TRACE(jobs).print("JobCompletedEvent()\n");
            auto job = (df::job*) j;
            // we only care if the job is a channeling one
            if (ChannelManager::Get().exists(job->pos)) {
                ChannelManager::Get().manage_group(job->pos, true, false);
                // check job outcome
                auto block = Maps::getTileBlock(job->pos);
                df::coord local(job->pos);
                local.x = local.x % 16;
                local.y = local.y % 16;
                // verify completion
                if (TileCache::Get().hasChanged(job->pos, block->tiletype[Coord(local)])) {
                    // the job can be considered done
                    df::coord below(job->pos);
                    below.z--;
                    DEBUG(jobs).print(" -> (" COORD ") is marked done, managing group below.\n", COORDARGS(job->pos));
                    // mark done and manage below (and the rest of the group, if there were cavein candidates)
                    block->designation[Coord(local)].bits.traffic = df::tile_traffic::Normal;
                    ChannelManager::Get().mark_done(job->pos);
                    ChannelManager::Get().manage_group(below);
                    if (config.resurrect) {
                        // this is the only place we can be 100% sure of "safety"
                        // (excluding deadly enemies that may have arrived, and future digging)
                        if (active_workers.count(job->id)) {
                            df::unit* worker = active_workers[job->id];
                            last_safe[worker->id] = worker->pos;
                        }
                    }
                }
                // clean up
                auto jp = active_jobs[job->id];
                job_id_map.erase(jp);
                active_workers.erase(job->id);
                active_jobs.erase(job->id);
            }
            TRACE(jobs).print("JobCompletedEvent() exits\n");
        }
    }

    void NewReportEvent(color_ostream &out, void* r) {
        int32_t tick = df::global::world->frame_counter;
        auto report_id = (int32_t)(intptr_t(r));
        if (df::global::world) {
            df::report* report = df::report::find(report_id);
            if (!report) {
                WARN(plugin).print("Error: NewReportEvent() received an invalid report_id - a report* cannot be found\n");
                return;
            }
            switch (report->type) {
                case announcement_type::CANCEL_JOB:
                    if (config.insta_dig) {
                        if (report->text.find("cancels Dig") != std::string::npos ||
                            report->text.find("path") != std::string::npos) {

                            dignow_queue.emplace(report->pos);
                        }
                        DEBUG(plugin).print("%d, pos: " COORD ", pos2: " COORD "\n%s\n", report_id, COORDARGS(report->pos),
                                            COORDARGS(report->pos2), report->text.c_str());
                    }
                    break;
                case announcement_type::CAVE_COLLAPSE:
                    if (config.resurrect) {
                        DEBUG(plugin).print("CAVE IN\n%d, pos: " COORD ", pos2: " COORD "\n%s\n", report_id, COORDARGS(report->pos),
                                            COORDARGS(report->pos2), report->text.c_str());

                        df::coord below = report->pos;
                        below.z -= 1;
                        below = simulate_area_fall(below);
                        df::coord areaMin{report->pos};
                        df::coord areaMax{areaMin};
                        areaMin.x -= 15;
                        areaMin.y -= 15;
                        areaMax.x += 15;
                        areaMax.y += 15;
                        areaMin.z = below.z;
                        areaMax.z += 1;
                        std::vector<df::unit*> units;
                        Units::getUnitsInBox(units, COORDARGS(areaMin), COORDARGS(areaMax));
                        for (auto unit: units) {
                            endangered_units[unit] = tick;
                            DEBUG(plugin).print(" [id %d] was near a cave in.\n", unit->id);
                        }
                        for (auto unit : world->units.all) {
                            if (last_safe.count(unit->id)) {
                                endangered_units[unit] = tick;
                                DEBUG(plugin).print(" [id %d] is/was a worker, we'll track them too.\n", unit->id);
                            }
                        }
                    }
                    break;
                default:
                    break;
            }
        }
    }

    void OnUpdate(color_ostream &out) {
        CoreSuspender suspend;
        if (enabled && World::isFortressMode() && Maps::IsValid() && !World::ReadPauseState()) {
            static int32_t last_tick = df::global::world->frame_counter;
            static int32_t last_monitor_tick = df::global::world->frame_counter;
            static int32_t last_refresh_tick = df::global::world->frame_counter;
            static int32_t last_resurrect_tick = df::global::world->frame_counter;
            int32_t tick = df::global::world->frame_counter;

            // Refreshing the group data with full scanning
            if (tick - last_refresh_tick >= config.refresh_freq) {
                last_refresh_tick = tick;
                TRACE(monitor).print("OnUpdate() refreshing now\n");
                if (config.insta_dig) {
                    TRACE(monitor).print(" -> evaluate dignow queue\n");
                    for (auto iter = dignow_queue.begin(); iter != dignow_queue.end();) {
                        auto map_pos = *iter;
                        dig_now(out, map_pos); // teleports units to the bottom of a simulated fall
                        ChannelManager::Get().mark_done(map_pos);
                        iter = dignow_queue.erase(iter);
                    }
                }
                UnpauseEvent(false);
                TRACE(monitor).print("OnUpdate() refresh done\n");
            }

            // Clean up stale df::job*
            if ((config.monitoring || config.resurrect) && tick - last_tick >= 1) {
                last_tick = tick;
                // make note of valid jobs
                std::unordered_map<int32_t, df::job*> valid_jobs;
                for (df::job_list_link* link = &df::global::world->jobs.list; link != nullptr; link = link->next) {
                    df::job* job = link->item;
                    if (job && active_jobs.count(job->id)) {
                        valid_jobs.emplace(job->id, job);
                    }
                }

                // erase the active jobs that aren't valid
                std::unordered_set<df::job*> erase;
                map_value_difference(active_jobs, valid_jobs, erase);
                for (auto j : erase) {
                    auto id = job_id_map[j];
                    job_id_map.erase(j);
                    active_jobs.erase(id);
                    active_workers.erase(id);
                }
            }

            // Monitoring Active and Resurrecting Dead
            if (config.monitoring && tick - last_monitor_tick >= config.monitor_freq) {
                last_monitor_tick = tick;
                TRACE(monitor).print("OnUpdate() monitoring now\n");

                // iterate active jobs
                for (auto pair: active_jobs) {
                    df::job* job = pair.second;
                    df::unit* unit = active_workers[job->id];
                    if (!unit) continue;
                    if (!Maps::isValidTilePos(job->pos)) continue;
                    TRACE(monitor).print(" -> check for job in tracking\n");
                    if (Units::isAlive(unit)) {
                        if (!config.monitoring) continue;
                        TRACE(monitor).print(" -> compare positions of worker and job\n");

                        // check for fall safety
                        if (unit->pos == job->pos && !is_safe_fall(job->pos)) {
                            // unsafe
                            WARN(monitor).print(" -> unsafe job\n");
                            Job::removeWorker(job);

                            // decide to insta-dig or marker mode
                            if (config.insta_dig) {
                                // delete the job
                                Job::removeJob(job);
                                // queue digging the job instantly
                                dignow_queue.emplace(job->pos);
                                DEBUG(monitor).print(" -> insta-dig\n");
                            } else if (config.resurrect) {
                                endangered_units.emplace(unit, tick);
                            } else {
                                // set marker mode
                                Maps::getTileOccupancy(job->pos)->bits.dig_marked = true;

                                // prevent algorithm from re-enabling designation
                                for (auto &be: Maps::getBlock(job->pos)->block_events) {
                                    if (auto bsedp = virtual_cast<df::block_square_event_designation_priorityst>(
                                            be)) {
                                        df::coord local(job->pos);
                                        local.x = local.x % 16;
                                        local.y = local.y % 16;
                                        bsedp->priority[Coord(local)] = config.ignore_threshold * 1000 + 1;
                                        break;
                                    }
                                }
                                DEBUG(monitor).print(" -> set marker mode\n");
                            }
                        }
                    } else if (config.resurrect) {
                        resurrect(out, unit->id);
                        if (last_safe.count(unit->id)) {
                            df::coord lowest = simulate_fall(last_safe[unit->id]);
                            Units::teleport(unit, lowest);
                        }
                    }
                }
                TRACE(monitor).print("OnUpdate() monitoring done\n");
            }

            // Resurrect Dead Workers
            if (config.resurrect && tick - last_resurrect_tick >= 1) {
                last_resurrect_tick = tick;

                // clean up any "endangered" workers that have been tracked 100 ticks or more
                for (auto iter = endangered_units.begin(); iter != endangered_units.end();) {
                    if (tick - iter->second >= 1200) { //keep watch 1 day
                        DEBUG(plugin).print("It has been one day since [id %d]'s last incident.\n", iter->first->id);
                        iter = endangered_units.erase(iter);
                        continue;
                    }
                    ++iter;
                }

                // resurrect any dead units
                for (auto pair : endangered_units) {
                    auto unit = pair.first;
                    if (!Units::isAlive(unit)) {
                        resurrect(out, unit->id);
                        if (last_safe.count(unit->id)) {
                            df::coord lowest = simulate_fall(last_safe[unit->id]);
                            Units::teleport(unit, lowest);
                        }
                    }
                }
            }
        }
    }
}

command_result channel_safely(color_ostream &out, std::vector<std::string> &parameters);

DFhackCExport command_result plugin_init(color_ostream &out, std::vector<PluginCommand> &commands) {
    commands.push_back(PluginCommand("channel-safely",
                                     "Automatically manage channel designations.",
                                     channel_safely,
                                     false));
    return CR_OK;
}

DFhackCExport command_result plugin_shutdown(color_ostream &out) {
    EM::unregisterAll(plugin_self);
    return CR_OK;
}

DFhackCExport command_result plugin_load_data (color_ostream &out) {
    CSP::LoadSettings();
    if (enabled) {
        std::vector<std::string> params;
        channel_safely(out, params);
    }
    return DFHack::CR_OK;
}

DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) {
    if (enable && !enabled) {
        // register events to check jobs / update tracking
        EM::EventHandler jobStartHandler(CSP::JobStartedEvent, 0);
        EM::EventHandler jobCompletionHandler(CSP::JobCompletedEvent, 0);
        EM::EventHandler reportHandler(CSP::NewReportEvent, 0);
        EM::registerListener(EventType::REPORT, reportHandler, plugin_self);
        EM::registerListener(EventType::JOB_STARTED, jobStartHandler, plugin_self);
        EM::registerListener(EventType::JOB_COMPLETED, jobCompletionHandler, plugin_self);
        // manage designations to start off (first time building groups [very important])
        out.print("channel-safely: enabled!\n");
        CSP::UnpauseEvent(true);
    } else if (!enable) {
        // don't need the groups if the plugin isn't going to be enabled
        EM::unregisterAll(plugin_self);
        out.print("channel-safely: disabled!\n");
    }
    enabled = enable;
    return CR_OK;
}

DFhackCExport command_result plugin_onstatechange(color_ostream &out, state_change_event event) {
    switch (event) {
        case SC_UNPAUSED:
            if (enabled && World::isFortressMode() && Maps::IsValid()) {
                // manage all designations on unpause
                CSP::UnpauseEvent(true);
            }
            break;
        case SC_MAP_LOADED:
            // cache the map size
            Maps::getSize(mapx, mapy, mapz);
            CSP::ClearData();
            ChannelManager::Get().build_groups(true);
            break;
        case SC_WORLD_UNLOADED:
        case SC_MAP_UNLOADED:
            CSP::ClearData();
            break;
        default:
            return DFHack::CR_OK;
    }
    return DFHack::CR_OK;
}

DFhackCExport command_result plugin_onupdate(color_ostream &out, state_change_event event) {
    CSP::OnUpdate(out);
    return DFHack::CR_OK;
}

command_result channel_safely(color_ostream &out, std::vector<std::string> &parameters) {
    if (!parameters.empty()) {
        if (parameters[0] == "runonce") {
            CSP::UnpauseEvent(true);
            return DFHack::CR_OK;
        } else if (parameters[0] == "rebuild") {
            ChannelManager::Get().destroy_groups();
            ChannelManager::Get().build_groups(true);
        }
        if (parameters.size() >= 2 && parameters.size() <= 3) {
            bool state = false;
            bool set = false;
            if (parameters[0] == "enable") {
                state = true;
            } else if (parameters[0] == "disable") {
                state = false;
            } else if (parameters[0] == "set") {
                set = true;
            } else {
                return DFHack::CR_WRONG_USAGE;
            }
            try {
                if(parameters[1] == "monitoring"){
                    if (state != config.monitoring) {
                        config.monitoring = state;
                        // if this is a fresh start
                        if (state && !config.resurrect) {
                            // we need a fresh start
                            CSP::active_workers.clear();
                        }
                    }
                } else if (parameters[1] == "risk-averse") {
                    config.riskaverse = state;
                } else if (parameters[1] == "require-vision") {
                    config.require_vision = state;
                } else if (parameters[1] == "insta-dig") {
                    //config.insta_dig = state;
                    config.insta_dig = false;
                } else if (parameters[1] == "resurrect") {
                    if (state != config.resurrect) {
                        config.resurrect = state;
                        // if this is a fresh start
                        if (state && !config.monitoring) {
                            // we need a fresh start
                            CSP::active_workers.clear();
                        }
                    }
                } else if (parameters[1] == "refresh-freq" && set && parameters.size() == 3) {
                    config.refresh_freq = std::abs(std::stol(parameters[2]));
                } else if (parameters[1] == "monitor-freq" && set && parameters.size() == 3) {
                    config.monitor_freq = std::abs(std::stol(parameters[2]));
                } else if (parameters[1] == "ignore-threshold" && set && parameters.size() == 3) {
                    config.ignore_threshold = std::abs(std::stol(parameters[2]));
                } else if (parameters[1] == "fall-threshold" && set && parameters.size() == 3) {
                    uint8_t t = std::abs(std::stol(parameters[2]));
                    if (t > 0) {
                        config.fall_threshold = t;
                    } else {
                        out.printerr("fall-threshold must have a value greater than 0 or the plugin does a lot of nothing.\n");
                        return DFHack::CR_FAILURE;
                    }
                } else {
                    return DFHack::CR_WRONG_USAGE;
                }
            } catch (const std::exception &e) {
                out.printerr("%s\n", e.what());
                return DFHack::CR_FAILURE;
            }
        }
    } else {
        out.print("Channel-Safely is %s\n", enabled ? "ENABLED." : "DISABLED.");
        out.print(" FEATURES:\n");
        out.print("  %-20s\t%s\n", "risk-averse: ", config.riskaverse ? "on." : "off.");
        out.print("  %-20s\t%s\n", "monitoring: ", config.monitoring ? "on." : "off.");
        out.print("  %-20s\t%s\n", "require-vision: ", config.require_vision ? "on." : "off.");
        //out.print("  %-20s\t%s\n", "insta-dig: ", config.insta_dig ? "on." : "off.");
        out.print("  %-20s\t%s\n", "resurrect: ", config.resurrect ? "on." : "off.");
        out.print(" SETTINGS:\n");
        out.print("  %-20s\t%" PRIi32 "\n", "refresh-freq: ", config.refresh_freq);
        out.print("  %-20s\t%" PRIi32 "\n", "monitor-freq: ", config.monitor_freq);
        out.print("  %-20s\t%" PRIu8 "\n", "ignore-threshold: ", config.ignore_threshold);
        out.print("  %-20s\t%" PRIu8 "\n", "fall-threshold: ", config.fall_threshold);
    }
    CSP::SaveSettings();
    return DFHack::CR_OK;
}