#include <maps/wikimap/mapspro/services/autocart/pipeline/libs/nmaps/include/publish_nmaps_blds.h>
#include <maps/wikimap/mapspro/services/autocart/pipeline/libs/objects/include/building.h>
#include <maps/wikimap/mapspro/services/autocart/pipeline/libs/storage/include/base_results.h>
#include <maps/wikimap/mapspro/services/autocart/pipeline/libs/storage/include/publication_results.h>
#include <maps/wikimap/mapspro/services/autocart/libs/post_processing/include/post_processing.h>
#include <maps/wikimap/mapspro/services/autocart/libs/geometry/include/polygon_processing.h>

#include <maps/libs/common/include/retry.h>

#include <maps/libs/log8/include/log8.h>

#include <maps/libs/geolib/include/polygon.h>
#include <maps/libs/geolib/include/distance.h>

#include <maps/libs/json/include/value.h>
#include <maps/libs/json/include/builder.h>

#include <maps/libs/common/include/file_utils.h>

#include <maps/wikimap/mapspro/libs/editor_client/include/instance.h>

#include <util/folder/tempdir.h>

#include <thread>
#include <iostream>
#include <filesystem>

namespace maps::wiki::autocart::pipeline {

namespace {

const std::string NMAPS_CATEGORY_BLD = "bld";
const std::string NMAPS_CATEGORY_ROAD = "rd_el";
const std::string NMAPS_CATEGORY_URBAN_AREAL = "urban_areal";
const std::string BLD_HEIGHT_ATTR = "bld:height";
const uint64_t DEFAULT_BLD_HEIGHT = 3;
// see YMapsDF docs for bld:ft_type_id
// https://doc.yandex-team.ru/ymaps/ymapsdf/ymapsdf-ref/concepts/bld.html
const std::string BLD_FT_TYPE_ID_ATTR = "bld:ft_type_id";
const double MIN_HOUSE_AREA_IN_METERS = 20.;
const double MIN_HOUSE_SIDE_IN_METERS = 4.;

std::vector<editor_client::ObjectIdentity> getObjectsAroundBuilding(
    const editor_client::Instance& client,
    const geolib3::Polygon2& geodeticRegion,
    const std::set<std::string>& categoryIds)
{
    static const int MAX_COUNT = 50;
    geolib3::Polygon2 mercRegion
        = geolib3::convertGeodeticToMercator(geodeticRegion);
    return client.getObjectsByLasso(
        categoryIds,
        mercRegion,
        std::nullopt,
        MAX_COUNT, // there can't be many objects inside a small building
        editor_client::GeomPredicate::Intersects
    );
}

double minRectSideInMeters(const geolib3::Polygon2& rect) {
    REQUIRE(rect.pointsNumber() == 4, "Rectangle must have 4 points");
    double height = geoDistance(rect.pointAt(0), rect.pointAt(1));
    double width = geoDistance(rect.pointAt(1), rect.pointAt(2));
    return std::min(height, width);
}

double rectAreaInMeters(const geolib3::Polygon2& rect) {
    REQUIRE(rect.pointsNumber() == 4, "Rectangle must have 4 points");
    double height = geoDistance(rect.pointAt(0), rect.pointAt(1));
    double width = geoDistance(rect.pointAt(1), rect.pointAt(2));
    return height * width;
}

FTTypeId getNMapsBldTypeId(
    const editor_client::Instance& client,
    const Building& bld)
{
    geolib3::Polygon2 rotatedBbox = getBoundingRectangle(bld.toGeodeticGeom());
    if (rectAreaInMeters(rotatedBbox) < MIN_HOUSE_AREA_IN_METERS
        || minRectSideInMeters(rotatedBbox) < MIN_HOUSE_SIDE_IN_METERS) {
        // small size building
        return FTTypeId::URBAN_STRUCTURE;
    } else {
        std::vector<editor_client::ObjectIdentity> urbanAreals
            = getObjectsAroundBuilding(client, bld.toGeodeticGeom(), {NMAPS_CATEGORY_URBAN_AREAL});
        if (urbanAreals.empty()) {
            return FTTypeId::URBAN_RESIDENTIAL;
        } else {
            return FTTypeId::URBAN_INDUSTRIAL;
        }
    }
}

std::optional<uint64_t> isAlreadyPublished(
    const editor_client::Instance& client, types::TUid uid,
    const Building& bld,
    const std::vector<editor_client::ObjectIdentity>& objects)
{
    static const double IOU_THRESHOLD = 0.95;
    for (const editor_client::ObjectIdentity& object : objects) {
        if (NMAPS_CATEGORY_BLD != object.categoryId) {
            continue;
        }
        editor_client::BasicEditorObject basicObject = client.getObjectById(object.id);
        if (!basicObject.recentCommit.has_value() ||
            basicObject.recentCommit->author != uid)
        {
            // commit by another user
            continue;
        }
        auto heightIt = basicObject.plainAttributes.find(BLD_HEIGHT_ATTR);
        if (heightIt == basicObject.plainAttributes.end() ||
            std::stoi(heightIt->second) != bld.getHeight())
        {
            // has another height value
            continue;
        }
        auto ftTypeIdIt = basicObject.plainAttributes.find(BLD_FT_TYPE_ID_ATTR);
        if (ftTypeIdIt == basicObject.plainAttributes.end() ||
            std::stoi(ftTypeIdIt->second) != encodeFTTypeId(bld.getFTTypeId()))
        {
            // has another ft type
            continue;
        }
        std::optional<geolib3::SimpleGeometryVariant> nmapsMercatorGeom
            = basicObject.getGeometryInMercator();
        REQUIRE(
            nmapsMercatorGeom.has_value() &&
            geolib3::GeometryType::Polygon == nmapsMercatorGeom->geometryType(),
            "Invalid building object with id = " << object.id
        );
        if (IoU(bld.toMercatorGeom(), nmapsMercatorGeom->get<geolib3::Polygon2>()) < IOU_THRESHOLD) {
            // has another geometry
            continue;
        }
        return object.id;
    }
    return std::nullopt;
}

editor_client::BasicEditorObject createNMapsObject(const Building& bld) {
    REQUIRE(bld.hasFTTypeId() && bld.hasHeight(),
            "Building height and type should be specified");
    editor_client::BasicEditorObject object;
    object.categoryId = NMAPS_CATEGORY_BLD;
    object.setGeometryInGeodetic(bld.toGeodeticGeom());
    object.plainAttributes[BLD_FT_TYPE_ID_ATTR] = std::to_string(encodeFTTypeId(bld.getFTTypeId()));
    object.plainAttributes[BLD_HEIGHT_ATTR] = std::to_string(bld.getHeight());
    return object;
}

std::unordered_map<uint64_t, PublicationResult> loadState(const std::string& path) {
    std::unordered_map<uint64_t, PublicationResult> state;
    for (const json::Value& value : json::Value::fromFile(path)) {
        PublicationResult result = PublicationResult::fromJson(value);
        state[result.id] = result;
    }
    return state;
}

void dumpState(const std::string& path, const std::vector<PublicationResult>& results) {
    TTempDir tempDir;
    std::string tempPath = common::joinPath(tempDir.Name(), "state.json");

    std::ofstream ofs(tempPath);
    json::Builder builder(ofs);
    builder << [&](json::ArrayBuilder b) {
        for (const PublicationResult& result : results) {
            b << [&](json::ObjectBuilder b) {
                result.toJson(b);
            };
        }
    };
    ofs.close();

    std::filesystem::rename(tempPath, path);
}

} // namespace

PublicationTimeRange::PublicationTimeRange(int startHour, int endHour)
    : startHour_(startHour)
    , endHour_(endHour)
{
    REQUIRE(
        0 <= startHour_ && startHour_ <= 23,
        "Incorrect start hour: " + std::to_string(startHour_)
    );
    REQUIRE(
        0 <= endHour_ && endHour_ <= 23,
        "Incorrect end hour: " + std::to_string(endHour_)
    );
    REQUIRE(
        startHour_ != endHour_,
        "Start and end hours can not be equal (use allDay() method)"
    );
}

PublicationTimeRange PublicationTimeRange::allDay() {
    PublicationTimeRange timeRange;
    timeRange.startHour_ = 0;
    timeRange.endHour_ = 0;
    return timeRange;
}

int PublicationTimeRange::getHours(const chrono::TimePoint& timePoint) {
    return std::stoi(chrono::formatIntegralDateTime(timePoint, "%H"));
}

int PublicationTimeRange::getMinutes(const chrono::TimePoint& timePoint) {
    return std::stoi(chrono::formatIntegralDateTime(timePoint, "%M"));
}

bool PublicationTimeRange::checkIsInRange(const chrono::TimePoint& timePoint) const {
    int hours = getHours(timePoint);
    if (startHour_ < endHour_) {
        return startHour_ <= hours && hours < endHour_;
    } else {
        return startHour_ <= hours || hours < endHour_;
    }
}

std::chrono::minutes PublicationTimeRange::getMinutesToStart(
    const chrono::TimePoint& timePoint) const
{
    static const int MIDNIGHT_HOUR = 24;
    if (checkIsInRange(timePoint)) {
        return std::chrono::minutes(0);
    } else {
        int hours = getHours(timePoint);
        int minutes = getMinutes(timePoint);
        std::chrono::hours hoursDiff;
        if (hours < startHour_) {
            hoursDiff = std::chrono::hours(startHour_ - hours);
        } else {
            hoursDiff = std::chrono::hours(MIDNIGHT_HOUR - hours + startHour_);
        }
        return hoursDiff - std::chrono::minutes(minutes);
    }
}

std::vector<PublicationResult> publishBuildingsToNMaps(
    const std::vector<BaseResult>& baseResults,
    editor_client::Instance& readerClient,
    editor_client::Instance& writerClient,
    types::TUid uid,
    const PublicationTimeRange& timeRange,
    const std::string& statePath, size_t stateStep)
{
    std::unordered_map<uint64_t, PublicationResult> state;
    if (std::filesystem::is_regular_file(statePath)) {
        state = loadState(statePath);
    }

    std::vector<PublicationResult> results;
    for (size_t i = 0; i < baseResults.size(); i++) {
        chrono::TimePoint now = chrono::TimePoint::clock::now();
        if (!timeRange.checkIsInRange(now)) {
            INFO() << "Dumping state before sleeping";
            dumpState(statePath, results);
            std::chrono::minutes waitTimeout = timeRange.getMinutesToStart(now);
            INFO() << "Sleeping for " << waitTimeout.count() << " minutes";
            std::this_thread::sleep_for(waitTimeout);
        }
        const BaseResult& baseResult = baseResults[i];
        try {
            auto stateIt = state.find(baseResult.id);
            if (stateIt != state.end()) {
                if (PublicationStatus::Published == stateIt->second.status) {
                    INFO() << "Building with result id = " << baseResult.id
                           << " has been restored from state";
                    results.push_back(stateIt->second);
                }
            } else {
                PublicationResult result;
                result.id = baseResult.id;
                result.bld = baseResult.bld;
                if (!result.bld.hasFTTypeId()) {
                    result.bld.setFTTypeId(getNMapsBldTypeId(readerClient, result.bld));
                }
                if (!result.bld.hasHeight()) {
                    result.bld.setHeight(DEFAULT_BLD_HEIGHT);
                }

                std::vector<editor_client::ObjectIdentity> intersectedObjects
                    = getObjectsAroundBuilding(
                        readerClient,
                        result.bld.toGeodeticGeom(),
                        {NMAPS_CATEGORY_BLD, NMAPS_CATEGORY_ROAD}
                    );
                if (!intersectedObjects.empty()) {
                    std::optional<uint64_t> nmapsId
                        = isAlreadyPublished(readerClient, uid, result.bld, intersectedObjects);
                    if (nmapsId.has_value()) {
                        result.bld.setId(nmapsId.value());
                        INFO() << "Building with result id = " << baseResult.id
                               << " has been already published with NMAPS id = " << result.bld.getId();
                        result.status = PublicationStatus::Published;
                        results.push_back(result);
                    } else {
                        INFO() << "Building with result id = " << baseResult.id
                               << " intersects with existing NMAPS objects";
                        result.status = PublicationStatus::Intersects;
                        results.push_back(result);
                    }
                } else {
                    editor_client::BasicEditorObject object = createNMapsObject(result.bld);
                    common::retry(
                        [&]() {
                            uint64_t nmapsId = writerClient.saveObject(object).id;
                            result.bld.setId(nmapsId);
                        },
                        common::RetryPolicy()
                            .setInitialCooldown(std::chrono::seconds(1))
                            .setTryNumber(5)
                            .setCooldownBackoff(2)
                    );
                    INFO() << "Building with result id = " << baseResult.id
                           << " has been successfully published with NMAPS id = " << result.bld.getId();
                    result.status = PublicationStatus::Published;
                    results.push_back(result);
                }
            }
        } catch (const std::exception& e) {
            INFO() << e.what();
            INFO() << "There is error while publishing"
                   << " building with result id = " << baseResult.id;
            PublicationResult result;
            result.id = baseResult.id;
            result.bld = baseResult.bld;
            result.status = PublicationStatus::Error;
            results.push_back(result);
        }
        if (i % stateStep == 0) {
            INFO() << "Dumping state at step " << i;
            dumpState(statePath, results);
        }
    }
    return results;
}

} // namespace maps::wiki::autocart::pipeline
