/**
 * Memory consolidation — auto-dream system.
 *
 * Four-phase consolidation inspired by Claude Code's autoDream:
 * 1. Orient: Gather memory stats and identify areas needing attention
 * 2. Gather Signal: Load candidate chunks for LLM review
 * 3. Consolidate: LLM rewrites/merges/prunes chunks intelligently
 * 4. Prune: SQL-based cleanup of stale, low-access entries
 *
 * Uses a lightweight LLM (Haiku) for semantic understanding of memory content.
 */

import { sql, type SqlBool } from "kysely";
import { z } from "zod";
import { getKysely } from "../db/client.ts";
import { loadEnvConfig } from "../config/env.ts";
import { createLogger } from "../lib/logger.ts";
import { runReasoningFork } from "../sdk/reasoning-fork.ts";

const log = createLogger("consolidator");

export interface ConsolidationResult {
  merged: number;
  pruned: number;
  rewritten: number;
  totalBefore: number;
  totalAfter: number;
}

/** Minimum age (days) before a chunk is eligible for pruning. */
const MIN_AGE_DAYS = 7;

/** Maximum access count for pruning eligibility — chunks accessed more than this are kept. */
const PRUNE_ACCESS_THRESHOLD = 1;

/** Similarity threshold for merging (cosine similarity). */
const MERGE_SIMILARITY_THRESHOLD = 0.92;

/** Maximum chunks to prune in a single consolidation run. */
const MAX_PRUNE_PER_RUN = 100;

/**
 * Run a full consolidation cycle:
 * 1. Prune old, rarely-accessed chunks (SQL)
 * 2. Merge near-duplicate chunks by vector similarity (SQL)
 * 3. LLM-powered review: rewrite/merge semantically similar chunks (Haiku)
 * 4. Decay user model confidence for stale entries
 */
export async function consolidateMemory(userId: string): Promise<ConsolidationResult> {
  const db = getKysely();

  // Count before (this owner only)
  const before = await db
    .selectFrom("memory_chunks")
    .select(sql<number>`count(*)::int`.as("count"))
    .where("user_id", "=", userId)
    .executeTakeFirstOrThrow();
  const totalBefore = before.count;

  // Phase 1: Prune old, rarely-accessed chunks
  const pruned = await pruneStaleChunks(userId);

  // Phase 2: Merge near-duplicate chunks (vector similarity)
  const merged = await mergeNearDuplicates(userId);

  // Phase 3: LLM-powered review and rewrite
  const rewritten = await llmConsolidate(userId);

  // Phase 4: Decay user model confidence for stale entries
  await decayUserModelConfidence(userId);

  // Count after
  const after = await db
    .selectFrom("memory_chunks")
    .select(sql<number>`count(*)::int`.as("count"))
    .where("user_id", "=", userId)
    .executeTakeFirstOrThrow();

  return { merged, pruned, rewritten, totalBefore, totalAfter: after.count };
}

/** Remove chunks that are old and rarely accessed (for one owner). */
async function pruneStaleChunks(userId: string): Promise<number> {
  const db = getKysely();
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - MIN_AGE_DAYS);

  const deleted = await db
    .deleteFrom("memory_chunks")
    .where("user_id", "=", userId)
    .where(
      "id",
      "in",
      db
        .selectFrom("memory_chunks")
        .select("id")
        .where("user_id", "=", userId)
        .where("access_count", "<=", PRUNE_ACCESS_THRESHOLD)
        .where("created_at", "<", cutoffDate)
        .where((eb) =>
          eb.or([eb("last_accessed_at", "is", null), eb("last_accessed_at", "<", cutoffDate)]),
        )
        .where(sql<SqlBool>`(metadata->>'category') NOT IN ('correction', 'skill')`)
        .orderBy("access_count", "asc")
        .orderBy("created_at", "asc")
        .limit(MAX_PRUNE_PER_RUN),
    )
    .returning("id")
    .execute();

  return deleted.length;
}

