/**
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 */

import { sanitizeUntrustedContent } from "moz-src:///browser/components/aiwindow/models/ChatUtils.sys.mjs";

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  getPlacesSemanticHistoryManager:
    "resource://gre/modules/PlacesSemanticHistoryManager.sys.mjs",
  // Domain fallback / workaround for general-category queries (games, movies, etc.)
  SearchBrowsingHistoryDomainBoost:
    "moz-src:///browser/components/aiwindow/models/SearchBrowsingHistoryDomainBoost.sys.mjs",
});

/**
 * Convert ISO timestamp string to microseconds (moz_places format).
 *
 * @param {string|null} iso
 * @returns {number|null}
 */
function isoToMicroseconds(iso) {
  if (!iso) {
    return null;
  }
  const ms = new Date(iso).getTime();
  return Number.isFinite(ms) ? ms * 1000 : null;
}

/**
 * A history row from the moz_places databases, normalized for usage.
 *
 * @typedef {object} HistoryRow
 * @property {string} title - Sanitized title (falls back to URL if missing).
 * @property {string} url - Page URL.
 * @property {string|null} visitDate - ISO timestamp of last visit, or null.
 * @property {number} visitCount - Number of visits (defaults to 0).
 * @property {number} relevanceScore - Ranking score (semantic relevance or frecency fallback).
 */

/**
 * Normalize a history row from either:
 * - semantic SQL result (mozIStorageRow), or
 * - Places history node (plain object from nsINavHistoryResultNode).
 *
 * @param {object} row
 * @param {boolean} [fromNode=false]  // true if row came from Places node
 * @returns {HistoryRow}              // normalized history entry
 */
function buildHistoryRow(row, fromNode = false) {
  let title, url, visitDateIso, visitCount, distance, frecency;

  if (!fromNode) {
    // from semantic / SQL result (mozIStorageRow)
    title = row.getResultByName("title");
    url = row.getResultByName("url");
    visitCount = row.getResultByName("visit_count");
    distance = row.getResultByName("distance");
    frecency = row.getResultByName("frecency");

    // convert last_visit_date to ISO format
    const lastVisitRaw = row.getResultByName("last_visit_date");
    // last_visit_date is in microseconds from moz_places
    if (typeof lastVisitRaw === "number") {
      visitDateIso = new Date(Math.round(lastVisitRaw / 1000)).toISOString();
    } else if (lastVisitRaw instanceof Date) {
      visitDateIso = lastVisitRaw.toISOString();
    } else {
      visitDateIso = null;
    }
  } else {
    // from basic / Places history node (nsINavHistoryResultNode)
    title = row.title;
    url = row.uri;
    visitCount = row.accessCount;
    frecency = row.frecency;

    // convert time to ISO format
    const lastVisitDate = lazy.PlacesUtils.toDate(row.time);
    visitDateIso = lastVisitDate ? lastVisitDate.toISOString() : null;
  }

  let relevanceScore;
  if (typeof distance === "number") {
    relevanceScore = 1 - distance;
  } else {
    relevanceScore = frecency;
  }

  return {
    title: sanitizeUntrustedContent(title || url),
    url,
    visitDate: visitDateIso, // ISO timestamp format
    visitCount: visitCount || 0,
    relevanceScore: relevanceScore || 0, // Use embedding's distance as relevance score when available
  };
}

/**
 * Plain time-range browsing history search without search term (no semantic search).
 *
 * @param {object} params
 * @param {number|null} params.startTs
 * @param {number|null} params.endTs
 * @param {number} params.historyLimit
 * @returns {Promise<HistoryRow[]>}
 */
async function searchBrowsingHistoryTimeRange({
  startTs,
  endTs,
  historyLimit,
}) {
  const results = [];
  await lazy.PlacesUtils.withConnectionWrapper(
    "SearchBrowsingHistory:searchBrowsingHistoryTimeRange",
    async db => {
      const stmt = await db.executeCached(
        `
          SELECT id,
                 title,
                 url,
                 NULL AS distance,
                 visit_count,
                 frecency,
                 last_visit_date
          FROM moz_places
          WHERE frecency <> 0
          AND (:startTs IS NULL OR last_visit_date >= :startTs)
          AND (:endTs IS NULL OR last_visit_date <= :endTs)
          ORDER BY last_visit_date DESC, frecency DESC
          LIMIT :limit
        `,
        {
          startTs,
          endTs,
          limit: historyLimit,
        }
      );

      for (let row of stmt) {
        results.push(row);
      }
    }
  );

  const rows = [];
  for (let row of results) {
    rows.push(buildHistoryRow(row));
  }
  return rows;
}

