/*
 * SPDX-FileName: ErrorReporter.cxx
 * SPDX-FileComment: This file is part of the program FlightGear
 * SPDX-FileCopyrightText: 2021 James Turner
 * SPDX-License-Identifier: GPL-2.0-or-later
 */

#include "config.h"
#include "simgear/debug/debug_types.h"

#include "ErrorReporter.hxx"

#include <algorithm>
#include <ctime> // for strftime, etc
#include <deque>
#include <map>
#include <mutex>
#include <string>

#include <simgear/debug/ErrorReportingCallback.hxx>
#include <simgear/debug/LogCallback.hxx>
#include <simgear/timing/timestamp.hxx>

#include <simgear/io/iostreams/sgstream.hxx>
#include <simgear/structure/commands.hxx>

#include <GUI/MessageBox.hxx>
#include <GUI/dialog.hxx>
#include <GUI/new_gui.hxx>

#include <Main/fg_props.hxx>
#include <Main/globals.hxx>
#include <Main/locale.hxx>
#include <Main/options.hxx>
#include <Main/sentryIntegration.hxx>
#include <Model/validateSharedModels.hxx>
#include <Scripting/NasalClipboard.hxx> // clipboard access

using simgear::LoadFailure;
using std::string;

namespace {

const double MinimumIntervalBetweenDialogs = 5.0;
const double NoNewErrorsTimeout = 8.0;

// map of context values; we allow a stack of values for
// cases such as sub-sub-model loading where we might process repeated
// nested model XML files
using PerThreadErrorContextStack = std::map<std::string, string_list>;

// context storage. This is per-thread so parallel osgDB threads don't
// confuse each other
static thread_local PerThreadErrorContextStack thread_errorContextStack;
/**
 Define the aggregation points we use for errors, to direct the user towards the likely
 source of problems.
 */
enum class Aggregation {
    MainAircraft,
    HangarAircraft, // handle hangar aircraft differently
    CustomScenery,
    TerraSync,
    AddOn,
    Scenario,
    InputDevice,
    FGData,
    MultiPlayer,
    Unknown,      ///< error coudln't be attributed more specifcially
    OutOfMemory,  ///< separate category to give it a custom message
    SharedModels, ///< special category, for when shared models are not found
    Traffic,
    ShadersEffects,
    NetworkFailure, ///< category for issues related to (local) connectivity
};

// these should correspond to simgear::ErrorCode enum
// they map to translateable strings in fgdata/Translations/sys.xml

string_list static_errorIds = {
    "error-missing-shader",
    "error-loading-texture",
    "error-xml-model-load",
    "error-3D-model-load",
    "error-btg-load",
    "error-scenario-load",
    "error-dialog-load",
    "error-audio-fx-load",
    "error-xml-load-command",
    "error-aircraft-systems",
    "error-input-device-config",
    "error-ai-traffic-schedule",
    "error-terrasync"};

string_list static_errorTypeIds = {
    "error-type-unknown",
    "error-type-not-found",
    "error-type-out-of-memory",
    "error-type-bad-header",
    "error-type-bad-data",
    "error-type-misconfigured",
    "error-type-io",
    "error-type-network"};


string_list static_categoryIds = {
    "error-category-aircraft",
    "error-category-aircraft-from-hangar",
    "error-category-custom-scenery",
    "error-category-terrasync",
    "error-category-addon",
    "error-category-scenario",
    "error-category-input-device",
    "error-category-fgdata",
    "error-category-multiplayer",
    "error-category-unknown",
    "error-category-out-of-memory",
    "error-category-shared-models",
    "error-category-traffic",
    "error-category-shaders",
    "error-category-network"};

class RecentLogCallback : public simgear::LogCallback
{
public:
    RecentLogCallback() : simgear::LogCallback("errors-recent-log")
    {
        simgear::LogLevels l;
        l.set(SG_ALL, SG_INFO);
        setLogLevels(l);
    }

    bool doProcessEntry(const simgear::LogEntry& e) override
    {
        if (!shouldLog(e.debugClass, e.debugPriority)) {
            return false;
        }

        std::ostringstream os;
        if (e.file != nullptr) {
            os << e.file << ":" << e.line << ":\t";
        }

        os << e.message;

        std::lock_guard<std::mutex> g(_lock); // begin access to shared state
        _recentLogEntries.push_back(os.str());

        while (_recentLogEntries.size() > _preceedingLogMessageCount) {
            _recentLogEntries.pop_front();
        }

        return true;
    }

    string_list copyRecentLogEntries() const
    {
        std::lock_guard<std::mutex> g(_lock); // begin access to shared state

        string_list r(_recentLogEntries.begin(), _recentLogEntries.end());
        return r;
    }

private:
    mutable std::mutex _lock;
    size_t _preceedingLogMessageCount = 6;