/** Find and merge near-duplicate chunks based on vector similarity (for one owner). */
async function mergeNearDuplicates(userId: string): Promise<number> {
  const db = getKysely();
  let mergeCount = 0;

  // Find pairs of chunks with very high cosine similarity (same owner only)
  const duplicatePairs = await db
    .selectFrom("memory_chunks as a")
    .innerJoin("memory_chunks as b", (join) => join.on(sql`a.id < b.id AND a.user_id = b.user_id`))
    .select([
      "a.id as id_a",
      "b.id as id_b",
      "a.text as text_a",
      "b.text as text_b",
      "a.access_count as access_a",
      "b.access_count as access_b",
      "a.created_at as created_a",
      "b.created_at as created_b",
      sql<number>`1 - (a.embedding <=> b.embedding)`.as("similarity"),
    ])
    .where("a.user_id", "=", userId)
    .where("a.embedding", "is not", null)
    .where("b.embedding", "is not", null)
    .where(sql`1 - (a.embedding <=> b.embedding)`, ">", MERGE_SIMILARITY_THRESHOLD)
    .orderBy("similarity", "desc")
    .limit(50)
    .execute();

  const deletedIds = new Set<string>();

  for (const pair of duplicatePairs) {
    if (deletedIds.has(pair.id_a) || deletedIds.has(pair.id_b)) continue;

    // Keep the chunk with more accesses, or the newer one if equal
    const keepId =
      pair.access_a > pair.access_b
        ? pair.id_a
        : pair.access_a < pair.access_b
          ? pair.id_b
          : pair.created_a > pair.created_b
            ? pair.id_a
            : pair.id_b;
    const removeId = keepId === pair.id_a ? pair.id_b : pair.id_a;

    // Merge: combine access counts and update the kept chunk
    const combinedAccess = pair.access_a + pair.access_b;
    await db
      .updateTable("memory_chunks")
      .set({ access_count: combinedAccess, updated_at: sql`now()` })
      .where("user_id", "=", userId)
      .where("id", "=", keepId)
      .execute();

    await db
      .deleteFrom("memory_chunks")
      .where("user_id", "=", userId)
      .where("id", "=", removeId)
      .execute();
    deletedIds.add(removeId);
    mergeCount++;
  }

  return mergeCount;
}

const LLM_CONSOLIDATION_PROMPT = `You are a memory consolidation system and a JSON decision system. Review the provided memory chunks and decide what to do with each. Output only valid JSON. No explanations.

For each chunk, decide ONE action:
- KEEP: Important, unique information — leave as-is
- REWRITE: Useful but verbose/messy — provide a cleaner version
- MERGE: Combine with another chunk (specify which by ID)
- DROP: Redundant, outdated, or trivially obvious — remove

Return ONLY a JSON array of decisions:
[{"id": "chunk-id", "action": "KEEP|REWRITE|MERGE|DROP", "rewrite": "new text if REWRITE", "merge_with": "other-id if MERGE"}]

Guidelines:
- Prefer concise, factual summaries over verbose conversational records
- Merge chunks that say the same thing in different words
- Drop chunks that state the obvious or repeat common knowledge
- Keep corrections, user preferences, and unique project-specific facts
- When rewriting, preserve the core information but make it concise`;

/** Zod schema for the LLM consolidation decision array. Mirrors the decision shape below. */
const ConsolidationDecisionSchema = z.object({
  id: z.string(),
  action: z.string(),
  rewrite: z.string().optional(),
  merge_with: z.string().optional(),
});
const ConsolidationDecisionsSchema = z.array(ConsolidationDecisionSchema).default([]);

/** Maximum chunks to review per LLM consolidation pass. */
const LLM_BATCH_SIZE = 20;

/**
 * LLM-powered consolidation: sends batches of memory chunks to Haiku
 * for semantic review, rewriting, and intelligent pruning.
 */