/**
 * Normalize tensor/output format from the embedder into a single vector.
 *
 * @param {Array|object} tensor
 * @returns {Array|Float32Array}
 */
function extractVectorFromTensor(tensor) {
  if (!tensor) {
    throw new Error("Unexpected empty tensor");
  }

  // Case 1: { output: ... } or { metrics, output }
  if (tensor.output) {
    if (
      Array.isArray(tensor.output) &&
      (Array.isArray(tensor.output[0]) || ArrayBuffer.isView(tensor.output[0]))
    ) {
      // output is an array of vectors, return the first
      return tensor.output[0];
    }
    // output is already a single vector
    return tensor.output;
  }

  // Case 2: tensor is nested like [[...]]
  if (
    Array.isArray(tensor) &&
    tensor.length === 1 &&
    Array.isArray(tensor[0])
  ) {
    tensor = tensor[0];
  }

  // Then we check if it's an array of arrays or just a single value.
  if (
    Array.isArray(tensor) &&
    (Array.isArray(tensor[0]) || ArrayBuffer.isView(tensor[0]))
  ) {
    return tensor[0];
  }

  return tensor;
}

/**
 * Semantic browsing history search using embeddings.
 *
 * This performs a two-stage retrieval for performance:
 * 1. Coarse search: over the quantized embeddings (`embedding_coarse`) to
 *    quickly select up to 100 candidate rows. This hard limit keeps the
 *    expensive cosine-distance computation bounded.
 * 2. Refined search: computes the exact cosine distance for those candidates
 *    and applies the caller-provided `historyLimit` and `distanceThreshold`
 *    filters.
 *
 * @param {object} params
 * @param {string} params.searchTerm
 * @param {number|null} params.startTs
 * @param {number|null} params.endTs
 * @param {number} params.historyLimit
 * @param {number} params.distanceThreshold
 * @returns {Promise<HistoryRow[]>}
 */
async function searchBrowsingHistorySemantic({
  searchTerm,
  startTs,
  endTs,
  historyLimit,
  distanceThreshold,
}) {
  const semanticManager = lazy.getPlacesSemanticHistoryManager();
  await semanticManager.embedder.ensureEngine();

  // Embed search term
  let tensor = await semanticManager.embedder.embed(searchTerm);
  const vec = extractVectorFromTensor(tensor);
  const vector = lazy.PlacesUtils.tensorToSQLBindable(vec);

  let conn = await semanticManager.getConnection();
  const results = await conn.executeCached(
    `
    WITH coarse_matches AS (
      SELECT rowid,
             embedding
      FROM vec_history
      WHERE embedding_coarse match vec_quantize_binary(:vector)
      ORDER BY distance
      LIMIT 100
    ),
    matches AS (
      SELECT url_hash, vec_distance_cosine(embedding, :vector) AS distance
      FROM vec_history_mapping
      JOIN coarse_matches USING (rowid)
      WHERE distance <= :distanceThreshold
      ORDER BY distance
      LIMIT :limit
    )
    SELECT id,
           title,
           url,
           distance,
           visit_count,
           frecency,
           last_visit_date
    FROM moz_places
    JOIN matches USING (url_hash)
    WHERE frecency <> 0
    AND (:startTs IS NULL OR last_visit_date >= :startTs)
    AND (:endTs IS NULL OR last_visit_date <= :endTs)
    ORDER BY distance
    `,
    {
      vector,
      distanceThreshold,
      limit: historyLimit,
      startTs,
      endTs,
    }
  );

  const rows = [];
  for (let row of results) {
    rows.push(buildHistoryRow(row));
  }

  // Domain fallback for general-category queries (games, movies, news, etc.)
  // Keep semantic ranking primary, only top-up if we have room.
  if (rows.length < historyLimit) {
    const domains =
      lazy.SearchBrowsingHistoryDomainBoost.matchDomains(searchTerm);
    if (domains?.length) {
      const domainRows =
        await lazy.SearchBrowsingHistoryDomainBoost.searchByDomains({
          conn,
          domains,
          startTs,
          endTs,
          historyLimit: Math.max(historyLimit * 2, 200), // extra for dedupe
          buildHistoryRow,
        });

      return lazy.SearchBrowsingHistoryDomainBoost.mergeDedupe(
        rows,
        domainRows,
        historyLimit
      );
    }
  }

  return rows;
}

/**
 * Browsing history search using the default history search.
 *
 * @param {object} params
 * @param {string} params.searchTerm
 * @param {number} params.historyLimit
 * @returns {Promise<HistoryRow[]>}
 */
