/**
 * InMemoryJsonStore — concrete VectorStore backed by a single JSON file.
 *
 * Design choices:
 *   - Vectors held in memory as Float32Array (~3 KB each at 768 dims).
 *     At our scale (~10K vectors per agent) that's <30 MB resident.
 *   - Cosine similarity reduces to a dot product because the base-class
 *     normalizes every stored + queried vector. Tight loop in V8.
 *   - Single-file persistence with atomic write (write to `.tmp`, then
 *     fs.rename — atomic on the same filesystem). No partial reads.
 *   - File format is human-inspectable JSON (verbose but debuggable).
 *     The 4× size penalty vs binary is fine until we hit 100K+ vectors,
 *     at which point sqlite-vec is the planned drop-in.
 *   - Substring search is plain `String.includes()` on `metadata.text`
 *     after lowercasing both sides. No regex, matches the codebase's
 *     "native string ops" preference.
 *   - Fingerprint/dimension drift on load: silently empties the store
 *     (we treat the on-disk file as garbage). The orchestrator's
 *     responsibility is to backfill from source.
 *
 * Concurrency: not safe for multiple writers on the same file from
 * different processes. Loxia is single-process per agent state, so
 * this is fine. If we ever multiprocess, switch to sqlite-vec.
 */

import fsp from 'fs/promises';
import path from 'path';
import { reciprocalRankFusion } from './storeInterface.js';

const FILE_VERSION = 1;

export class InMemoryJsonStore {
  /**
   * @param {object} opts
   * @param {string} opts.filePath         - Absolute path to the .vec.json file.
   * @param {number} opts.dimensions       - Expected vector dimensionality.
   * @param {string} opts.modelFingerprint - From EmbeddingProvider.getModelFingerprint().
   * @param {object} [opts.logger]
   */
  constructor({ filePath, dimensions, modelFingerprint, logger = null }) {
    if (!filePath) throw new Error('InMemoryJsonStore requires filePath');
    if (!Number.isInteger(dimensions) || dimensions <= 0) {
      throw new Error('InMemoryJsonStore requires positive integer dimensions');
    }
    if (!modelFingerprint) throw new Error('InMemoryJsonStore requires modelFingerprint');

    this._filePath = filePath;
    this._dimensions = dimensions;
    this._modelFingerprint = modelFingerprint;
    this._logger = logger;

    /** @type {Array<{id: string, vector: Float32Array, metadata: object}>} */
    this._rows = [];
    /** @type {Map<string, number>} id → index into _rows */
    this._idIndex = new Map();
    this._loaded = false;
  }

  /**
   * Load from disk. Idempotent; safe to call repeatedly. If the file
   * doesn't exist, or its fingerprint / dimensions don't match the
   * constructor args, the store starts empty (caller re-embeds).
   */
  async load() {
    if (this._loaded) return;
    this._loaded = true;

    let raw;
    try {
      raw = await fsp.readFile(this._filePath, 'utf8');
    } catch (err) {
      if (err?.code === 'ENOENT') return; // first run, file doesn't exist
      this._logger?.warn?.('[vectorStore] failed to read store; starting empty', { path: this._filePath, err: err.message });
      return;
    }

    let parsed;
    try {
      parsed = JSON.parse(raw);
    } catch (err) {
      this._logger?.warn?.('[vectorStore] corrupt JSON; starting empty', { path: this._filePath, err: err.message });
      return;
    }

    if (parsed?.version !== FILE_VERSION) {
      this._logger?.info?.('[vectorStore] file version mismatch; starting empty', {
        path: this._filePath, found: parsed?.version, expected: FILE_VERSION,
      });
      return;
    }
    if (parsed.dimensions !== this._dimensions) {
      this._logger?.info?.('[vectorStore] dimension mismatch; starting empty (re-embed required)', {
        path: this._filePath, fileDims: parsed.dimensions, expectedDims: this._dimensions,
      });
      return;
    }
    if (parsed.modelFingerprint !== this._modelFingerprint) {
      this._logger?.info?.('[vectorStore] fingerprint mismatch; starting empty (re-embed required)', {
        path: this._filePath, fileFp: parsed.modelFingerprint, expectedFp: this._modelFingerprint,
      });
      return;
    }
    if (!Array.isArray(parsed.rows)) return;

    for (const r of parsed.rows) {
      if (!r || typeof r.id !== 'string' || !Array.isArray(r.vector)) continue;
      if (r.vector.length !== this._dimensions) continue;
      this._rows.push({
        id: r.id,
        vector: Float32Array.from(r.vector),
        metadata: r.metadata || {},
      });
      this._idIndex.set(r.id, this._rows.length - 1);
    }
  }