async function llmConsolidate(userId: string): Promise<number> {
  const db = getKysely();
  const config = loadEnvConfig();
  const model = config.extractionModel ?? "claude-haiku-4-5";

  // Select candidates: older chunks with moderate access, grouped by category
  const candidates = await db
    .selectFrom("memory_chunks")
    .select(["id", "text", "metadata", "access_count", "created_at"])
    .where("user_id", "=", userId)
    .where("created_at", "<", sql<Date>`now() - interval '3 days'`)
    .orderBy("access_count", "asc")
    .orderBy("created_at", "asc")
    .limit(LLM_BATCH_SIZE)
    .execute();

  if (candidates.length < 3) return 0; // Not enough to consolidate

  // Format chunks for the LLM
  const chunksText = candidates
    .map((c) => {
      const cat = (c.metadata as Record<string, unknown>)?.category ?? "unknown";
      return `[${c.id}] (${cat}, accessed: ${c.access_count}x)\n${(c.text as string).slice(0, 300)}`;
    })
    .join("\n\n");

  try {
    // Reasoning fork: stable rubric caches in the system-prompt prefix; only the
    // dynamic chunk batch is billed uncached. allowedTools:[] keeps the fork from
    // spending its turn on tools; the zod schema replaces the ad-hoc regex parse.
    const { data: decisions } = await runReasoningFork({
      instructions: LLM_CONSOLIDATION_PROMPT,
      input: `Memory chunks to review:\n${chunksText}`,
      schema: ConsolidationDecisionsSchema,
      model,
      label: "memory-consolidation",
    });
    if (!decisions || decisions.length === 0) return 0;

    let changeCount = 0;
    const validIds = new Set(candidates.map((c) => c.id));

    for (const decision of decisions) {
      if (!validIds.has(decision.id)) continue;

      switch (decision.action.toUpperCase()) {
        case "DROP": {
          await db
            .deleteFrom("memory_chunks")
            .where("user_id", "=", userId)
            .where("id", "=", decision.id)
            .execute();
          changeCount++;
          break;
        }
        case "REWRITE": {
          if (decision.rewrite && decision.rewrite.length > 10) {
            await db
              .updateTable("memory_chunks")
              .set({ text: decision.rewrite, updated_at: sql`now()` })
              .where("user_id", "=", userId)
              .where("id", "=", decision.id)
              .execute();

            // Try to regenerate embedding for the rewritten text
            try {
              const { isEmbeddingAvailable, generateEmbedding } = await import("./embeddings.ts");
              if (isEmbeddingAvailable()) {
                const embedding = await generateEmbedding(decision.rewrite);
                const embeddingStr = `[${embedding.join(",")}]`;
                await db
                  .updateTable("memory_chunks")
                  .set({ embedding: sql`${embeddingStr}::vector` })
                  .where("user_id", "=", userId)
                  .where("id", "=", decision.id)
                  .execute();
              }
            } catch {
              // Continue without embedding update
            }

            changeCount++;
          }
          break;
        }
        case "MERGE": {
          if (decision.merge_with && validIds.has(decision.merge_with)) {
            const target = await db
              .selectFrom("memory_chunks")
              .select("access_count")
              .where("id", "=", decision.merge_with)
              .executeTakeFirst();
            const source = await db
              .selectFrom("memory_chunks")
              .select("access_count")
              .where("user_id", "=", userId)
              .where("id", "=", decision.id)
              .executeTakeFirst();
            if (target && source) {
              const combined = target.access_count + source.access_count;
              await db
                .updateTable("memory_chunks")
                .set({ access_count: combined, updated_at: sql`now()` })
                .where("id", "=", decision.merge_with)
                .execute();
              await db
                .deleteFrom("memory_chunks")
                .where("user_id", "=", userId)
                .where("id", "=", decision.id)
                .execute();
              changeCount++;
            }
          }
          break;
        }
        // KEEP: no action needed
      }
    }

    if (changeCount > 0) {
      log.info({ changeCount, decisionsCount: decisions.length }, "LLM review");
    }

    return changeCount;
  } catch (err) {
    log.debug({ err }, "LLM consolidation failed");
    return 0;
  }
}