    using LogDeque = std::deque<string>;
    LogDeque _recentLogEntries;
};

std::string lastPathComponent(const std::string& d)
{
    const auto lastSlash = d.rfind('/');
    return d.substr(lastSlash + 1);
}

} // namespace

namespace flightgear {

class ErrorReporter::ErrorReporterPrivate
{
public:
    bool _reportsDirty = false;
    std::mutex _lock;
    SGTimeStamp _nextShowTimeout;
    bool _haveDonePostInit = false;
    bool _isEnabled = true;

    SGPropertyNode_ptr _enabledNode;
    SGPropertyNode_ptr _displayNode;
    SGPropertyNode_ptr _activeErrorNode;
    SGPropertyNode_ptr _mpReportNode;

    using ErrorContext = std::map<std::string, std::string>;
    /**
    @brief structure representing a single error which has occurred
     */
    struct ErrorOcurrence {
        simgear::ErrorCode code;
        simgear::LoadFailure type;
        string detailedInfo;
        sg_location origin;
        time_t when;
        string_list log;
        ErrorContext context;

        bool hasContextKey(const std::string& key) const
        {
            return context.find(key) != context.end();
        }

        std::string getContextValue(const std::string& key) const
        {
            auto it = context.find(key);
            if (it == context.end())
                return {};

            return it->second;
        }
    };

    using OccurrenceVec = std::vector<ErrorOcurrence>;

    std::unique_ptr<RecentLogCallback> _logCallback;
    bool _logCallbackRegistered = false;

    string _terrasyncPathPrefix;
    string _fgdataPathPrefix;
    string _aircraftDirectoryName;

    /**
        @brief heuristic to identify relative paths as origination from the main aircraft as opposed
                to something else. This is based on containing the aircraft directory name.
     */
    bool isMainAircraftPath(const std::string& path) const;

    bool isAnyAircraftPath(const std::string& path) const;

    static bool isFGDataPath(const std::string& path);

    /**
    @brief structure representing one or more errors, aggregated together

    This is what we send to Sentry, or present to the user, after combining occurrences from
    the same / related sources, or duplicates.
     */
    struct AggregateReport {
        Aggregation type;
        std::string parameter; ///< base on type, the specific point. For example the add-on ID, AI model ident or custom scenery path
        SGTimeStamp lastErrorTime;

        bool haveShownToUser = false;
        OccurrenceVec errors;
        bool isCritical = false;
        bool isMessage = false;

        bool addOccurence(const ErrorOcurrence& err);

        using OcurrencePredicate = std::function<bool(const ErrorOcurrence&)>;
        bool allOccurences(OcurrencePredicate pred) const;
    };


    using AggregateErrors = std::vector<AggregateReport>;
    AggregateErrors _aggregated;
    int _activeReportIndex = -1;
    string_list _significantProperties; ///< properties we want to include in reports, for debugging

    /**
    find the appropriate agrgegate for an error, based on its context
     */
    AggregateErrors::iterator getAggregateForOccurence(const ErrorOcurrence& oc);

    AggregateErrors::iterator getAggregate(Aggregation ag, const std::string& param = {});

    AggregateErrors::iterator mainAircraftAggregate();


    void collectError(simgear::LoadFailure type, simgear::ErrorCode code, const std::string& details, const sg_location& location)
    {
        ErrorOcurrence occurrence{code, type, details, location, time(nullptr), string_list(), ErrorContext()};

        // snapshot the top of the context stacks into our occurrence data
        for (const auto& c : thread_errorContextStack) {
            occurrence.context[c.first] = c.second.back();
        }

        occurrence.log = _logCallback->copyRecentLogEntries();

        std::lock_guard<std::mutex> g(_lock); // begin access to shared state
        auto it = getAggregateForOccurence(occurrence);

        // add to the occurrence, if it's not a duplicate
        if (!it->addOccurence(occurrence)) {
            return; // duplicate, nothing else to do
        }

        // log it once we know it's not a duplicate
        SG_LOG(SG_GENERAL, SG_ALERT, "Error:" << static_errorTypeIds.at(static_cast<int>(type)) << " from " << static_errorIds.at(static_cast<int>(code)) << "::" << details << "\n\t" << location.asString());

        it->lastErrorTime.stamp();
        _reportsDirty = true;

        const auto ty = it->type;
        // decide if it's a critical error or not
        // aircraft errors are critical if they occur during initial
        // aircraft load, otherwise we just show the warning
        if (!_haveDonePostInit && (ty == Aggregation::MainAircraft)) {
            it->isCritical = true;
        }
    }

