import crypto from 'node:crypto';

/**
 * PostgreSQL + pgvector backed experience store (etat C, polyglot mode).
 *
 * Same contract as the MySQL `createExperienceService` (record / search /
 * reflect) so callers (mindspace-agent-runner) are backend-agnostic. The MySQL
 * business DB is untouched; only this shared-experience workload lives on PG.
 *
 * Semantic search via pgvector is used when an `embed(text) => number[]` function
 * is supplied; otherwise it degrades to keyword (ILIKE) + recency, identical in
 * spirit to the MySQL version, so it is useful before an embedding provider is wired.
 *
 * The `pg` driver is imported dynamically so this module loads even when the
 * dependency is not yet installed; it is only required once the service is built.
 *
 * @param {object} options
 * @param {string} options.connectionString - e.g. process.env.EXPERIENCE_PG_URL
 * @param {(text: string) => Promise<number[]>} [options.embed] - embedding fn
 * @param {number} [options.embeddingDim=1536] - vector dimension (must match embed)
 * @param {() => number} [options.now]
 * @param {number} [options.recencyHalfLifeMs]
 */
export async function createPgExperienceService(options = {}) {
  const connectionString = options.connectionString;
  if (!connectionString) {
    throw new Error('createPgExperienceService 需要 connectionString（建议用 EXPERIENCE_PG_URL）');
  }
  const now = options.now ?? (() => Date.now());
  const embed = options.embed ?? null;
  const embeddingDim = Math.max(1, Number(options.embeddingDim ?? 1536));
  const halfLifeMs = Math.max(
    60_000,
    Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1000),
  );

  const { default: pg } = await import('pg');
  const pool = new pg.Pool({ connectionString, max: Number(options.poolMax ?? 10) });

  // Idempotent setup. pgvector must be available on the server (CREATE EXTENSION).
  await pool.query('CREATE EXTENSION IF NOT EXISTS vector');
  await pool.query(`
    CREATE TABLE IF NOT EXISTS h5_experience (
      id UUID PRIMARY KEY,
      scope TEXT NOT NULL DEFAULT 'global',
      kind TEXT NOT NULL DEFAULT 'lesson',
      title TEXT NOT NULL,
      body TEXT NOT NULL,
      tags JSONB NOT NULL DEFAULT '[]'::jsonb,
      source_session_id TEXT,
      source_user_id TEXT,
      use_count BIGINT NOT NULL DEFAULT 0,
      embedding vector(${embeddingDim}),
      created_at BIGINT NOT NULL,
      updated_at BIGINT NOT NULL
    )
  `);
  await pool.query(
    'CREATE INDEX IF NOT EXISTS idx_h5_experience_scope_updated ON h5_experience (scope, updated_at DESC)',
  );
  // Trigram-ish keyword fallback index (btree on lower(title)) is cheap; the
  // ANN vector index is created lazily once rows have embeddings.
  await pool.query(
    "CREATE INDEX IF NOT EXISTS idx_h5_experience_embedding ON h5_experience USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)",
  ).catch(() => {
    // ivfflat needs the extension + may warn on empty table; non-fatal.
  });

  function normalizeTags(tags) {
    if (!Array.isArray(tags)) return [];
    return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))];
  }

  function toVectorLiteral(vec) {
    // pgvector accepts a string like '[0.1,0.2,...]'
    return `[${vec.map((n) => Number(n)).join(',')}]`;
  }

  function rowToExperience(row) {
    return {
      id: row.id,
      scope: row.scope,
      kind: row.kind,
      title: row.title,
      body: row.body,
      tags: Array.isArray(row.tags) ? row.tags : [],
      sourceSessionId: row.source_session_id ?? null,
      sourceUserId: row.source_user_id ?? null,
      useCount: Number(row.use_count ?? 0),
      createdAt: Number(row.created_at),
      updatedAt: Number(row.updated_at),
    };
  }

  async function record(input) {
    const title = String(input?.title ?? '').trim();
    const body = String(input?.body ?? '').trim();
    if (!title) throw experienceError('经验标题不能为空', 'invalid_experience_input');
    if (!body) throw experienceError('经验内容不能为空', 'invalid_experience_input');
    const id = crypto.randomUUID();
    const ts = now();
    const scope = String(input?.scope ?? 'global').trim() || 'global';
    const kind = String(input?.kind ?? 'lesson').trim() || 'lesson';
    const tags = normalizeTags(input?.tags);
    let embedding = null;
    if (embed) {
      try {
        const vec = await embed(`${title}\n${body}`);
        if (Array.isArray(vec) && vec.length === embeddingDim) embedding = toVectorLiteral(vec);
      } catch {
        embedding = null; // embedding failure must not block recording
      }
    }
    await pool.query(
      `INSERT INTO h5_experience
         (id, scope, kind, title, body, tags, source_session_id, source_user_id,
          use_count, embedding, created_at, updated_at)
       VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,0,$9::vector,$10,$11)`,
      [
        id,
        scope,
        kind,
        title,
        body,
        JSON.stringify(tags),
        input?.sourceSessionId ?? null,
        input?.sourceUserId ?? null,
        embedding,
        ts,
        ts,
      ],
    );
    return rowToExperience({
      id,
      scope,
      kind,
      title,
      body,
      tags,
      source_session_id: input?.sourceSessionId ?? null,
      source_user_id: input?.sourceUserId ?? null,
      use_count: 0,
      created_at: ts,
      updated_at: ts,
    });
  }

  async function search(query, { scope = 'global', limit = 5 } = {}) {
    const text = String(query ?? '').trim();
    if (!text) return [];
    const max = Math.max(1, limit);

    // Vector path: cosine distance ordering, recency as tiebreaker.
    if (embed) {
      try {
        const vec = await embed(text);
        if (Array.isArray(vec) && vec.length === embeddingDim) {
          const { rows } = await pool.query(
            `SELECT id, scope, kind, title, body, tags, source_session_id,
                    source_user_id, use_count, created_at, updated_at
             FROM h5_experience
             WHERE scope = $1 AND embedding IS NOT NULL
             ORDER BY embedding <=> $2::vector, updated_at DESC
             LIMIT $3`,
            [scope, toVectorLiteral(vec), max],
          );
          if (rows.length > 0) {
            await bumpUseCount(rows.map((r) => r.id));
            return rows.map(rowToExperience);
          }
        }
      } catch {
        // fall through to keyword path
      }
    }

    // Keyword fallback: ILIKE any term, ordered by recency.
    const terms = tokenize(text);
    if (terms.length === 0) return [];
    const likeClauses = terms.map((_, i) => `(title ILIKE $${i + 2} OR body ILIKE $${i + 2})`);
    const params = [scope, ...terms.map((t) => `%${t}%`), max];
    const { rows } = await pool.query(
      `SELECT id, scope, kind, title, body, tags, source_session_id,
              source_user_id, use_count, created_at, updated_at
       FROM h5_experience
       WHERE scope = $1 AND (${likeClauses.join(' OR ')})
       ORDER BY updated_at DESC
       LIMIT $${terms.length + 2}`,
      params,
    );
    if (rows.length > 0) await bumpUseCount(rows.map((r) => r.id));
    return rows.map(rowToExperience);
  }

  async function bumpUseCount(ids) {
    if (!ids.length) return;
    await pool
      .query('UPDATE h5_experience SET use_count = use_count + 1 WHERE id = ANY($1::uuid[])', [ids])
      .catch(() => {});
  }

  function tokenize(query) {
    return [
      ...new Set(
        String(query ?? '')
          .toLowerCase()
          .split(/[^\p{L}\p{N}]+/u)
          .map((t) => t.trim())
          .filter((t) => t.length >= 2),
      ),
    ];
  }

  async function reflect() {
    return { ok: false, reason: 'not_implemented' };
  }

  async function close() {
    await pool.end();
  }

  return { record, search, reflect, close };
}

function experienceError(message, code) {
  return Object.assign(new Error(message), { code });
}
