// Relevance scoring and similarity scoring — CS 499 Enhancement Two.
//
// Both scorers are pure functions over plain data, which keeps them
// unit-testable without a database. Field weights mirror the plan
// from the code review (name matches matter most, then resort, then
// description) and anticipate the weighted text index that Enhancement
// Three will push down into MongoDB.
//
// Security note: queries are tokenized to lowercase alphanumeric
// terms and compared with substring checks — no regular expressions
// are ever built from user input, which removes the possibility of
// ReDoS (catastrophic-backtracking) attacks via crafted queries.

const FIELD_WEIGHTS = { name: 3, resort: 2, description: 1 };
const MAX_QUERY_LENGTH = 100;

/** Lowercase alphanumeric tokens; caps input length defensively. */
function tokenize(text) {
    if (typeof text !== 'string') {
        return [];
    }
    return text
        .slice(0, MAX_QUERY_LENGTH)
        .toLowerCase()
        .split(/[^a-z0-9]+/)
        .filter((token) => token.length > 0);
}

/**
 * Scores a trip against a search query.
 * For each query token: +3 if it appears in the name, +2 in the
 * resort, +1 in the description, with a +1 bonus for a token that
 * matches the start of the trip name (prefix matches are usually
 * what an admin typing a name expects to rank first).
 * @returns {number} 0 means "no match".
 */
function scoreTripAgainstQuery(trip, queryTokens) {
    if (queryTokens.length === 0) {
        return 0;
    }
    const name = (trip.name || '').toLowerCase();
    const resort = (trip.resort || '').toLowerCase();
    const description = (trip.description || '').toLowerCase();

    let score = 0;
    for (const token of queryTokens) {
        if (name.includes(token)) {
            score += FIELD_WEIGHTS.name;
            if (name.startsWith(token)) {
                score += 1;
            }
        }
        if (resort.includes(token)) {
            score += FIELD_WEIGHTS.resort;
        }
        if (description.includes(token)) {
            score += FIELD_WEIGHTS.description;
        }
    }
    return score;
}

/** Parses "Emerald Bay, 3 stars" -> 3; returns null when absent. */
function parseStars(resort) {
    const match = /(\d+)\s*stars?/i.exec(resort || '');
    return match ? Number(match[1]) : null;
}

/** Parses the perPerson string price defensively. */
function parsePrice(perPerson) {
    const price = Number(perPerson);
    return Number.isFinite(price) ? price : null;
}

// Common English stop words carry no destination signal but are shared
// by virtually every pair of descriptions, so leaving them in washes
// out the content similarity between trips. They are removed before
// vocabulary comparison (but NOT from search queries, where the user
// chose every word deliberately).
const STOP_WORDS = new Set([
    'a','an','and','are','as','at','be','before','between','by','end','ends',
    'for','from','in','into','is','it','its','of','on','or','our','over',
    'the','their','then','this','to','under','up','while','with','without','your',
    'star','stars' // rating metadata present in every resort string — pure noise
]);

function contentTokens(trip) {
    const tokens = tokenize(
        `${trip.name || ''} ${trip.resort || ''} ${trip.description || ''}`.slice(0, 1000)
    );
    return new Set(tokens.filter((token) => !STOP_WORDS.has(token) && token.length > 2));
}

/**
 * Builds an inverse-document-frequency map over a trip collection:
 * weight(token) = ln(N / documentFrequency). Rare, destination-specific
 * vocabulary ("alpine", "dunes", "canopy") earns high weights, while
 * generic travel words shared by half the catalog ("guided", "camp",
 * "evening") earn low ones. O(total tokens) to build, O(1) to query.
 */
function buildIdf(trips) {
    const documentFrequency = new Map();
    for (const trip of trips) {
        for (const token of contentTokens(trip)) {
            documentFrequency.set(token, (documentFrequency.get(token) || 0) + 1);
        }
    }
    const idf = new Map();
    for (const [token, df] of documentFrequency) {
        idf.set(token, Math.log(trips.length / df));
    }
    return idf;
}

/**
 * Similarity between a seed trip and a candidate, used for the
 * "trips similar to X" recommendation feature:
 *   - up to +2 for price proximity (linear falloff over $1,500)
 *   - +1.5 for the same resort star rating
 *   - +0.5 for the same trip length
 *   - up to +6 for shared content vocabulary, where each shared token
 *     contributes its inverse-document-frequency weight (or 1 when no
 *     idf map is supplied), scaled by 0.9 and capped at 6
 *
 * The design history is itself a documented trade-off (Course Outcome
 * 3): the first version weighted price 3 and used unweighted Jaccard
 * overlap, and integration tests proved cross-category trips at
 * similar prices outranked same-category trips — a desert camel trek
 * recommended for a reef seed. Plain overlap could not fix it because
 * generic travel vocabulary ("guided", "camp", "trek") is shared by
 * most pairs. IDF weighting resolves it structurally: rare
 * destination terms dominate the vocabulary score, so category
 * clusters emerge without the algorithm ever being told categories
 * exist.
 */
function similarityScore(seed, candidate, idf = null) {
    let score = 0;

    const seedPrice = parsePrice(seed.perPerson);
    const candidatePrice = parsePrice(candidate.perPerson);
    if (seedPrice !== null && candidatePrice !== null) {
        const closeness = 1 - Math.min(1, Math.abs(seedPrice - candidatePrice) / 1500);
        score += 2 * closeness;
    }

    const seedStars = parseStars(seed.resort);
    if (seedStars !== null && seedStars === parseStars(candidate.resort)) {
        score += 1.5;
    }

    if (seed.length && seed.length === candidate.length) {
        score += 0.5;
    }

    const seedTokens = contentTokens(seed);
    const candidateTokens = contentTokens(candidate);
    if (seedTokens.size > 0 && candidateTokens.size > 0) {
        let weightedShared = 0;
        for (const token of seedTokens) {
            if (candidateTokens.has(token)) {
                weightedShared += idf ? (idf.get(token) || 0) : 1;
            }
        }
        score += Math.min(6, 0.9 * weightedShared);
    }

    return score;
}

module.exports = { tokenize, scoreTripAgainstQuery, similarityScore, buildIdf, contentTokens, parseStars, parsePrice };
