// Helper for OpenAI + Pinecone integration.
// Uses a two-step approach: looks up the input book to extract its genres,
// then queries Pinecone with a genre-enriched embedding for better matches.
//
// You only need to edit environment variables in your `.env` file.

import OpenAI from "openai";
import { Pinecone } from "@pinecone-database/pinecone";

const requiredEnv = ["OPENAI_API_KEY", "PINECONE_API_KEY", "PINECONE_INDEX_NAME"];

function assertEnv() {
  const missing = requiredEnv.filter((key) => !process.env[key]);
  if (missing.length > 0) {
    const error = new Error(`Missing required environment variables: ${missing.join(", ")}`);
    error.code = "CONFIG_MISSING";
    throw error;
  }
}

let openai;
let pinecone;
let pineconeIndex;

function initClients() {
  assertEnv();

  if (!openai) {
    openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
  }

  if (!pinecone) {
    pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY });
  }

  if (!pineconeIndex) {
    pineconeIndex = pinecone.index(process.env.PINECONE_INDEX_NAME);
  }
}

async function embed(text) {
  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: text
  });
  return response.data[0].embedding;
}

async function generateDescriptions(books) {
  if (!books.length) return [];

  const bookList = books
    .map((b, i) => `${i + 1}. "${b.title}" by ${b.author}`)
    .join("\n");

  try {
    const response = await openai.chat.completions.create({
      model: "gpt-4o-mini",
      temperature: 0.7,
      max_tokens: 400,
      messages: [
        {
          role: "system",
          content:
            "You are a book expert. For each book listed, write exactly one 1-2 sentence description suitable for a recommendation card. Be concise and engaging. Do not mention ratings, page counts, or ISBN numbers. Return ONLY a JSON array of strings, one per book, in the same order."
        },
        { role: "user", content: bookList }
      ]
    });

    const raw = response.choices[0]?.message?.content || "[]";
    const cleaned = raw.replace(/```json\s*|```/g, "").trim();
    const descriptions = JSON.parse(cleaned);
    if (Array.isArray(descriptions) && descriptions.length === books.length) {
      return descriptions;
    }
  } catch (err) {
    console.error("Failed to generate descriptions:", err.message);
  }

  return books.map(() => "");
}

const TITLE_STOP_WORDS = new Set([
  "a",
  "an",
  "and",
  "the",
  "of",
  "to",
  "in",
  "for",
  "on",
  "book",
  "novel",
  "series",
  "volume",
  "vol",
  "part"
]);

