#include "globals.h"
#include "utils.h"
#include "wiki_editor_client.h"

#include <maps/infra/yacare/include/params/tvm.h>
#include <maps/infra/yacare/include/yacare.h>
#include <maps/wikimap/mapspro/libs/editor_client/include/basic_object.h>
#include <maps/wikimap/mapspro/libs/editor_client/include/instance.h>
#include <maps/wikimap/mapspro/services/mrc/libs/common/include/mds_path.h>
#include <maps/wikimap/mapspro/services/mrc/libs/db/include/feature_gateway.h>
#include <maps/wikimap/mapspro/services/mrc/libs/db/include/walk_object_gateway.h>
#include <yandex/maps/geolib3/sproto.h>
#include <yandex/maps/geolib3/internal/coord.h>
#include <yandex/maps/proto/offline-mrc/results.sproto.h>
#include <maps/libs/common/include/profiletimer.h>
#include <maps/libs/geolib/include/exception.h>

#include <boost/lexical_cast.hpp>
#include <geos/util/IllegalArgumentException.h>

#include <algorithm>

YCR_QUERY_PARAM(task_id, std::string);

namespace presults = yandex::maps::sproto::offline::mrc::results;

namespace maps::mrc::agent_proxy {

namespace {

using PointsVector = std::vector<geolib3::Point2>;

template <class Geom>
PointsVector decodeCoordSequence(const Geom& geom) {

    return geolib3::proto::internal::decodeCoordSequence(
        *geom.lons().first(),
        *geom.lats().first(),
        geom.lons().deltas().begin(), geom.lons().deltas().end(),
        geom.lats().deltas().begin(), geom.lats().deltas().end());
}

geolib3::Polygon2 decodeAndFixPolygon(
    const yandex::maps::sproto::common2::geometry::Polygon& polygon)
{
    std::vector<geolib3::LinearRing2> interiorRings;
    for (const auto& ring : polygon.inner_rings()) {
        auto coordsSequence = decodeCoordSequence(ring);
        interiorRings.emplace_back(coordsSequence);
    }

    auto coordsSequence = decodeCoordSequence(polygon.outer_ring());
    while (coordsSequence.size() >= 3) {
        try {
            auto outerRing = geolib3::LinearRing2(coordsSequence);
            return geolib3::Polygon2(outerRing, interiorRings);
        } catch (const geolib3::ValidationError& e) {
            WARN() << "Polygon validation error, remove last point";
            coordsSequence.pop_back();
        }
    }
    throw geolib3::ValidationError("Invalid polygon", geolib3::Point2(0, 0));
}

db::ObjectActionType decodeActionType(presults::ActionType actionType)
{
    switch (actionType) {
    case presults::ActionType::ADD:
        return db::ObjectActionType::Add;
    case presults::ActionType::REMOVE:
        return db::ObjectActionType::Remove;
    default:
        throw yacare::errors::BadRequest() << "Unexpected object action type " << actionType;
    }
}

void validateObjectImages(const auto& images)
{
    size_t size = images.size();

    if (size > 0) {
        bool haveNonEmptyImage = false;
        for (size_t i = 0; i < size; ++i) {
            const auto& image = images[i];

            haveNonEmptyImage |= (!image.image().empty());

            CHECK_REQ(image.estimatedPosition(),
                      "Image #" << i << " does not have estimatedPosition");
            CHECK_REQ(image.estimatedPosition().get().heading(),
                      "Image #" << i << " does not have heading");
        }
        CHECK_REQ(haveNonEmptyImage, "Object must have a non-empty image");
    }
}

void validateWalkObject(
    const presults::Object& object,
    const std::string& deviceId,
    db::Dataset dataset)
{
    INFO() << "Validate walk object from deviceId " << deviceId
           << ", dataset " << dataset;

    CHECK_REQ( (object.point() && !object.polyline() && !object.polygon())
            || (!object.point() && object.polyline() && !object.polygon())
            || (!object.point() && !object.polyline() && object.polygon()),
        "Object must have exactly one geometry");

    if (dataset == db::Dataset::Walks) {
        CHECK_REQ(object.point(), "Object from WALKS must have point geometry");
    }

    if (object.polygon()) try {
        decodeAndFixPolygon(object.polygon().get());
    } catch (const geolib3::ValidationError& e) {
        WARN() << "geolib polygon with invalid geometry: " << e.what();
        throw yacare::errors::BadRequest() << e.what();
    } catch (const geos::util::IllegalArgumentException& e) {
        WARN() << "geos polygon with invalid geometry: " << e.what();
        throw yacare::errors::BadRequest() << e.what();
    }

    CHECK_REQ(object.type(), "Object must have type");
    validateObjectImages(object.images());
}

presults::Results getValidatedResults(
    const yacare::Request& request,
    const std::string& deviceId,
    db::Dataset dataset)
{
    auto results = boost::lexical_cast<presults::Results>(request.body());
    CHECK_REQ(results.images().empty(),  "Images must be empty");
    CHECK_REQ(results.track().empty(),   "Track must be empty");
    CHECK_REQ(results.reports().empty(), "Reports must be empty");

    for (const auto& object: results.objects()) {
        validateWalkObject(object, deviceId, dataset);
    }

    return results;
}

chrono::TimePoint getCreatedAt(const presults::Image& protoImage)
{
    return chrono::TimePoint(std::chrono::milliseconds(protoImage.created()));
}

bool featureExists(
    const std::string& deviceId,
    chrono::TimePoint createdAt,
    pqxx::transaction_base& txn)
{
    return db::FeatureGateway{txn}.exists(
        db::table::Feature::sourceId == deviceId &&
        db::table::Feature::date == createdAt);
}

std::pair<mds::Key, db::Feature> saveImage(
    const presults::Image& protoImage,
    const std::string& userId,
    const std::string& deviceId,
    db::Dataset dataset,
    db::TId objectId,
    pqxx::transaction_base& txn,
    mds::Mds& mdsClient)
{
    auto createdAt = getCreatedAt(protoImage);
    const auto& location = protoImage.estimatedPosition().get();
    auto position = geolib3::sproto::decode(location.point());
    auto heading = location.heading().get();

    auto photo = sql_chemistry::GatewayAccess<db::Feature>::construct()
        .setSourceId(deviceId)
        .setGeodeticPos(position)
        .setHeading(geolib3::Heading(heading))
        .setTimestamp(createdAt)
        .setDataset(dataset)
        .setUploadedAt(chrono::TimePoint::clock::now())
        .setUserId(userId)
        .setWalkObjectId(objectId);

    auto image = common::decodeImage(protoImage.image());
    photo.setSize(image.cols, image.rows);

    db::FeatureGateway photoGateway(txn);
    photoGateway.insert(photo);

    ProfileTimer pt;

    auto mdsPath = common::makeMdsPath(
        common::MdsObjectSource::Walk,
        common::MdsObjectType::Image,
        photo.id());
    auto mdsResponse = mdsClient.post(mdsPath, protoImage.image());

    auto key = mdsResponse.key();
    INFO() << "Uploading photo to MDS took " << pt << " seconds. "
           << "Mds key: " << key.groupId << "/" << key.path;

    photo.setMdsKey(key);
    photoGateway.update(photo, db::UpdateFeatureTxn::No);
    return std::make_pair(key, std::move(photo));
}

db::Features saveImages(
    const auto& protoImages,
    const std::string& userId,
    const std::string& deviceId,
    db::Dataset dataset,
    db::TId objectId,
    pqxx::transaction_base& txn)
{
    auto& mdsClient = Globals::mdsClient();

    auto numImages = protoImages.size();
    INFO() << "Saving " << numImages << " images to MDS...";

    std::vector<mds::Key> mdsKeys;
    mdsKeys.reserve(numImages);

    std::vector<db::Feature> features;
    features.reserve(numImages);

    try {
        for (size_t i = 0; i < numImages; ++i) {
            const auto& protoImage = protoImages[i];
            if (protoImage.image().empty()) {
                WARN() << "Image " << i << " is empty, skip it";
                continue;
            }
            auto createdAt = getCreatedAt(protoImage);
            if (featureExists(deviceId, createdAt, txn)) {
                WARN() << "Duplicate feature: sourceId = " << deviceId
                    << ", date = " << chrono::formatIsoDateTime(createdAt);
                continue;
            }

            auto [key, feature]
                = saveImage(protoImage, userId, deviceId, dataset, objectId,
                            txn, mdsClient);
            mdsKeys.push_back(std::move(key));
            features.push_back(std::move(feature));
        }
    } catch (const std::exception& ex) {
        INFO() << "Remove saved images from MDS";
        for (const auto& key : mdsKeys) {
            mdsClient.del(key);
        }
        throw;
    }
    INFO() << "Saving images to MDS done";
    return features;
}

db::GeometryVariant decodeGeometry(const presults::Object& object)
{
    if (object.point()) {
        return geolib3::sproto::decode(object.point().get());
    } else if (object.polyline()) {
        return geolib3::sproto::decode(object.polyline().get());
    } else if (object.polygon()) {
        return decodeAndFixPolygon(object.polygon().get());
    }
    throw maps::RuntimeError() << "Missing object geometry";
}

std::optional<db::TId> saveWalkObject(
    const presults::Object& object,
    const std::string& userId,
    const std::string& deviceId,
    db::Dataset dataset,
    const std::optional<std::string>& ugcAccountTaskId,
    pqxx::transaction_base& txn) try
{
    auto createdAt = chrono::TimePoint(std::chrono::milliseconds(object.created()));

    db::WalkObjectGateway walkObjectsGateway(txn);
    auto objectsInDb = walkObjectsGateway.load(
        db::table::WalkObject::deviceId.equals(deviceId)
        && db::table::WalkObject::createdAt.equals(createdAt));
    if (!objectsInDb.empty()) {
        WARN() << "Skip: object is already in the database: "
            << deviceId << " " << chrono::formatIsoDateTime(createdAt);
        return std::nullopt;
    }

    db::WalkObject walkObject(
        dataset,
        deviceId,
        createdAt,
        decodeFeedbackType(object.type().get()),
        decodeGeometry(object)
    );

    walkObject.setUserId(userId);

    if (object.comment()) {
        walkObject.setComment(object.comment().get());
    }
    if (object.indoorLevelId()) {
        walkObject.setIndoorLevelId(object.indoorLevelId().get());
    }
    if (object.actionType()) {
        walkObject.setActionType(decodeActionType(object.actionType().get()));
    }
    if (object.pedestrian_task_id()) {
        walkObject.setTaskId(object.pedestrian_task_id().get());
    }
    else if (ugcAccountTaskId) {
        walkObject.setTaskId(*ugcAccountTaskId);
    }
    if (db::isGroupHypothesisGeneration(walkObject)) {
        walkObject.setObjectStatus(db::ObjectStatus::Delayed);
    }

    try {
        walkObjectsGateway.insertx(walkObject);
    } catch (const maps::sql_chemistry::UniqueViolationError& e) {
        WARN() << "Unique violation: object is already in the database: "
            << deviceId << " "
            << chrono::formatIsoDateTime(walkObject.createdAt());
        return std::nullopt;
    }
    return walkObject.id();
} catch (const geolib3::ValidationError& e) {
    // TODO: Temporarily skip objects with invalid geometry.
    // Validation should be enabled after client side is fixed
    WARN() << "Drop object with invalid geometry";
    return std::nullopt;
}

void logUgcPhotoCreationEvents(
    const std::string& userId,
    const std::vector<db::Feature>& features,
    const yacare::Request& request)
{
    ugc_event_logger::UserInfo userInfo{.ip = std::string(request.getClientIpAddress()), .uid = userId};
    auto maybePort = request.getClientPort();
    if (maybePort.has_value()) {
        userInfo.port = maybePort.value();
    }

    for (const auto& feature : features) {
        Globals::ugcEventLogger().logEvent(
            userInfo,
            ugc_event_logger::Action::Create,
            ugc_event_logger::Photo{.id = std::to_string(feature.id())}
        );
    }
}

} //anonymous namespace

YCR_RESPOND_TO(
    "POST /walks/upload",
    YCR_USING(yacare::NginxLimitBody<yacare::ConfigScope::Endpoint>{}.maxSize(30_MB)),
    userId)
{
    checkUserCanPublishUgcContent(request);

    const auto& deviceId = input["deviceid"];
    const std::string uid = std::to_string(userId);
    const db::Dataset dataset = db::Dataset::Walks;

    auto results = getValidatedResults(request, deviceId, dataset);

    for (const auto& object : results.objects()) {
        auto txn = Globals::pool().masterWriteableTransaction();

        auto walkObjectId = saveWalkObject(object,
                                           uid,
                                           deviceId,
                                           dataset,
                                           std::nullopt,
                                           *txn);

        if (!walkObjectId) {
            continue;
        }

        auto features = saveImages(object.images(),
                                   std::to_string(userId),
                                   deviceId,
                                   dataset,
                                   *walkObjectId,
                                   *txn);

        if (features.empty() && !object.images().empty()) {
            WARN() << "Object does not add new/good features";
            continue;
        }

        txn->commit();
        logUgcPhotoCreationEvents(std::to_string(userId), features, request);
    }
}

YCR_RESPOND_TO(
    "POST /pedestrian_task/upload",
    YCR_USING(yacare::NginxLimitBody<yacare::ConfigScope::Endpoint>{}.maxSize(30_MB)),
    task_id,
    userId)
{
    const auto& deviceId = input["deviceid"];
    const std::string uid = std::to_string(userId);
    const db::Dataset dataset = db::Dataset::PedestrianTask;

    auto results = getValidatedResults(request, deviceId, dataset);

    for (const auto& object : results.objects()) {
        auto txn = Globals::pool().masterWriteableTransaction();

        auto walkObjectId = saveWalkObject(object,
                                           uid,
                                           deviceId,
                                           dataset,
                                           task_id,
                                           *txn);

        if (!walkObjectId) {
            continue;
        }

        auto features = saveImages(object.images(),
                                   uid,
                                   deviceId,
                                   dataset,
                                   *walkObjectId,
                                   *txn);

        if (features.empty() && !object.images().empty()) {
            WARN() << "Object does not add new/good features";
            continue;
        }

        txn->commit();
        logUgcPhotoCreationEvents(uid, features, request);
    }
}

namespace {

const std::string MRC_PEDESTRIAN_REGION_CATEGORY = "mrc_pedestrian_region";
const std::string STATUS_AWAITING_CHECK = "awaiting_check";
const std::string STATUS_CAN_START = "can_start";

// Expected taskId format: `mrc_pedestrian:<object_id>`
std::optional<uint64_t> getObjectIdFromTaskId(const std::string& taskId)
{
    static const std::string PREFIX = "mrc_pedestrian:";

    if (PREFIX != taskId.substr(0, PREFIX.size())) {
        return std::nullopt;
    }

    try {
        return boost::lexical_cast<uint64_t>(taskId.substr(PREFIX.size()));
    } catch (const std::exception&) {
        return std::nullopt;
    }
}

void updateWalkObjectsDelayedToPending(pgpool3::Pool& pool,
                                       const std::string& userId,
                                       const std::string& deviceId,
                                       const std::string& taskId)
{
    auto txn = pool.masterWriteableTransaction();
    auto objects = db::WalkObjectGateway{*txn}.load(
        db::table::WalkObject::userId.equals(userId) &&
        db::table::WalkObject::deviceId.equals(deviceId) &&
        db::table::WalkObject::taskId.equals(taskId) &&
        db::table::WalkObject::status == db::ObjectStatus::Delayed);
    for (auto& object : objects) {
        object.setObjectStatus(db::ObjectStatus::Pending);
    }
    db::WalkObjectGateway{*txn}.updatex(objects);
    txn->commit();
}

void updatePedestrianZoneStatusToAwaitingCheck(uint64_t objectId,
                                               const std::string& login)
{
    auto& wikiEditor = Globals::wikiEditorClient();

    auto zone = wikiEditor.getPedestrianZone(objectId);
    if (zone.category() != MRC_PEDESTRIAN_REGION_CATEGORY) {
        throw yacare::errors::BadRequest()
            << "Object " << objectId
            << " has wrong category: " << zone.category();
    }

    if (zone.assigneeLogin() != login) {
        WARN() << "Zone assignee conflict: " << zone.assigneeLogin()
               << " != " << login;
        return;
    }

    auto status = zone.status();
    if (status != STATUS_CAN_START) {
        WARN() << "Zone status: " << status;
        return;
    }

    zone.setStatus(STATUS_AWAITING_CHECK);
    auto patchedZone = wikiEditor.savePedestrianZone(zone);
    auto newStatus = patchedZone.status();
    INFO() << "Patched zone " << objectId << ", new status: " << newStatus;

    REQUIRE(newStatus == STATUS_AWAITING_CHECK, "Failed to update zone status");
}

} // namespace

YCR_RESPOND_TO("PUT /pedestrian_task/complete", task_id, userInfo)
{
    auto objectId = getObjectIdFromTaskId(task_id);
    if (!objectId) {
        WARN() << "task_id " << task_id << " is not from WikiEditor object";
        throw yacare::errors::NotFound() << "Could not determine object id";
    }

    updateWalkObjectsDelayedToPending(Globals::pool(),
                                      userInfo.uid(),
                                      input["deviceid"],
                                      task_id);

    updatePedestrianZoneStatusToAwaitingCheck(*objectId, userInfo.login());
}

} //namespace maps::mrc::agent_proxy