  async stats() {
    let newestTs = null;
    for (const r of this._rows) {
      const ts = r.metadata?.createdAt;
      if (typeof ts === 'string' && (!newestTs || ts > newestTs)) {
        newestTs = ts;
      }
    }
    return {
      count: this._rows.length,
      dimensions: this._dimensions,
      modelFingerprint: this._modelFingerprint,
      lastIndexedAt: newestTs,
    };
  }

  async upsert(id, vector, metadata = {}) {
    this._assertLoaded();
    this._validateVector(vector);
    if (typeof id !== 'string' || !id) throw new Error('upsert: id must be a non-empty string');

    const existing = this._idIndex.get(id);
    if (existing !== undefined) {
      this._rows[existing] = { id, vector, metadata };
    } else {
      this._rows.push({ id, vector, metadata });
      this._idIndex.set(id, this._rows.length - 1);
    }
    await this._flush();
  }

  async upsertBatch(rows) {
    this._assertLoaded();
    if (!Array.isArray(rows)) throw new Error('upsertBatch: rows must be an array');
    for (const r of rows) {
      this._validateVector(r?.vector);
      if (typeof r?.id !== 'string' || !r.id) throw new Error('upsertBatch: each row needs an id');
      const existing = this._idIndex.get(r.id);
      if (existing !== undefined) {
        this._rows[existing] = { id: r.id, vector: r.vector, metadata: r.metadata || {} };
      } else {
        this._rows.push({ id: r.id, vector: r.vector, metadata: r.metadata || {} });
        this._idIndex.set(r.id, this._rows.length - 1);
      }
    }
    await this._flush();
  }

  async delete(id) {
    this._assertLoaded();
    const idx = this._idIndex.get(id);
    if (idx === undefined) return;
    // Swap-remove for O(1). Update the moved row's index.
    const last = this._rows.length - 1;
    if (idx !== last) {
      this._rows[idx] = this._rows[last];
      this._idIndex.set(this._rows[idx].id, idx);
    }
    this._rows.pop();
    this._idIndex.delete(id);
    await this._flush();
  }

  /**
   * Delete every row whose metadata satisfies `predicate`. Used when a
   * single source object (e.g. a conversation message) produced multiple
   * chunks under the convention `${sourceId}#${chunkIndex}` — calling
   * `delete()` per chunk would require N round-trips and N flushes.
   *
   * Returns the number of rows removed. One atomic flush at the end.
   *
   * @param {(metadata: object) => boolean} predicate
   * @returns {Promise<number>}
   */
  async deleteWhere(predicate) {
    this._assertLoaded();
    if (typeof predicate !== 'function') {
      throw new Error('deleteWhere: predicate must be a function');
    }
    let removed = 0;
    // Walk from the tail so swap-remove indices stay valid as we splice.
    for (let i = this._rows.length - 1; i >= 0; i--) {
      const row = this._rows[i];
      let match = false;
      try { match = !!predicate(row.metadata); } catch { match = false; }
      if (!match) continue;
      const last = this._rows.length - 1;
      if (i !== last) {
        this._rows[i] = this._rows[last];
        this._idIndex.set(this._rows[i].id, i);
      }
      this._rows.pop();
      this._idIndex.delete(row.id);
      removed++;
    }
    if (removed > 0) await this._flush();
    return removed;
  }

  /**
   * Cosine query. Vectors are normalized, so cosine = dot product.
   * Linear scan — fine up to ~100K rows. For larger corpora we swap
   * to sqlite-vec (HNSW) without changing this interface.
   *
   * @param {Float32Array} queryVector
   * @param {object} [opts]
   * @param {number} [opts.topK=10]
   * @param {(metadata: object) => boolean} [opts.filter]
   * @param {(baseScore: number, metadata: object) => number} [opts.scoring]
   *        Optional per-row score adjuster — used by the multi-signal
   *        ranking layer (recency + access boosts). See
   *        `vectorStore/scoring.js`. Identity when omitted.
   */
  async query(queryVector, { topK = 10, filter = null, scoring = null } = {}) {
    this._assertLoaded();
    this._validateVector(queryVector);
    const adjust = typeof scoring === 'function' ? scoring : null;

    const scored = [];
    for (const row of this._rows) {
      if (filter && !filter(row.metadata)) continue;
      const base = dot(queryVector, row.vector);
      const score = adjust ? adjust(base, row.metadata) : base;
      scored.push({ id: row.id, score, metadata: row.metadata });
    }
    scored.sort((a, b) => b.score - a.score);
    return scored.slice(0, topK);
  }