function normalizeTitle(text) {
  if (typeof text !== "string") return "";
  return text
    .toLowerCase()
    .replace(/\([^)]*\)/g, " ")
    .replace(/\[[^\]]*\]/g, " ")
    .replace(/[^a-z0-9\s]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

function titleTokens(text) {
  return normalizeTitle(text)
    .split(" ")
    .filter((token) => token && !TITLE_STOP_WORDS.has(token))
    .filter((token) => !/^(book|vol|volume|part)?\d+$/i.test(token))
    .filter((token) => !/^[ivxlcdm]+$/i.test(token));
}

function jaccardSimilarity(tokensA, tokensB) {
  if (!tokensA.length || !tokensB.length) return 0;
  const setA = new Set(tokensA);
  const setB = new Set(tokensB);
  let intersection = 0;
  for (const token of setA) {
    if (setB.has(token)) intersection += 1;
  }
  const union = new Set([...setA, ...setB]).size;
  return union > 0 ? intersection / union : 0;
}

function leadingTokenMatchRatio(tokensA, tokensB) {
  const limit = Math.min(tokensA.length, tokensB.length, 4);
  if (!limit) return 0;
  let matched = 0;
  for (let i = 0; i < limit; i++) {
    if (tokensA[i] !== tokensB[i]) break;
    matched += 1;
  }
  return matched / limit;
}

function startsWithSameTokens(tokensA, tokensB, count) {
  if (tokensA.length < count || tokensB.length < count) return false;
  for (let i = 0; i < count; i++) {
    if (tokensA[i] !== tokensB[i]) return false;
  }
  return true;
}

function isStrongIdentityToken(token) {
  return typeof token === "string" && token.length >= 4 && !/^\d+$/.test(token);
}

function similarIdentityToken(a, b) {
  if (!a || !b) return false;
  if (a === b) return true;
  if (!isStrongIdentityToken(a) || !isStrongIdentityToken(b)) return false;
  const minPrefix = 4;
  const aPrefix = a.slice(0, minPrefix);
  const bPrefix = b.slice(0, minPrefix);
  return aPrefix === bPrefix;
}

function isSeriesLikeTitle(sourceTitle, candidateTitle, threshold) {
  const sourceNormalized = normalizeTitle(sourceTitle);
  const candidateNormalized = normalizeTitle(candidateTitle);

  if (!sourceNormalized || !candidateNormalized) return false;
  if (sourceNormalized === candidateNormalized) return true;

  const sourceTokens = titleTokens(sourceTitle);
  const candidateTokens = titleTokens(candidateTitle);
  if (!sourceTokens.length || !candidateTokens.length) return false;
  const sourceTokenSet = new Set(sourceTokens);
  const candidateTokenSet = new Set(candidateTokens);

  // Strong deterministic prefix rule for franchise-like naming:
  // e.g. "harry potter and ..." variants
  if (
    sourceTokens.length >= 2 &&
    candidateTokens.length >= 2 &&
    similarIdentityToken(sourceTokens[0], candidateTokens[0]) &&
    similarIdentityToken(sourceTokens[1], candidateTokens[1]) &&
    isStrongIdentityToken(sourceTokens[0]) &&
    isStrongIdentityToken(sourceTokens[1])
  ) {
    return true;
  }

  // Very strong 3-token shared prefix is almost always same-series/variant
  if (startsWithSameTokens(sourceTokens, candidateTokens, 3)) {
    return true;
  }

  // For short canonical titles, if all key source tokens are present in the candidate,
  // it's usually an edition/annotation/analysis variant of the same work.
  if (sourceTokens.length >= 2 && sourceTokens.length <= 3) {
    let hasAllSourceTokens = true;
    for (const token of sourceTokenSet) {
      if (!candidateTokenSet.has(token)) {
        hasAllSourceTokens = false;
        break;
      }
    }
    if (hasAllSourceTokens) {
      return true;
    }
  }

  const jaccard = jaccardSimilarity(sourceTokens, candidateTokens);
  const leading = leadingTokenMatchRatio(sourceTokens, candidateTokens);
  const minTokenLength = Math.min(sourceTokens.length, candidateTokens.length);

  if (minTokenLength >= 2) {
    const sourceJoined = sourceTokens.join(" ");
    const candidateJoined = candidateTokens.join(" ");
    if (sourceJoined.length >= 8 && candidateJoined.length >= 8) {
      if (sourceJoined.startsWith(candidateJoined) || candidateJoined.startsWith(sourceJoined)) {
        return true;
      }
    }
  }

  return jaccard >= threshold || (leading >= 0.5 && jaccard >= 0.35);
}

/**
 * Search for books matching a partial query string.
 * Embeds the query and returns the closest matches from Pinecone.
 *
 * @param {string} query - Partial book title (>= 3 chars)
 * @returns {Promise<{ results: Array<{ title: string; author: string; isbn13?: string }>, found: boolean }>}
 */
export async function searchBooks(query) {
  initClients();

  const namespace = process.env.PINECONE_NAMESPACE || undefined;
  const target = namespace ? pineconeIndex.namespace(namespace) : pineconeIndex;

  const embedding = await embed(query);
  const response = await target.query({
    vector: embedding,
    topK: 6,
    includeMetadata: true
  });

  const MIN_SCORE = 0.3;
  const matches = (response.matches || []).filter((m) => m.score >= MIN_SCORE);

  if (matches.length === 0) {
    return { results: [], found: false };
  }

  const seen = new Set();
  const results = [];
  for (const match of matches) {
    const meta = match.metadata || {};
    const title = meta.title || "Untitled";
    const key = title.trim().toLowerCase();
    if (seen.has(key)) continue;
    seen.add(key);
    results.push({
      title,
      author: meta.author || "Unknown author",
      isbn13: meta.isbn13 || meta.isbn || undefined
    });
  }

  return { results, found: true };
}

/**
 * Fetch book recommendations from Pinecone based on a last read book title.
 * Uses a two-step approach: first identifies the input book to extract its
 * genres, then queries with a genre-enriched embedding for better matches.
 *
 * @param {string} lastBookTitle
 * @param {string[]} excludedBooks
 * @param {{ excludeSeries?: boolean }} options
 * @returns {Promise<Array<{ title: string; author: string; reason?: string }>>}
 */
export async function getBookRecommendations(lastBookTitle, excludedBooks = [], options = {}) {
  initClients();

  const namespace = process.env.PINECONE_NAMESPACE || undefined;
  const target = namespace ? pineconeIndex.namespace(namespace) : pineconeIndex;
  const topK = Number(process.env.PINECONE_TOP_K || 5);
  const excludeSeries = Boolean(options.excludeSeries);
  const seriesThreshold = Number(process.env.SERIES_TITLE_SIMILARITY_THRESHOLD || 0.58);
  const seriesFetchBuffer = Number(process.env.SERIES_FETCH_BUFFER || 10);
  const seriesDebug = process.env.SERIES_FILTER_DEBUG === "true";
  const normalizedExcludedBooks = excludedBooks
    .map((value) => value.trim())
    .filter(Boolean);
  const excludedIsbns = new Set(
    normalizedExcludedBooks
      .map((value) => value.replace(/[^0-9Xx]/g, ""))
      .filter((value) => value.length >= 10)
  );
  const excludedTitles = new Set(
    normalizedExcludedBooks
      .map((value) => value.toLowerCase())
      .filter((value) => !/^[0-9Xx-]+$/.test(value))
  );
  const fetchK = topK + Math.max(normalizedExcludedBooks.length, 3) + 1 + (excludeSeries ? seriesFetchBuffer : 0);

  // Step 1: Look up the input book to extract its metadata
  const titleEmbedding = await embed(lastBookTitle);
  const lookupResponse = await target.query({
    vector: titleEmbedding,
    topK: 1,
    includeMetadata: true
  });

  const sourceMatch = (lookupResponse.matches || [])[0];
  const sourceMeta = sourceMatch?.metadata || {};
  const sourceId = sourceMatch?.id;

  // Step 2: Build a genre-enriched query so results match by genre primarily
  const sourceGenres = sourceMeta.genres || "";
  const sourceAuthor = sourceMeta.author || "";
  const sourceTitle = sourceMeta.title || lastBookTitle;

  const enrichedQuery = sourceGenres
    ? `${sourceGenres.replaceAll(";", "; ")}. A book similar to ${sourceTitle} by ${sourceAuthor}`
    : lastBookTitle;

  const enrichedEmbedding = await embed(enrichedQuery);
  const queryResponse = await target.query({
    vector: enrichedEmbedding,
    topK: fetchK,
    includeMetadata: true
  });

  // Filter out the source book itself, then take topK
  let filteredBySeries = 0;
  let filteredByExactTitle = 0;
  let filteredByIsbn = 0;
  const recommendationsPool = (queryResponse.matches || [])
    .filter((m) => {
      if (m.id === sourceId) {
        return false;
      }

      const meta = m.metadata || {};
      const matchTitle = typeof meta.title === "string" ? meta.title.trim().toLowerCase() : "";
      const rawIsbn = meta.isbn13 || meta.isbn || "";
      const normalizedIsbn = typeof rawIsbn === "string" ? rawIsbn.replace(/[^0-9Xx]/g, "") : "";

      if (matchTitle && excludedTitles.has(matchTitle)) {
        filteredByExactTitle += 1;
        return false;
      }

      if (normalizedIsbn && excludedIsbns.has(normalizedIsbn)) {
        filteredByIsbn += 1;
        return false;
      }

      if (excludeSeries && matchTitle && isSeriesLikeTitle(sourceTitle, matchTitle, seriesThreshold)) {
        filteredBySeries += 1;
        return false;
      }

      return true;
    })
    .map((match) => {
      const meta = match.metadata || {};

      return {
        title: meta.title || "Untitled",
        author: meta.author || "Unknown author",
        isbn13: meta.isbn13 || meta.isbn || undefined,
        genres: typeof meta.genres === "string" ? meta.genres : undefined,
        rating: meta.rating,
        numPages: meta.num_pages || meta.pages || undefined,
        reason: `Recommended because it shares conceptual Similarity with "${sourceTitle}".`,
        description: ""
      };
    });

  const recommendations = recommendationsPool.slice(0, topK);

  if (seriesDebug && excludeSeries) {
    console.log(
      `[series-filter] source="${sourceTitle}" filtered(series=${filteredBySeries}, exactTitle=${filteredByExactTitle}, isbn=${filteredByIsbn}) kept=${recommendations.length}/${recommendationsPool.length}`
    );
  }

  const descriptions = await generateDescriptions(recommendations);
  for (let i = 0; i < recommendations.length; i++) {
    if (descriptions[i]) {
      recommendations[i].description = descriptions[i];
    }
  }

  return recommendations;
}