/** Reduce confidence of user model entries that haven't been reinforced recently (one owner). */
async function decayUserModelConfidence(userId: string): Promise<void> {
  const db = getKysely();
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - 30);

  // Decay confidence by 10% for entries not updated in 30+ days
  await db
    .updateTable("user_model")
    .set({
      confidence: sql`GREATEST(confidence * 0.9, 0.1)`,
      updated_at: sql`now()`,
    })
    .where("user_id", "=", userId)
    .where("updated_at", "<", cutoffDate)
    .where("confidence", ">", 0.1)
    .execute();
}

/** Shape of the value-reflection produced by {@link reflectOnValues}, consumed by reRankValues. */
export interface ValueReflection {
  values_to_boost?: { key: string; reason: string }[];
  values_to_decrease?: { key: string; reason: string }[];
  new_values?: { value: string; description: string; context: string; evidence: string[] }[];
  pattern_refinements?: { key: string; refinement: string }[];
}

/**
 * Zod schema for {@link ValueReflection}. Every array defaults to [] so a partial
 * emit still validates. Field names/optionality mirror the interface exactly.
 */
const ValueReflectionSchema = z.object({
  values_to_boost: z.array(z.object({ key: z.string(), reason: z.string() })).default([]),
  values_to_decrease: z.array(z.object({ key: z.string(), reason: z.string() })).default([]),
  new_values: z
    .array(
      z.object({
        value: z.string(),
        description: z.string(),
        context: z.string(),
        evidence: z.array(z.string()).default([]),
      }),
    )
    .default([]),
  pattern_refinements: z.array(z.object({ key: z.string(), refinement: z.string() })).default([]),
});

const REFLECTION_PROMPT = `You are a value re-evaluation system. Given a user's known values and decision patterns, decide what to adjust based on confidence and coherence. Output ONLY one JSON object:
{
  "values_to_boost": [{"key":"","reason":""}],
  "values_to_decrease": [{"key":"","reason":""}],
  "new_values": [{"value":"","description":"","context":"","evidence":[]}],
  "pattern_refinements": [{"key":"","refinement":""}]
}
For boost/decrease/refine, use ONLY keys that appear in the provided values/patterns. Keep each array small (0-3 items). No prose, no markdown fences.`;

/**
 * Reflect on a single owner's values + decision patterns and produce a
 * re-ranking reflection (the Phase-5 output the consolidation prompt describes).
 *
 * Returns null when the owner has no values/patterns to re-rank (skips the LLM
 * call for fresh users) or when the model output can't be parsed. Best-effort.
 */
export async function reflectOnValues(userId: string): Promise<ValueReflection | null> {
  const { getUserModel } = await import("../db/user-model.ts");
  const values = await getUserModel(userId, "value");
  const patterns = await getUserModel(userId, "decision_pattern");
  if (values.length === 0 && patterns.length === 0) return null; // nothing to re-rank

  const model = loadEnvConfig().extractionModel ?? "claude-haiku-4-5";
  const ctx = [
    "VALUES:",
    ...values.map((v) => `- ${v.key} (confidence ${v.confidence})`),
    "PATTERNS:",
    ...patterns.map((p) => `- ${p.key}`),
  ].join("\n");

  try {
    // Reasoning fork: the stable REFLECTION_PROMPT caches as the system-prompt
    // prefix; only the per-owner values/patterns context is billed. allowedTools:[]
    // (forced by the helper) prevents a tool detour from dropping the reflection
    // under the single turn. The zod schema replaces the ad-hoc regex parse.
    const { data } = await runReasoningFork({
      instructions: REFLECTION_PROMPT,
      input: ctx,
      schema: ValueReflectionSchema,
      model,
      label: "value-reflection",
    });
    return data;
  } catch (err) {
    log.debug({ err, userId }, "reflectOnValues failed");
    return null;
  }
}