    void collectContext(const std::string& key, const std::string& value)
    {
        if (value == "POP") {
            auto it = thread_errorContextStack.find(key);
            assert(it != thread_errorContextStack.end());
            assert(!it->second.empty());
            it->second.pop_back();
            if (it->second.empty()) {
                thread_errorContextStack.erase(it);
            }
        } else {
            thread_errorContextStack[key].push_back(value);
        }
    }

    void presentErrorToUser(AggregateReport& report)
    {
        const int catId = static_cast<int>(report.type);
        auto catLabel = globals->get_locale()->getLocalizedString(static_categoryIds.at(catId), "sys");

        catLabel = simgear::strutils::replace(catLabel, "%VALUE%", report.parameter);

        _displayNode->setStringValue("category", catLabel);


        auto ns = globals->get_locale()->getLocalizedString("error-next-steps", "sys");
        _displayNode->setStringValue("next-steps", ns);


        // remove any existing error children
        _displayNode->removeChildren("error");

        std::ostringstream detailsTextStream;

        // add all the discrete errors as child nodes with all their information
        for (const auto& e : report.errors) {
            SGPropertyNode_ptr errNode = _displayNode->addChild("error");
            const auto em = globals->get_locale()->getLocalizedString(static_errorIds.at(static_cast<int>(e.code)), "sys");
            errNode->setStringValue("message", em);
            errNode->setIntValue("code", static_cast<int>(e.code));

            const auto et = globals->get_locale()->getLocalizedString(static_errorTypeIds.at(static_cast<int>(e.type)), "sys");
            errNode->setStringValue("type-message", et);
            errNode->setIntValue("type", static_cast<int>(e.type));
            errNode->setStringValue("details", e.detailedInfo);

            detailsTextStream << em << ": " << et << "\n";
            detailsTextStream << "(" << e.detailedInfo << ")\n";

            if (e.origin.isValid()) {
                errNode->setStringValue("location", e.origin.asString());
                detailsTextStream << "  from:" << e.origin.asString() << "\n";
            }

            detailsTextStream << "\n";
        } // of errors within the report iteration

        _displayNode->setStringValue("details-text", detailsTextStream.str());
        _activeErrorNode->setBoolValue(true);

        report.haveShownToUser = true;

        // compute index; slightly clunky, find the report in _aggregated
        auto it = std::find_if(_aggregated.begin(), _aggregated.end(), [report](const AggregateReport& a) {
            if (a.type != report.type) return false;
            return report.parameter.empty() ? true : report.parameter == a.parameter;
        });
        assert(it != _aggregated.end());
        _activeReportIndex = static_cast<int>(std::distance(_aggregated.begin(), it));
        _displayNode->setIntValue("index", _activeReportIndex);
        _displayNode->setBoolValue("have-next", _activeReportIndex < (int)_aggregated.size() - 1);
        _displayNode->setBoolValue("have-previous", _activeReportIndex > 0);
    }

    void sendReportToSentry(AggregateReport& report)
    {
        if (report.type == Aggregation::InputDevice) {
            // for non-official input configs, don't report, it's just noise
            // https://gitlab.com/flightgear/flightgear/-/issues/3177
            if (report.allOccurences([](const ErrorOcurrence& oc) {
                    return !isFGDataPath(oc.origin.getPath());
                })) {
                return;
            }
        }

        // don't report socket or local IO issues to Sentry
        // https://gitlab.com/flightgear/fgmeta/-/issues/33
        if (report.type == Aggregation::NetworkFailure) {
            return;
        }


        if (report.type == Aggregation::TerraSync) {
            if (report.allOccurences([](const ErrorOcurrence& oc) {
                    return (oc.type == LoadFailure::IOError) || (oc.type == LoadFailure::NetworkError);
                })) {
                return;
            }
        }

        std::vector<SentryExceptionData> occurrences;
        for (const auto& e : report.errors) {
            const auto m = static_errorIds.at(static_cast<int>(e.code)) + "-" + static_errorTypeIds.at(static_cast<int>(e.type));
            occurrences.emplace_back(m, e.detailedInfo, e.origin.asString());
        }

        const int catId = static_cast<int>(report.type);
        flightgear::sentryReportUserError(static_categoryIds.at(catId),
                                          report.parameter,
                                          occurrences);
    }

    void writeReportToStream(const AggregateReport& report, std::ostream& os) const;
    void writeContextToStream(const ErrorOcurrence& error, std::ostream& os) const;
    void writeLogToStream(const ErrorOcurrence& error, std::ostream& os) const;
    void writeSignificantPropertiesToStream(std::ostream& os) const;

    bool dismissReportCommand(const SGPropertyNode* args, SGPropertyNode*);
    bool saveReportCommand(const SGPropertyNode* args, SGPropertyNode*);
    bool showErrorReportCommand(const SGPropertyNode* args, SGPropertyNode*);

