/**
 * scoring.js — multiplicative score adjusters for VectorStore.query().
 *
 * Why this exists: pure cosine similarity is "topical proximity" but
 * misses signals that turn out to matter a lot in production —
 * particularly recency (a 6-day-old memory is usually more relevant
 * than an 18-month-old one with the same cosine) and access frequency
 * (memories the agent has called up before tend to be useful again).
 *
 * Design (lit-aligned, 2025–2026 consensus):
 *   - SIGMOID recency with a 1-week half-life. This is what production
 *     systems (Mem0, ChatGPT memory) have converged on. Old exponential
 *     decays are too aggressive on medium-old items; sigmoid keeps
 *     "two weeks old" close to neutral, "two months old" softly damped.
 *   - LOG access boost: 1 + weight * ln(1 + accessCount). Saturating so
 *     a single hot memory doesn't dominate everything.
 *   - Multiplicative composition, clamped to a sane range so signals
 *     can't blow up the score or drive it to zero. Range
 *     [minFactor, maxFactor] = [0.3, 1.5] in production-tested defaults.
 *
 * Backwards compatible: `makeScoringFn()` with no args returns the
 * identity scorer. Indexers opt in to recency / access boost per surface.
 *
 * Citations + numbers come from the literature review above:
 *   - Mem0 (2026): "production setup weights semantic similarity at 60%,
 *     recency at 10%, access frequency at 10%"
 *   - Mem0: "168-hour (1-week) half-life sigmoid"
 *   - Mem0: "access boost = 1 + 0.1 × ln(1 + access_count_30d)"
 *   - Mem0: "scaling factor between 0.3× and 1.5×"
 *   - FadeMem (arXiv 2601.18642): biologically-inspired decay is a
 *     soft demotion, not deletion — we treat it that way too.
 */

/**
 * Build a scoring function that adjusts base cosine scores.
 *
 * @param {object} [opts]
 * @param {object|boolean} [opts.recency]  Recency boost config. Pass `true` for
 *        defaults, an object to customise, or omit to disable.
 *        Defaults: { halfLifeDays: 7, minFactor: 0.3, maxFactor: 1.5 }
 * @param {object|boolean} [opts.access]   Access-frequency boost config.
 *        Defaults: { weight: 0.1, maxFactor: 1.5 }
 *        Reads `metadata.accessCount`; defaults to 0 if absent.
 * @param {number} [opts.now]              Override Date.now() for deterministic tests.
 * @returns {(baseScore: number, metadata: object) => number}
 */
export function makeScoringFn(opts = {}) {
  const recencyOpts = _normalizeRecency(opts.recency);
  const accessOpts = _normalizeAccess(opts.access);
  const nowMs = Number.isFinite(opts.now) ? opts.now : Date.now();

  // Identity if nothing's enabled — keeps `scoring` a no-cost option.
  if (!recencyOpts && !accessOpts) {
    return (baseScore) => baseScore;
  }

  return function score(baseScore, metadata) {
    if (!metadata) return baseScore;
    let factor = 1;

    if (recencyOpts) {
      const tsRaw = metadata.updatedAt || metadata.createdAt || metadata.indexedAt;
      if (tsRaw) {
        const ts = Date.parse(tsRaw);
        if (Number.isFinite(ts)) {
          const ageDays = Math.max(0, (nowMs - ts) / DAY_MS);
          factor *= sigmoidRecencyFactor(ageDays, recencyOpts);
        }
      }
    }

    if (accessOpts) {
      const c = Number(metadata.accessCount) || 0;
      if (c > 0) {
        const boost = 1 + accessOpts.weight * Math.log(1 + c);
        factor *= Math.min(boost, accessOpts.maxFactor);
      }
    }

    return baseScore * factor;
  };
}

/**
 * Sigmoid recency factor mapped to [minFactor, maxFactor].
 *
 * Shape:
 *   age = 0           → ~maxFactor   (very recent → strongly boost)
 *   age = halfLife    → midpoint     (neutral)
 *   age = 2*halfLife  → ~minFactor   (old → strongly damp)
 *   age → ∞           →  minFactor
 *
 * The sharpness of the transition is controlled by `halfLife/4` so the
 * curve isn't too gentle. Exported for tests.
 *
 * @param {number} ageDays
 * @param {{halfLifeDays:number,minFactor:number,maxFactor:number}} opts
 * @returns {number}
 */
export function sigmoidRecencyFactor(ageDays, opts) {
  const k = opts.halfLifeDays;
  const scale = Math.max(0.5, k / 4);
  // s ∈ (0,1); 1 when very recent, 0.5 at half-life, ~0 when very old.
  const u = (k - ageDays) / scale;
  const s = 1 / (1 + Math.exp(-u));
  return opts.minFactor + (opts.maxFactor - opts.minFactor) * s;
}

const DAY_MS = 24 * 60 * 60 * 1000;

function _normalizeRecency(input) {
  if (!input) return null;
  const r = input === true ? {} : input;
  return {
    halfLifeDays: Number.isFinite(r.halfLifeDays) && r.halfLifeDays > 0 ? r.halfLifeDays : 7,
    minFactor:    Number.isFinite(r.minFactor) ? r.minFactor : 0.3,
    maxFactor:    Number.isFinite(r.maxFactor) ? r.maxFactor : 1.5,
  };
}

function _normalizeAccess(input) {
  if (!input) return null;
  const a = input === true ? {} : input;
  return {
    weight:    Number.isFinite(a.weight) ? a.weight : 0.1,
    maxFactor: Number.isFinite(a.maxFactor) ? a.maxFactor : 1.5,
  };
}
