import { createHash } from "node:crypto";
import { getDatabase } from "./db.js";
import { getConfig } from "../config.js";
import { createLogger } from "../logger.js";
const log = createLogger("cache");
function sanitizeFtsQuery(q) {
  const trimmed = q.trim();
  if (!trimmed) return "";
  if (/^".*"$/.test(trimmed)) return trimmed;
  const tokens = trimmed.match(/"[^"]*"|\S+/g) ?? [];
  const RESERVED = /* @__PURE__ */ new Set(["AND", "OR", "NOT", "(", ")"]);
  return tokens.map((tok) => {
    if (tok.startsWith('"') && tok.endsWith('"')) return tok;
    if (RESERVED.has(tok)) return tok;
    if (/^\w+\*?$/.test(tok)) return tok;
    return `"${tok.replace(/"/g, '""')}"`;
  }).join(" ");
}
const TRACKING_PARAMS = /* @__PURE__ */ new Set([
  "utm_source",
  "utm_medium",
  "utm_campaign",
  "utm_content",
  "utm_term",
  "utm_id",
  "fbclid",
  "gclid",
  "msclkid",
  "mc_cid",
  "mc_eid"
]);
function normalizeUrl(url) {
  const parsed = new URL(url);
  parsed.protocol = parsed.protocol.toLowerCase();
  parsed.hostname = parsed.hostname.toLowerCase().replace(/^www\./, "");
  for (const key of [...parsed.searchParams.keys()]) {
    if (TRACKING_PARAMS.has(key) || key.startsWith("utm_")) {
      parsed.searchParams.delete(key);
    }
  }
  parsed.searchParams.sort();
  let result = parsed.toString();
  if (parsed.pathname !== "/" && result.endsWith("/")) {
    result = result.slice(0, -1);
  }
  if (parsed.pathname === "/" && !parsed.search && !parsed.hash) {
    result = result.replace(/\/$/, "");
  }
  return result;
}
function toIsoSeconds(date) {
  return date.toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
}
function cacheContent(result, extraction) {
  try {
    const db = getDatabase();
    const config = getConfig();
    const normalizedUrl = normalizeUrl(result.finalUrl || result.url);
    const contentHash = createHash("sha256").update(extraction.markdown).digest("hex");
    const now = /* @__PURE__ */ new Date();
    const expiresAt = new Date(now.getTime() + config.cacheTtlContent * 1e3);
    const stmt = db.prepare(`
      INSERT OR REPLACE INTO url_cache (
        url, normalized_url, title, markdown, raw_html,
        metadata, links, images, fetch_method, extractor_used,
        content_hash, fetched_at, expires_at, http_status
      )
      VALUES (
        @url, @normalizedUrl, @title, @markdown, @rawHtml,
        @metadata, @links, @images, @fetchMethod, @extractorUsed,
        @contentHash, @fetchedAt, @expiresAt, @httpStatus
      )
    `);
    stmt.run({
      url: result.url,
      normalizedUrl,
      title: extraction.title,
      markdown: extraction.markdown,
      rawHtml: result.html,
      metadata: JSON.stringify(extraction.metadata),
      links: JSON.stringify(extraction.links),
      images: JSON.stringify(extraction.images),
      fetchMethod: result.method,
      extractorUsed: extraction.extractor,
      contentHash,
      fetchedAt: toIsoSeconds(now),
      expiresAt: toIsoSeconds(expiresAt),
      // Slice S1 (C2): persist upstream status so cache lookups can branch
      // on 200 vs 404 vs 5xx instead of trusting body-hash alone.
      httpStatus: typeof result.statusCode === "number" ? result.statusCode : null
    });
  } catch (err) {
    log.warn("cacheContent failed", {
      url: result.url,
      finalUrl: result.finalUrl,
      error: err instanceof Error ? err.message : String(err)
    });
  }
}
function rowToCachedContent(row) {
  return {
    id: row.id,
    url: row.url,
    normalizedUrl: row.normalized_url,
    title: row.title,
    markdown: row.markdown,
    rawHtml: row.raw_html,
    metadata: row.metadata,
    links: row.links,
    images: row.images,
    fetchMethod: row.fetch_method,
    extractorUsed: row.extractor_used,
    contentHash: row.content_hash,
    fetchedAt: row.fetched_at,
    expiresAt: row.expires_at,
    httpStatus: row.http_status ?? null
  };
}
function getCachedContent(url) {
  const db = getDatabase();
  const normalizedUrl = normalizeUrl(url);
  const row = db.prepare(`
    SELECT * FROM url_cache WHERE url = ? OR normalized_url = ? LIMIT 1
  `).get(url, normalizedUrl);
  return row ? rowToCachedContent(row) : null;
}
function getCachedContentByNormalizedUrl(normalizedUrl) {
  const db = getDatabase();
  const row = db.prepare(
    "SELECT * FROM url_cache WHERE normalized_url = ? LIMIT 1"
  ).get(normalizedUrl);
  return row ? rowToCachedContent(row) : null;
}
function getHashForNormalizedUrl(normalizedUrl) {
  const db = getDatabase();
  const row = db.prepare(
    "SELECT content_hash FROM url_cache WHERE normalized_url = ? LIMIT 1"
  ).get(normalizedUrl);
  return row?.content_hash ?? null;
}
function getHttpStatusForNormalizedUrl(normalizedUrl) {
  try {
    const db = getDatabase();
    const row = db.prepare(
      "SELECT http_status FROM url_cache WHERE normalized_url = ? LIMIT 1"
    ).get(normalizedUrl);
    return row?.http_status ?? null;
  } catch {
    return null;
  }
}
function getHashAndStatusForNormalizedUrl(normalizedUrl) {
  try {
    const db = getDatabase();
    const row = db.prepare(
      "SELECT content_hash, http_status FROM url_cache WHERE normalized_url = ? LIMIT 1"
    ).get(normalizedUrl);
    if (!row) return { hash: null, status: null };
    return {
      hash: row.content_hash ?? null,
      status: row.http_status ?? null
    };
  } catch {
    return { hash: null, status: null };
  }
}
function getMarkdownForNormalizedUrl(normalizedUrl) {
  const db = getDatabase();
  const row = db.prepare(
    "SELECT markdown FROM url_cache WHERE normalized_url = ? LIMIT 1"
  ).get(normalizedUrl);
  return row ? row.markdown : null;
}
function isExpired(cached) {
  if (!cached.expiresAt) return false;
  return new Date(cached.expiresAt).getTime() < Date.now();
}
function isCacheUsable(cached, opts = {}) {
  if (!cached.expiresAt) return { usable: true, stale: false };
  const expiresMs = new Date(cached.expiresAt).getTime();
  const now = Date.now();
  if (expiresMs >= now) return { usable: true, stale: false };
  const staleMaxMs = (opts.staleMaxSeconds ?? 0) * 1e3;
  if (now - expiresMs <= staleMaxMs) return { usable: true, stale: true };
  return { usable: false, stale: false };
}
function searchCache(query) {
  const db = getDatabase();
  const rows = db.prepare(`
    SELECT url_cache.*
    FROM url_cache
    JOIN url_cache_fts ON url_cache.id = url_cache_fts.rowid
    WHERE url_cache_fts MATCH ?
    ORDER BY rank
  `).all(sanitizeFtsQuery(query));
  return rows.map(rowToCachedContent);
}
function normaliseDomainList(list) {
  if (!list || list.length === 0) return null;
  const lower = list.map((d) => d.toLowerCase().trim()).filter((d) => d.length > 0);
  if (lower.length === 0) return null;
  return [...new Set(lower)].sort();
}
function hasAnyFilter(filters) {
  if (!filters) return false;
  return filters.category != null || (filters.include_domains?.length ?? 0) > 0 || (filters.exclude_domains?.length ?? 0) > 0 || filters.max_results != null || filters.from_date != null || filters.to_date != null || filters.language != null || filters.time_range != null || filters.exact_match != null;
}
function buildSearchCacheKey(query, filters) {
  const q = query.toLowerCase().trim();
  if (!hasAnyFilter(filters)) return q;
  const fingerprint = {
    category: filters.category ?? null,
    include_domains: normaliseDomainList(filters.include_domains),
    exclude_domains: normaliseDomainList(filters.exclude_domains),
    max_results: filters.max_results ?? null,
    from_date: filters.from_date ?? null,
    to_date: filters.to_date ?? null,
    language: filters.language ?? null,
    time_range: filters.time_range ?? null,
    exact_match: filters.exact_match ?? null
  };
  return `${query}\0${JSON.stringify(fingerprint)}`;
}
function cacheSearchResults(query, results, enginesUsed) {
  const db = getDatabase();
  const config = getConfig();
  const queryHash = createHash("sha256").update(query.toLowerCase().trim()).digest("hex");
  const now = /* @__PURE__ */ new Date();
  const expiresAt = new Date(now.getTime() + config.cacheTtlSearch * 1e3);
  const stmt = db.prepare(`
    INSERT OR REPLACE INTO search_cache (query, query_hash, results, engines_used, searched_at, expires_at)
    VALUES (@query, @queryHash, @results, @enginesUsed, @searchedAt, @expiresAt)
  `);
  stmt.run({
    query,
    queryHash,
    results: JSON.stringify(results),
    enginesUsed: JSON.stringify(enginesUsed),
    searchedAt: toIsoSeconds(now),
    expiresAt: toIsoSeconds(expiresAt)
  });
}
function getCachedSearchResults(query, opts = {}) {
  const db = getDatabase();
  const queryHash = createHash("sha256").update(query.toLowerCase().trim()).digest("hex");
  const row = db.prepare(
    "SELECT query, results, engines_used, searched_at, expires_at FROM search_cache WHERE query_hash = ? LIMIT 1"
  ).get(queryHash);
  if (!row) return null;
  if (row.expires_at) {
    const expiresMs = new Date(row.expires_at).getTime();
    const now = Date.now();
    if (expiresMs < now) {
      const staleMaxMs = (opts.staleMaxSeconds ?? 0) * 1e3;
      if (now - expiresMs > staleMaxMs) return null;
      return {
        query: row.query,
        results: JSON.parse(row.results),
        engines_used: JSON.parse(row.engines_used),
        searched_at: row.searched_at,
        stale: true
      };
    }
  }
  return {
    query: row.query,
    results: JSON.parse(row.results),
    engines_used: JSON.parse(row.engines_used),
    searched_at: row.searched_at
  };
}
const DEFAULT_FILTERED_LIMIT = 100;
function searchCacheFiltered(options) {
  const db = getDatabase();
  const conditions = [];
  const params = [];
  let fromClause = "url_cache";
  if (options.query) {
    fromClause = "url_cache JOIN url_cache_fts ON url_cache.id = url_cache_fts.rowid";
    conditions.push("url_cache_fts MATCH ?");
    params.push(sanitizeFtsQuery(options.query));
  }
  if (options.urlPattern) {
    conditions.push("url_cache.normalized_url GLOB ?");
    params.push(options.urlPattern);
  }
  if (options.since) {
    conditions.push("url_cache.fetched_at > datetime(?)");
    params.push(options.since);
  }
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
  const orderClause = options.query ? "ORDER BY rank" : "ORDER BY url_cache.fetched_at DESC";
  const limit = Math.max(1, Math.floor(options.limit ?? DEFAULT_FILTERED_LIMIT));
  const sql = `SELECT url_cache.* FROM ${fromClause} ${whereClause} ${orderClause} LIMIT ?`;
  const rows = db.prepare(sql).all(...params, limit);
  return rows.map(rowToCachedContent);
}
function ftsSearchRanked(query, limit) {
  if (!query.trim() || limit <= 0) return [];
  const db = getDatabase();
  const rows = db.prepare(`
    SELECT url_cache.normalized_url AS url, url_cache_fts.rank AS rank
    FROM url_cache
    JOIN url_cache_fts ON url_cache.id = url_cache_fts.rowid
    WHERE url_cache_fts MATCH ?
    ORDER BY url_cache_fts.rank
    LIMIT ?
  `).all(sanitizeFtsQuery(query), limit);
  return rows.map((r) => ({ url: r.url, score: -r.rank }));
}
function clearCacheEntries(options) {
  const db = getDatabase();
  const conditions = [];
  const params = [];
  if (options.query) {
    conditions.push(
      "id IN (SELECT url_cache.id FROM url_cache JOIN url_cache_fts ON url_cache.id = url_cache_fts.rowid WHERE url_cache_fts MATCH ?)"
    );
    params.push(sanitizeFtsQuery(options.query));
  }
  if (options.urlPattern) {
    conditions.push("normalized_url GLOB ?");
    params.push(options.urlPattern);
  }
  if (options.since) {
    conditions.push("fetched_at > datetime(?)");
    params.push(options.since);
  }
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
  const sql = `DELETE FROM url_cache ${whereClause}`;
  const result = db.prepare(sql).run(...params);
  return result.changes;
}
function countCachedUrlsForDomain(domain) {
  const db = getDatabase();
  const normalized = domain.toLowerCase().replace(/^www\./, "");
  const stmt = db.prepare(`
    SELECT COUNT(*) AS n FROM url_cache
    WHERE url LIKE 'http://' || ? || '/%'
       OR url LIKE 'https://' || ? || '/%'
       OR url LIKE 'http://www.' || ? || '/%'
       OR url LIKE 'https://www.' || ? || '/%'
       OR url = 'http://' || ?
       OR url = 'https://' || ?
       OR url = 'http://www.' || ?
       OR url = 'https://www.' || ?
  `);
  const row = stmt.get(
    normalized,
    normalized,
    normalized,
    normalized,
    normalized,
    normalized,
    normalized,
    normalized
  );
  return row.n;
}
function getCacheStats() {
  const db = getDatabase();
  const row = db.prepare(`
    SELECT
      COUNT(*) as total_urls,
      COALESCE(SUM(LENGTH(markdown) + LENGTH(COALESCE(raw_html, ''))), 0) as total_bytes,
      MIN(fetched_at) as oldest,
      MAX(fetched_at) as newest
    FROM url_cache
  `).get();
  return {
    total_urls: row.total_urls,
    total_size_mb: Math.round(row.total_bytes / (1024 * 1024) * 1e6) / 1e6,
    oldest: row.oldest ?? "",
    newest: row.newest ?? ""
  };
}
function updateCacheEmbedding(url, embedding, model, dims) {
  try {
    const db = getDatabase();
    let normalized;
    try {
      normalized = normalizeUrl(url);
    } catch {
      normalized = url;
    }
    const result = db.prepare(`
      UPDATE url_cache
      SET embedding = ?, embedding_model = ?, embedding_dims = ?, updated_at = datetime('now')
      WHERE normalized_url = ?
    `).run(embedding, model, dims, normalized);
    return result.changes > 0;
  } catch {
    return false;
  }
}
function getEmbeddingForUrl(url, modelId) {
  try {
    const db = getDatabase();
    let normalized;
    try {
      normalized = normalizeUrl(url);
    } catch {
      normalized = url;
    }
    const row = db.prepare(`
      SELECT embedding, embedding_model, embedding_dims
      FROM url_cache
      WHERE (url = ? OR normalized_url = ?) AND embedding IS NOT NULL
      LIMIT 1
    `).get(url, normalized);
    if (!row) return null;
    if (modelId !== void 0 && row.embedding_model !== modelId) return null;
    return {
      embedding: row.embedding,
      model: row.embedding_model,
      dims: row.embedding_dims
    };
  } catch {
    return null;
  }
}
function getDomainRouting(domain) {
  try {
    const db = getDatabase();
    const row = db.prepare(
      `SELECT domain, prefer_playwright, http_failures,
              prefer_tls_impersonation, tls_success_count, last_updated
       FROM domain_routing WHERE domain = ? LIMIT 1`
    ).get(domain);
    if (!row) return null;
    return {
      domain: row.domain,
      preferPlaywright: (row.prefer_playwright ?? 0) === 1,
      httpFailures: row.http_failures ?? 0,
      preferTlsImpersonation: (row.prefer_tls_impersonation ?? 0) === 1,
      tlsSuccessCount: row.tls_success_count ?? 0,
      lastUpdated: row.last_updated ?? void 0
    };
  } catch (err) {
    log.warn("getDomainRouting failed", { domain, error: err instanceof Error ? err.message : String(err) });
    return null;
  }
}
function recordTlsImpersonationSuccess(domain, threshold) {
  try {
    const db = getDatabase();
    const preferOnInsert = threshold <= 1 ? 1 : 0;
    db.prepare(`
      INSERT INTO domain_routing (
        domain, prefer_playwright, http_failures,
        prefer_tls_impersonation, tls_success_count, last_updated
      )
      VALUES (?, 0, 0, ?, 1, datetime('now'))
      ON CONFLICT(domain) DO UPDATE SET
        tls_success_count = tls_success_count + 1,
        last_updated = datetime('now'),
        prefer_tls_impersonation = CASE
          WHEN tls_success_count + 1 >= ?
          THEN 1
          ELSE prefer_tls_impersonation
        END
    `).run(domain, preferOnInsert, threshold);
    return getDomainRouting(domain);
  } catch (err) {
    log.warn("recordTlsImpersonationSuccess failed", {
      domain,
      error: err instanceof Error ? err.message : String(err)
    });
    return null;
  }
}
function getAllEmbeddings(modelId) {
  try {
    const db = getDatabase();
    const rows = modelId !== void 0 ? db.prepare(`
          SELECT normalized_url, embedding, embedding_model, embedding_dims
          FROM url_cache
          WHERE embedding IS NOT NULL AND embedding_model = ?
        `).all(modelId) : db.prepare(`
          SELECT normalized_url, embedding, embedding_model, embedding_dims
          FROM url_cache
          WHERE embedding IS NOT NULL
        `).all();
    return rows.map((r) => ({
      normalizedUrl: r.normalized_url,
      embedding: r.embedding,
      model: r.embedding_model,
      dims: r.embedding_dims
    }));
  } catch {
    return [];
  }
}
export {
  buildSearchCacheKey,
  cacheContent,
  cacheSearchResults,
  clearCacheEntries,
  countCachedUrlsForDomain,
  ftsSearchRanked,
  getAllEmbeddings,
  getCacheStats,
  getCachedContent,
  getCachedContentByNormalizedUrl,
  getCachedSearchResults,
  getDomainRouting,
  getEmbeddingForUrl,
  getHashAndStatusForNormalizedUrl,
  getHashForNormalizedUrl,
  getHttpStatusForNormalizedUrl,
  getMarkdownForNormalizedUrl,
  isCacheUsable,
  isExpired,
  normalizeUrl,
  recordTlsImpersonationSuccess,
  sanitizeFtsQuery,
  searchCache,
  searchCacheFiltered,
  updateCacheEmbedding
};
//# sourceMappingURL=store.js.map