// Search / sort / pagination / recommendation controllers.
// CS 499 Enhancement Two: Algorithms and Data Structures.
//
// These endpoints deliberately perform their work in application code
// (merge sort, scoring, heap selection) to demonstrate the algorithms
// explicitly; Enhancement Three will push the equivalent work into
// MongoDB aggregation pipelines and indexes and compare the two.
const mongoose = require('mongoose');
require('../models/travlr');
const Model = mongoose.model('trips');
const asyncHandler = require('../middleware/asyncHandler');
const { ApiError } = require('../middleware/errorHandler');
const mergeSort = require('../algorithms/mergeSort');
const BoundedMinHeap = require('../algorithms/minHeap');
const { tokenize, scoreTripAgainstQuery, similarityScore, buildIdf, parsePrice } = require('../algorithms/relevance');
const { encodeCursor, decodeCursor } = require('../algorithms/cursor');

const DEFAULT_LIMIT = 6;
const MAX_LIMIT = 50;
const SORT_FIELDS = new Set(['name', 'price', 'start', 'relevance']);

// Extracts the value a trip is sorted by for a given field.
function sortValueOf(trip, sortBy, scores) {
    switch (sortBy) {
        case 'price': {
            const price = parsePrice(trip.perPerson);
            return price === null ? Number.MAX_SAFE_INTEGER : price;
        }
        case 'start':
            return trip.start ? new Date(trip.start).getTime() : 0;
        case 'relevance':
            return scores.get(trip.code) || 0;
        default:
            return (trip.name || '').toLowerCase();
    }
}

function compareValues(a, b) {
    if (a < b) return -1;
    if (a > b) return 1;
    return 0;
}

// GET /trips/search?search=&sortBy=&order=&limit=&cursor=
const tripsSearch = asyncHandler(async (req, res) => {
    // ---- Parse and clamp inputs defensively ----
    const query = typeof req.query.search === 'string' ? req.query.search.trim() : '';
    const queryTokens = tokenize(query);

    let sortBy = typeof req.query.sortBy === 'string' ? req.query.sortBy : '';
    if (sortBy && !SORT_FIELDS.has(sortBy)) {
        throw new ApiError(400, `Unsupported sortBy value: must be one of ${[...SORT_FIELDS].join(', ')}`);
    }
    if (!sortBy) {
        sortBy = queryTokens.length > 0 ? 'relevance' : 'name';
    }

    const order = req.query.order === 'desc' ? 'desc' : 'asc';
    let limit = Number.parseInt(req.query.limit, 10);
    if (!Number.isInteger(limit)) {
        limit = DEFAULT_LIMIT;
    }
    limit = Math.max(1, Math.min(MAX_LIMIT, limit));

    // ---- Load, filter by relevance, sort with merge sort ----
    const allTrips = (await Model.find({}).lean().exec());

    const scores = new Map(); // code -> relevance score (O(1) lookups)
    let matched = allTrips;
    if (queryTokens.length > 0) {
        matched = allTrips.filter((trip) => {
            const score = scoreTripAgainstQuery(trip, queryTokens);
            scores.set(trip.code, score);
            return score > 0;
        });
    }

    const direction = order === 'desc' ? -1 : 1;
    // For relevance, "asc" makes no sense; best matches always first.
    const relevanceDirection = sortBy === 'relevance' ? -1 : direction;
    const sorted = mergeSort(matched, (a, b) => {
        const primary = compareValues(
            sortValueOf(a, sortBy, scores),
            sortValueOf(b, sortBy, scores)
        ) * (sortBy === 'relevance' ? relevanceDirection : direction);
        if (primary !== 0) {
            return primary;
        }
        // Unique tiebreaker keeps pagination deterministic.
        return compareValues(a.code, b.code);
    });

    // ---- Cursor pagination ----
    const context = { sortBy, order, query };
    let startIndex = 0;
    if (req.query.cursor) {
        const position = decodeCursor(req.query.cursor, context);
        const resumeAfter = sorted.findIndex((trip) =>
            sortValueOf(trip, sortBy, scores) === position.value &&
            trip.code === position.code);
        // If the anchor record was deleted between pages, fall back to
        // the first record PAST its sort position instead of failing.
        startIndex = resumeAfter >= 0
            ? resumeAfter + 1
            : sorted.findIndex((trip) => {
                const cmp = compareValues(sortValueOf(trip, sortBy, scores), position.value);
                return (sortBy === 'relevance' ? -cmp : cmp * direction) > 0;
            });
        if (startIndex < 0) {
            startIndex = sorted.length;
        }
    }

    const page = sorted.slice(startIndex, startIndex + limit);
    const hasMore = startIndex + limit < sorted.length;
    const nextCursor = hasMore
        ? encodeCursor(context, {
            sortValue: sortValueOf(page[page.length - 1], sortBy, scores),
            code: page[page.length - 1].code
        })
        : null;

    return res.status(200).json({
        results: page,
        totalMatches: sorted.length,
        nextCursor,
        sortBy,
        order
    });
});

// GET /trips/recommendations?basedOn=CODE&k=3
const tripsRecommendations = asyncHandler(async (req, res) => {
    const basedOn = typeof req.query.basedOn === 'string' ? req.query.basedOn.trim() : '';
    if (!basedOn) {
        throw new ApiError(400, 'basedOn query parameter is required');
    }
    let k = Number.parseInt(req.query.k, 10);
    if (!Number.isInteger(k)) {
        k = 3;
    }
    k = Math.max(1, Math.min(10, k));

    const allTrips = await Model.find({}).lean().exec();

    // Hash map index: O(n) to build once, O(1) to look up the seed —
    // and reusable for any number of subsequent lookups per request.
    const byCode = new Map(allTrips.map((trip) => [trip.code, trip]));
    const seed = byCode.get(basedOn);
    if (!seed) {
        throw new ApiError(404, `Trip '${basedOn}' not found`);
    }

    // IDF weights are computed once per request over the collection
    // (O(n) in total tokens), so rare destination vocabulary dominates
    // the similarity score. Then a single O(n log k) pass with a
    // bounded min-heap of size k selects the recommendations.
    const idf = buildIdf(allTrips);
    const scored = new Map();
    const heap = new BoundedMinHeap(k, (trip) => scored.get(trip.code));
    for (const candidate of allTrips) {
        if (candidate.code === seed.code) {
            continue;
        }
        scored.set(candidate.code, similarityScore(seed, candidate, idf));
        heap.offer(candidate);
    }

    const recommendations = heap.drainDescending().map((trip) => ({
        ...trip,
        similarity: Number(scored.get(trip.code).toFixed(3))
    }));

    return res.status(200).json({ basedOn: seed.code, recommendations });
});

module.exports = { tripsSearch, tripsRecommendations };