  /**
   * Hybrid query: rank-fuse semantic results with substring matches
   * over `metadata.text`. RRF is used because raw scores live on
   * different scales (cosine vs boolean) and RRF only needs ranks.
   *
   * @param {Float32Array} queryVector
   * @param {string}       queryText  - For substring matching.
   * @param {object}       [opts]
   * @param {number}       [opts.topK=10]
   * @param {Function}     [opts.filter] - Same shape as query().
   * @returns {Promise<Array<{id, score, metadata}>>}
   */
  async hybridQuery(queryVector, queryText, { topK = 10, filter = null, scoring = null } = {}) {
    this._assertLoaded();
    // The semantic candidate set is the one that benefits from scoring
    // adjustments. The substring path is rank-only, so reweighting
    // wouldn't affect RRF anyway.
    const semantic = await this.query(queryVector, { topK: topK * 4, filter, scoring });
    const substring = await this._substringMatches(queryText, { topK: topK * 4, filter });
    const fused = reciprocalRankFusion([semantic, substring], { topK });
    // Hydrate ids back to full rows.
    return fused.map(({ id, score }) => {
      const idx = this._idIndex.get(id);
      const row = idx !== undefined ? this._rows[idx] : null;
      return { id, score, metadata: row?.metadata || {} };
    });
  }

  /** @private */
  async _substringMatches(queryText, { topK, filter }) {
    if (typeof queryText !== 'string' || !queryText) return [];
    const needle = queryText.toLowerCase();
    const matches = [];
    for (const row of this._rows) {
      if (filter && !filter(row.metadata)) continue;
      const haystack = String(row.metadata?.text || '').toLowerCase();
      if (haystack.length === 0) continue;
      const idx = haystack.indexOf(needle);
      if (idx === -1) continue;
      // Score = inverse position (earlier matches rank higher). Used only
      // for ordering inside this list — final fusion ignores raw scores.
      matches.push({ id: row.id, score: 1 / (1 + idx) });
    }
    matches.sort((a, b) => b.score - a.score);
    return matches.slice(0, topK);
  }

  async drop() {
    this._assertLoaded();
    this._rows = [];
    this._idIndex.clear();
    try {
      await fsp.unlink(this._filePath);
    } catch (err) {
      if (err?.code !== 'ENOENT') {
        this._logger?.warn?.('[vectorStore] drop(): could not unlink file', { path: this._filePath, err: err.message });
      }
    }
  }

  /**
   * Force a flush. Public because callers occasionally want to ensure
   * persistence before a critical handoff (e.g. shutdown).
   */
  async flush() {
    await this._flush();
  }

  /** @private */
  _assertLoaded() {
    if (!this._loaded) {
      throw new Error('VectorStore not loaded — call await store.load() first');
    }
  }

  /** @private */
  _validateVector(v) {
    if (!(v instanceof Float32Array)) {
      throw new Error('vector must be a Float32Array');
    }
    if (v.length !== this._dimensions) {
      throw new Error(`vector has wrong dimensions (got ${v.length}, expected ${this._dimensions})`);
    }
  }

  /** @private */
  async _flush() {
    const payload = {
      version: FILE_VERSION,
      dimensions: this._dimensions,
      modelFingerprint: this._modelFingerprint,
      savedAt: new Date().toISOString(),
      rows: this._rows.map(r => ({
        id: r.id,
        vector: Array.from(r.vector),  // JSON can't hold typed arrays
        metadata: r.metadata,
      })),
    };
    const tmpPath = `${this._filePath}.tmp`;
    await fsp.mkdir(path.dirname(this._filePath), { recursive: true });
    await fsp.writeFile(tmpPath, JSON.stringify(payload));
    await fsp.rename(tmpPath, this._filePath);
  }
}

/** Dot product over two equal-length Float32Arrays. Hot path — no allocation. */
function dot(a, b) {
  let s = 0;
  for (let i = 0; i < a.length; i++) s += a[i] * b[i];
  return s;
}