async function searchBrowsingHistoryBasic({ searchTerm, historyLimit }) {
  let root;
  let openedRoot = false;

  try {
    const currentHistory = lazy.PlacesUtils.history;
    const query = currentHistory.getNewQuery();
    const opts = currentHistory.getNewQueryOptions();

    // Use Places' built-in text filtering
    query.searchTerms = searchTerm;

    // Simple URI results, ranked by frecency
    opts.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_URI;
    opts.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_DESCENDING;
    opts.maxResults = historyLimit;
    opts.excludeQueries = false;
    opts.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY;

    const result = currentHistory.executeQuery(query, opts);
    root = result.root;

    if (!root.containerOpen) {
      root.containerOpen = true;
      openedRoot = true;
    }

    const rows = [];
    for (let i = 0; i < root.childCount && rows.length < historyLimit; i++) {
      const node = root.getChild(i);
      rows.push(buildHistoryRow(node, true));
    }
    return rows;
  } catch (error) {
    console.error("Error searching browser history:", error);
    return [];
  } finally {
    if (root && openedRoot) {
      root.containerOpen = false;
    }
  }
}

/**
 * @typedef {object} HistorySearchSummary
 * @property {string} searchTerm - The search term.
 * @property {number} count - The history count.
 * @property {HistoryRow[]} results - The history row results.
 * @property {string} [message] - A message if there are no results.
 * @property {string} [error] - An error message if there is an error.
 */

/**
 * Searches browser history using semantic search when possible, otherwise basic
 * text search or time-range filtering.
 *
 * Rules:
 *   - Empty searchTerm: time-range search (if start/end given) or recent history.
 *   - Non-empty searchTerm: semantic search when available, otherwise basic text
 *     search (ignore time filtering).
 *
 * @param {object} params
 *  The search parameters.
 * @param {string} params.searchTerm
 *  The search string. If null or empty, semantic search is skipped and
 *  results are filtered by time range and sorted by last_visit_date and frecency.
 * @param {string|null} params.startTs
 *  Optional local ISO-8601 start timestamp (e.g. "2025-11-07T09:00:00").
 * @param {string|null} params.endTs
 *  Optional local ISO-8601 end timestamp (e.g. "2025-11-07T09:00:00").
 * @param {number} params.historyLimit
 *  Maximum number of history results to return.
 * @returns {Promise<HistorySearchSummary>}
 *  A promise resolving to an object with the search term and history results.
 *  Includes `count` when matches exist, a `message` when none are found, or an
 *  `error` string on failure.
 */
export async function searchBrowsingHistory({
  searchTerm = "",
  startTs = null,
  endTs = null,
  historyLimit = 15,
}) {
  /** @type {HistoryRow[]} */
  let rows = [];

  try {
    // Convert ISO timestamp strings to microseconds to match the format used in moz_places
    const startUs = isoToMicroseconds(startTs);
    const endUs = isoToMicroseconds(endTs);

    const distanceThreshold = Services.prefs.getFloatPref(
      "places.semanticHistory.distanceThreshold",
      0.6
    );

    const semanticManager = lazy.getPlacesSemanticHistoryManager();

    // If semantic search cannot be used or we don't have enough entries, always
    // fall back to plain time-range search.
    const canUseSemantic =
      semanticManager.canUseSemanticSearch &&
      (await semanticManager.hasSufficientEntriesForSearching());

    if (!searchTerm?.trim()) {
      // Plain time-range search (no searchTerm)
      rows = await searchBrowsingHistoryTimeRange({
        startTs: startUs,
        endTs: endUs,
        historyLimit,
      });
    } else if (canUseSemantic) {
      // Semantic search
      rows = await searchBrowsingHistorySemantic({
        searchTerm,
        startTs: startUs,
        endTs: endUs,
        historyLimit,
        distanceThreshold,
      });
    } else {
      // Fallback to basic search without time window if semantic search not enable or insufficient records.
      rows = await searchBrowsingHistoryBasic({
        searchTerm,
        historyLimit,
      });
    }

    if (rows.length === 0) {
      return {
        searchTerm,
        count: 0,
        results: [],
        message: searchTerm
          ? `No browser history found for "${searchTerm}".`
          : "No browser history found in the requested time range.",
      };
    }

    // Return as JSON string with metadata
    return {
      searchTerm,
      count: rows.length,
      results: rows,
    };
  } catch (error) {
    console.error("Error searching browser history:", error);
    return {
      searchTerm,
      count: 0,
      results: [],
      error: `Error searching browser history: ${error.message}`,
    };
  }
}