    /**
     * @brief check if the report is still adding new errors, we avoid showing the UI
     * until all the errors are collected/
     */
    bool reportIsOngoing(const AggregateReport& report) const;
};

auto ErrorReporter::ErrorReporterPrivate::mainAircraftAggregate()
    -> AggregateErrors::iterator
{
    const auto fullId = fgGetString("/sim/aircraft-id");

    // we use the dir name so we combine reports from different variants, on Sentry
    const auto aircraftDirName = lastPathComponent(fgGetString("/sim/aircraft-dir"));

    if (fullId != fgGetString("/sim/aircraft")) {
        return getAggregate(Aggregation::MainAircraft, aircraftDirName);
    }

    return getAggregate(Aggregation::HangarAircraft, aircraftDirName);
}

auto ErrorReporter::ErrorReporterPrivate::getAggregateForOccurence(const ErrorReporter::ErrorReporterPrivate::ErrorOcurrence& oc)
    -> AggregateErrors::iterator
{
    // all OOM errors go to a dedicated category. This is so we don't blame
    // out of memory on the aircraft/scenery/etc, when it's not the underlying
    // cause.
    if (oc.type == simgear::LoadFailure::OutOfMemory) {
        return getAggregate(Aggregation::OutOfMemory, {});
    }

    // errors about shared models typically indicate that the shared Models are
    // missing or out of date.
    if ((oc.type == simgear::LoadFailure::NotFound) || (oc.type == simgear::LoadFailure::BadData)) {
        if (oc.detailedInfo.find("OBJECT_SHARED") != std::string::npos) {
            return getAggregate(Aggregation::SharedModels);
        }
    }

    if (oc.hasContextKey("primary-aircraft")) {
        return mainAircraftAggregate();
    }

    if (oc.hasContextKey("multiplayer")) {
        return getAggregate(Aggregation::MultiPlayer, {});
    }

    // traffic cases: need to handle errors in the traffic files (schedule, rwyuse)
    // but also errors loading aircraft models associated with traffic
    if (oc.code == simgear::ErrorCode::AITrafficSchedule) {
        return getAggregate(Aggregation::Traffic, {});
    }

    if (oc.hasContextKey("traffic-aircraft-callsign")) {
        return getAggregate(Aggregation::Traffic, {});
    }

    // all TerraSync coded errors go there: this is errors for the
    // actual download process (eg, failed to write to disk)
    if (oc.code == simgear::ErrorCode::TerraSync) {
        const auto details = oc.detailedInfo;
        if ((details.find("socket error") != std::string::npos) ||
            (details.find("SSL connect error") != std::string::npos) ||
            (details.find("Couldn't resolve host name") != std::string::npos)) {
            return getAggregate(Aggregation::NetworkFailure, {});
        }

        if (oc.type == simgear::LoadFailure::NetworkError) {
            return getAggregate(Aggregation::NetworkFailure, {});
        }

        return getAggregate(Aggregation::TerraSync, {});
    }

    if (oc.hasContextKey("terrain-stg") || oc.hasContextKey("btg")) {
        // determine if it's custom scenery, TerraSync or FGData

        // bucket is no use here, we need to check the BTG/XML/STG path etc.
        // STG is probably the best bet. This ensures if a custom scenery
        // STG references a model, XML or texture in FGData or TerraSync
        // incorrectly, we attribute the error to the scenery, which is
        // likely what we want/expect
        auto path = oc.hasContextKey("terrain-stg") ? oc.getContextValue("terrain-stg") : oc.getContextValue("btg");

        // try generic paths
        if (simgear::strutils::starts_with(path, _fgdataPathPrefix)) {
            return getAggregate(Aggregation::FGData, {});
        } else if (simgear::strutils::starts_with(path, _terrasyncPathPrefix)) {
            return getAggregate(Aggregation::TerraSync, {});
        }

        // custom scenery, find out the prefix. Order matters here because
        // TerraSync path is added to FG scenery list, so we check those first
        for (const auto& sceneryPath : globals->get_fg_scenery()) {
            const auto pathStr = sceneryPath.utf8Str();
            if (simgear::strutils::starts_with(path, pathStr)) {
                return getAggregate(Aggregation::CustomScenery, pathStr);
            }
        }

        // shouldn't ever happen
        return getAggregate(Aggregation::CustomScenery, {});
    }

    // assume only FGData can define materials, for now.
    if (oc.hasContextKey("materials")) {
        return getAggregate(Aggregation::FGData);
    }

    if (oc.hasContextKey("scenario-path")) {
        const auto scenarioPath = oc.getContextValue("scenario-path");
        return getAggregate(Aggregation::Scenario, scenarioPath);
    }

    if (oc.hasContextKey("input-device")) {
        return getAggregate(Aggregation::InputDevice, oc.getContextValue("input-device"));
    }

    if (oc.code == simgear::ErrorCode::AircraftSystems) {
        if (isMainAircraftPath(oc.origin.asString())) {
            return mainAircraftAggregate();
        }

        if (isFGDataPath(oc.origin.asString())) {
            return getAggregate(Aggregation::FGData);
        }
    }

    // start guessing :)
    // from this point on we're using less reliable inferences about where the
    // error came from, trying to avoid 'unknown'
    if (isMainAircraftPath(oc.origin.asString())) {
        return mainAircraftAggregate();
    }

    // GUI dialog errors often have no context
    if (oc.code == simgear::ErrorCode::GUIDialog) {
        // check if it's an aircraft dialog
        if (isMainAircraftPath(oc.origin.asString())) {
            const auto aircraftDirName = lastPathComponent(fgGetString("/sim/aircraft-dir"));
            return getAggregate(Aggregation::MainAircraft, aircraftDirName);
        }

        // check if it's an add-on and use that
        return getAggregate(Aggregation::FGData);
    }

    // because we report shader errors from the main thread, they don't
    // get attributed. Collect them into their own category, which also
    // means we can display a more specific message
    if (oc.code == simgear::ErrorCode::LoadEffectsShaders) {
        // we use the effect name to split shader errors
        return getAggregate(Aggregation::ShadersEffects, oc.getContextValue("effect"));
    }

    // if we've got this far, and the path looks like an aircraft path, assume it was a cross-aircraft
    // error, and report as such
    // see https://gitlab.com/flightgear/flightgear/-/issues/3008
    if (isAnyAircraftPath(oc.origin.asString())) {
        const auto aircraftDirName = lastPathComponent(fgGetString("/sim/aircraft-dir"));
        return getAggregate(Aggregation::MainAircraft, aircraftDirName);
    }

    return getAggregate(Aggregation::Unknown);
}

auto ErrorReporter::ErrorReporterPrivate::getAggregate(Aggregation ag, const std::string& param)
    -> AggregateErrors::iterator
{
    auto it = std::find_if(_aggregated.begin(), _aggregated.end(), [ag, &param](const AggregateReport& a) {
        if (a.type != ag) return false;
        return param.empty() ? true : param == a.parameter;
    });

    if (it == _aggregated.end()) {
        AggregateReport r;
        r.type = ag;
        r.parameter = param;
        _aggregated.push_back(r);
        it = _aggregated.end() - 1;
    }

    switch (ag) {
    case Aggregation::OutOfMemory:
    case Aggregation::NetworkFailure:
        it->isMessage = true;
    default:
        break;
    }

    return it;
}

void ErrorReporter::ErrorReporterPrivate::writeReportToStream(const AggregateReport& report, std::ostream& os) const
{
    os << "FlightGear " << VERSION << " error report, created at ";
    {
        char buf[64];
        time_t now = time(nullptr);
        strftime(buf, sizeof(buf), "%a, %d %b %Y %H:%M:%S GMT", gmtime(&now));
        os << buf << std::endl;
    }

    os << "Category:" << static_categoryIds.at(static_cast<int>(report.type)) << std::endl;
    if (!report.parameter.empty()) {
        os << "\tParameter:" << report.parameter << std::endl;
    }

    os << std::endl; // insert a blank line after header data

    int index = 1;
    char whenBuf[64];

    for (const auto& err : report.errors) {
        os << "Error " << index++ << std::endl;
        os << "\tcode:" << static_errorIds.at(static_cast<int>(err.code)) << std::endl;
        os << "\ttype:" << static_errorTypeIds.at(static_cast<int>(err.type)) << std::endl;

        strftime(whenBuf, sizeof(whenBuf), "%H:%M:%S GMT", gmtime(&err.when));
        os << "\twhen:" << whenBuf << std::endl;

        os << "\t" << err.detailedInfo << std::endl;
        os << "\tlocation:" << err.origin.asString() << std::endl;
        writeContextToStream(err, os);
        writeLogToStream(err, os);
        os << std::endl; // trailing blank line
    }

    os << "Command line / launcher / fgfsrc options" << std::endl;
    for (auto o : Options::sharedInstance()->extractOptions()) {
        os << "\t" << o << "\n";
    }
    os << std::endl;

    writeSignificantPropertiesToStream(os);
}

void ErrorReporter::ErrorReporterPrivate::writeSignificantPropertiesToStream(std::ostream& os) const
{
    os << "Properties:" << std::endl;
    for (const auto& ps : _significantProperties) {
        auto node = fgGetNode(ps);
        if (!node) {
            os << "\t" << ps << ": not defined\n";
        } else {
            os << "\t" << ps << ": " << node->getStringValue() << "\n";
        }
    }
    os << std::endl;
}


bool ErrorReporter::ErrorReporterPrivate::dismissReportCommand(const SGPropertyNode* args, SGPropertyNode*)
{
    std::lock_guard<std::mutex> g(_lock);
    _activeErrorNode->setBoolValue(false);

    if (args->getBoolValue("dont-show")) {
        // TODO implement dont-show behaviour
    }

    // clear any values underneath displayNode?

    _nextShowTimeout.stamp();
    _reportsDirty = true; // set this so we check for another report to present
    _activeReportIndex = -1;

    return true;
}

bool ErrorReporter::ErrorReporterPrivate::showErrorReportCommand(const SGPropertyNode* args, SGPropertyNode*)
{
    auto gui = globals->get_subsystem<NewGUI>();

    std::lock_guard<std::mutex> g(_lock);
    if (_aggregated.empty()) {
        _displayNode->setStringValue("category", "No errors to report");
        _displayNode->setIntValue("index", -1);
        _displayNode->setBoolValue("have-next", false);
        _displayNode->setBoolValue("have-previous", false);

        if (!gui->getDialog("error-report")) {
            gui->showDialog("error-report");
        }
        return true;
    }

    const auto numAggregates = static_cast<int>(_aggregated.size());
    if (args->getBoolValue("next")) {
        _activeReportIndex++;
        if (_activeReportIndex >= numAggregates) {
            return false;
        }
    } else if (args->getBoolValue("previous")) {
        if (_activeReportIndex < 1) {
            return false;
        }
        _activeReportIndex--;
    } else if (args->hasChild("index")) {
        _activeReportIndex = args->getIntValue("index");
        if ((_activeReportIndex < 0) || (_activeReportIndex >= numAggregates)) {
            return false;
        }
    } else {
        _activeReportIndex = 0;
    }

    auto& report = _aggregated.at(_activeReportIndex);
    presentErrorToUser(report);

    if (!gui->getDialog("error-report")) {
        gui->showDialog("error-report");
    }
    return true;
}

bool ErrorReporter::ErrorReporterPrivate::saveReportCommand(const SGPropertyNode* args, SGPropertyNode*)
{
    if (_activeReportIndex < 0) {
        return false;
    }

    const auto& report = _aggregated.at(_activeReportIndex);

    const string where = args->getStringValue("where");

    string when;
    {
        char buf[64];
        time_t now = time(nullptr);
        strftime(buf, sizeof(buf), "%Y%m%d", gmtime(&now));
        when = buf;
    }

    if (where.empty() || (where == "!desktop")) {
        SGPath p = SGPath::desktop() / ("flightgear_error_" + when + ".txt");
        int uniqueCount = 2;
        while (p.exists()) {
            p = SGPath::desktop() / ("flightgear_error_" + when + "_" + std::to_string(uniqueCount++) + ".txt");
        }

        sg_ofstream f(p, std::ios_base::out);
        writeReportToStream(report, f);
    } else if (where == "!clipboard") {
        std::ostringstream os;
        writeReportToStream(report, os);
        NasalClipboard::getInstance()->setText(os.str());
    }

    return true;
}


void ErrorReporter::ErrorReporterPrivate::writeContextToStream(const ErrorOcurrence& error, std::ostream& os) const
{
    os << "\tcontext:\n";
    for (const auto& c : error.context) {
        os << "\t\t" << c.first << " = " << c.second << "\n";
    }
}

void ErrorReporter::ErrorReporterPrivate::writeLogToStream(const ErrorOcurrence& error, std::ostream& os) const
{
    os << "\tpreceeding log:\n";
    for (const auto& c : error.log) {
        os << "\t\t" << c << "\n";
    }
}

bool ErrorReporter::ErrorReporterPrivate::AggregateReport::addOccurence(const ErrorOcurrence& err)
{
    auto it = std::find_if(errors.begin(), errors.end(), [err](const ErrorOcurrence& ext) {
        // check if the two occurrences match for the purposes of
        // de-duplication.
        return (ext.code == err.code) &&
               (ext.type == err.type) &&
               (ext.detailedInfo == err.detailedInfo) &&
               (ext.origin.asString() == err.origin.asString());
    });

    if (it != errors.end()) {
        return false; // duplicate, don't add
    }

    errors.push_back(err);
    lastErrorTime.stamp();
    return true;
}

bool ErrorReporter::ErrorReporterPrivate::isFGDataPath(const std::string& path)
{
    for (const auto& dp : globals->get_data_paths()) {
        if (path.find(dp.utf8Str()) == 0) {
            return true;
        }
    }

    return false;
}

bool ErrorReporter::ErrorReporterPrivate::AggregateReport::allOccurences(OcurrencePredicate p) const
{
    auto it = std::find_if(errors.begin(), errors.end(), [p](const ErrorOcurrence& ext) {
        return !p(ext);
    });

    return it == errors.end();
}

bool ErrorReporter::ErrorReporterPrivate::isMainAircraftPath(const std::string& path) const
{
    const auto pos = path.find(_aircraftDirectoryName);
    return pos != std::string::npos;
}

/**
 * @brief helper to determine if a file looks like it belongs to an aircraft.
 *
 * This is used to detect potential cross-aircraft packaging bugs which often lead to
 * 'not found' error reports. So we're looking for paths which look plausibly like any
 * aircraft path, even though @ref isMainAircraftPath didn't match on them.
 */
bool ErrorReporter::ErrorReporterPrivate::isAnyAircraftPath(const std::string& path) const
{
    const auto pos = path.find("Aircraft/");
    if (pos == 0) {
        // relative path which starts with "Aircraft/"
        return true;
    }

    // TODO: decide if we also include paths which contain 'Aircraft' as a component
    return false;
}

bool ErrorReporter::ErrorReporterPrivate::reportIsOngoing(const AggregateReport& report) const
{
    // don't wait for these to end, since they might continue forever
    if ((report.type == Aggregation::NetworkFailure) || (report.type == Aggregation::OutOfMemory)) {
        return false;
    }

    SGTimeStamp n = SGTimeStamp::now();
    const auto ageSec = (n - report.lastErrorTime).toSecs();
    return (ageSec < NoNewErrorsTimeout);
}


////////////////////////////////////////////

ErrorReporter::ErrorReporter() : d(new ErrorReporterPrivate)
{
    d->_logCallback.reset(new RecentLogCallback);

    // define significant properties
    d->_significantProperties = {
        "/sim/aircraft-id",
        "/sim/aircraft-dir",

        "/sim/rendering/gl-info/gl-vendor",
        "/sim/rendering/gl-info/gl-renderer",
        "/sim/rendering/gl-info/gl-version",
        "/sim/rendering/gl-info/gl-shading-language-version",
        "/sim/rendering/gl-info/gl-max-texture-size",
        "/sim/rendering/gl-info/gl-max-texture-units",

        "/sim/rendering/preset-description",
        "/sim/rendering/photoscenery/enabled",
        "/sim/rendering/hdr/compute",

        "/sim/rendering/max-paged-lod",
        "/sim/rendering/multithreading-mode"};
}

ErrorReporter::~ErrorReporter()
{
    // if we are deleted withut being shutdown(), ensure we clean
    // up our logging callback
    if (d->_logCallbackRegistered) {
        sglog().removeCallback(d->_logCallback.get());
    }
}

void ErrorReporter::bind()
{
    SGPropertyNode_ptr n = fgGetNode("/sim/error-report", true);

    d->_enabledNode = n->getNode("enabled", true);
    if (!d->_enabledNode->hasValue()) {
        d->_enabledNode->setBoolValue(false); // default to off for now
    }

    d->_displayNode = n->getNode("display", true);
    d->_activeErrorNode = n->getNode("active", true);
    d->_mpReportNode = n->getNode("mp-report-enabled", true);
}

void ErrorReporter::unbind()
{
    d->_enabledNode.clear();
    d->_displayNode.clear();
    d->_activeErrorNode.clear();
}

void ErrorReporter::preinit()
{
    ErrorReporterPrivate* p = d.get();
    simgear::setFailureCallback([p](simgear::LoadFailure type, simgear::ErrorCode code, const std::string& details, const sg_location& location) {
        p->collectError(type, code, details, location);
    });

    simgear::setErrorContextCallback([p](const std::string& key, const std::string& value) {
        p->collectContext(key, value);
    });

    sglog().addCallback(d->_logCallback.get());
    d->_logCallbackRegistered = true;

    // cache these values here
    d->_fgdataPathPrefix = globals->get_fg_root().utf8Str();
    d->_terrasyncPathPrefix = globals->get_terrasync_dir().utf8Str();

    const auto aircraftPath = SGPath::fromUtf8(fgGetString("/sim/aircraft-dir"));
    d->_aircraftDirectoryName = aircraftPath.file();
}

void ErrorReporter::init()
{
    // we want to disable errors in developer mode, but since self-compiled
    // builds default to developer-mode=true, need an override so people
    // can see errors if they want
    const auto developerMode = fgGetBool("sim/developer-mode");
    const auto disableInDeveloperMode = !d->_enabledNode->getParent()->getBoolValue("enable-in-developer-mode");
    const auto dd = developerMode && disableInDeveloperMode;

    if (dd || !d->_enabledNode->getBoolValue()) {
        d->_isEnabled = false;
    } else {
        d->_isEnabled = true;
    }

    // most work is done in preinit(), because errors can occur early in startup, before
    // subsystems are being initialized
    globals->get_commands()->addCommand("dismiss-error-report", d.get(), &ErrorReporterPrivate::dismissReportCommand);
    globals->get_commands()->addCommand("save-error-report-data", d.get(), &ErrorReporterPrivate::saveReportCommand);
    globals->get_commands()->addCommand("show-error-report", d.get(), &ErrorReporterPrivate::showErrorReportCommand);
}

void ErrorReporter::update(double dt)
{
    bool showDialog = false;
    bool showPopup = false;
    bool havePendingReports = false;
    bool showMessage = false;

    // beginning of locked section
    {
        std::lock_guard<std::mutex> g(d->_lock);
        // we are into the update phase (postinit has occurred). We treat errors
        // after this point with lower severity, to avoid popups during flight
        d->_haveDonePostInit = true;

        SGTimeStamp n = SGTimeStamp::now();

        // ensure we delay between successive error dialogs
        const auto timeSinceLastDialog = (n - d->_nextShowTimeout).toSecs();
        if (timeSinceLastDialog < MinimumIntervalBetweenDialogs) {
            return;
        }

        if (!d->_reportsDirty) {
            return;
        }

        if (d->_activeReportIndex >= 0) {
            return; // already showing a report
        }

        // check if any reports are due

        // check if an error is current active
        for (auto& report : d->_aggregated) {
            if (report.type == Aggregation::MultiPlayer) {
                if (!d->_mpReportNode->getBoolValue()) {
                    // mark it as shown, to suppress it
                    report.haveShownToUser = true;
                }
            }

            // if we have shown the special 'missing shared models' warning,
            // don't also report it here.
            if (report.type == Aggregation::SharedModels) {
                if (flightgear::haveShownSharedModelsError()) {
                    report.haveShownToUser = true;
                }
            }

            if (report.haveShownToUser) {
                // unless we ever re-show?
                continue;
            }

            if (d->reportIsOngoing(report)) {
                havePendingReports = true;
                continue;
            }

            d->presentErrorToUser(report);
            if (report.isCritical) {
                showDialog = true;
            } else if (report.isMessage) {
                showMessage = true;
            } else {
                showPopup = true;
            }

            if (d->_isEnabled) {
                d->sendReportToSentry(report);
            }

            // if we show one report, don't consider any others for now
            break;
        } // of active aggregates iteration

        if (!havePendingReports) {
            d->_reportsDirty = false;
        }
    } // end of locked section

    if (!d->_isEnabled || flightgear::isHeadlessMode()) {
        showDialog = false;
        showPopup = false;
        showMessage = false;
    }

    // show messages: these are local configuration pieces
    // so always warn them to the user, eg DNS/network failure, out-of-memory, or
    // missing shared models.
    if (showMessage) {
        SGPropertyNode_ptr popupArgs(new SGPropertyNode);
        popupArgs->setStringValue("message", d->_displayNode->getStringValue("category"));
        globals->get_commands()->execute("show-error-message-popup", popupArgs, nullptr);
    }

    // do not call into another subsystem with our lock held,
    // as this can trigger deadlocks
    if (showDialog) {
        auto gui = globals->get_subsystem<NewGUI>();
        gui->showDialog("error-report");
        // this needs a bit more thought, disabling for the now
#if 0
        // pause the sim when showing the popup
        SGPropertyNode_ptr pauseArgs(new SGPropertyNode);
        pauseArgs->setBoolValue("force-pause", true);
        globals->get_commands()->execute("do_pause", pauseArgs);
#endif
    } else if (showPopup) {
        SGPropertyNode_ptr popupArgs(new SGPropertyNode);
        popupArgs->setIntValue("index", d->_activeReportIndex);
        globals->get_commands()->execute("show-error-notification-popup", popupArgs, nullptr);
    }
}

void ErrorReporter::shutdown()
{
    globals->get_commands()->removeCommand("dismiss-error-report");
    globals->get_commands()->removeCommand("save-error-report-data");
    globals->get_commands()->removeCommand("show-error-report");

    // during a reset we don't want to touch the log callback; it was added in
    // preinit, which does not get repeated on a reset
    const bool inReset = fgGetBool("/sim/signals/reinit", false);
    if (!inReset) {
        sglog().removeCallback(d->_logCallback.get());
        d->_logCallbackRegistered = false;
    }
}

std::string ErrorReporter::threadSpecificContextValue(const std::string& key)
{
    auto it = thread_errorContextStack.find(key);
    if (it == thread_errorContextStack.end())
        return {};

    return it->second.back();
}


} // namespace flightgear

// Register the subsystem.
SGSubsystemMgr::Registrant<flightgear::ErrorReporter> registrantErrorReporter(
    SGSubsystemMgr::GENERAL);
