/**
 * Temper Summary — the v1 memory-standalone sub-task.
 *
 * One pass scans every chat_session: for each, reads the ingest cursor
 * from the memory_store, fetches chat_messages since cursor, decides
 * whether the new chunk crosses the trigger thresholds, and if so calls
 * summarizeAndIngest() to compress it and push summary + extracted
 * facts back into memory_store. Cursor + health blocks are upserted at
 * the same keys (see lib/memory/keys.ts) so two ticks racing the same
 * session converge instead of duplicating work.
 *
 * The core function is split out as a generic
 *   summarizeAndIngest({ store, messages, scope, cursorKey, provider })
 * so the Phase E6 global skill (workspace smiths / pipeline nodes /
 * task claude subprocesses) can reuse it without going through the
 * chat-session-scanner wrapper.
 *
 * See forge-chat-memory-summarizer-design.md §4 and §5.
 */

import {
  cursorKey,
  factKey,
  healthKey,
  stableHash,
  summaryKey,
  type CursorValue,
  type FactValue,
  type HealthValue,
  type SummaryValue,
} from './keys';
import { estimateTokens } from './token-estimate';
import { compressMessagesForSummarizer } from './compress-messages';
import { listSessions, listMessages } from '../chat/session-store';
import { getMemoryStore, type MemoryStore } from '../chat/memory-store';
import { resolveProvider, type ProviderResolution } from '../chat/agent-loop';
import type { Message } from '../chat/types';
import type { MemorySubTask } from '../memory-standalone';

// ── Tunables (see design §8) ──────────────────────────────────────────
const WORKING_BUFFER_MSGS = 40;
const TRIGGER_MIN_MSGS = 30;
const TRIGGER_MIN_TOKENS = 4000;
const SUMMARIZER_INPUT_TOKEN_BUDGET = 12000;
const SUMMARIZER_MAX_OUTPUT_TOKENS = 2000;

// ── Public types ──────────────────────────────────────────────────────
export interface FactInput {
  scope: string;        // e.g. 'user' / 'project' / 'preference'
  subject_kind: string; // free-form, set by the LLM
  subject: string;      // 'zliu', 'forge-project', …
  content: string;
}

export interface SummarizeAndIngestArgs {
  store: MemoryStore;
  /** The slice of conversation to compress (compressed, not raw blocks). */
  messages: Message[];
  /** Logical scope passed to keys.ts (e.g. session id). */
  scope: string;
  /** Where the cursor lives in memory_store. Caller owns the key shape. */
  cursorKeyName: string;
  /** Optional previous cursor value — used to bump ingest_count. */
  prevCursor: CursorValue | null;
  /** Resolved API provider. */
  provider: ProviderResolution;
  /** The to_ts boundary of this slice (last message ts in the slice). */
  toTs: number;
}

export interface SummarizeAndIngestResult {
  ok: boolean;
  summary?: string;
  factCount?: number;
  error?: string;
  promptTokens?: number;
  completionTokens?: number;
}

// ── Generic summarize + ingest (Phase B1 + B5 + B7 + B8 + B9) ─────────
export async function summarizeAndIngest(args: SummarizeAndIngestArgs): Promise<SummarizeAndIngestResult> {
  const { store, messages, scope, cursorKeyName, prevCursor, provider, toTs } = args;
  const fromTs = messages[0]?.ts ?? toTs;
  const transcript = compressMessagesForSummarizer(messages);
  const promptTokens = estimateTokens(transcript);

  // Call LLM
  let raw: string;
  try {
    raw = await callSummarizerLlm(provider, transcript);
  } catch (err) {
    return { ok: false, error: 'llm: ' + (err instanceof Error ? err.message : String(err)), promptTokens };
  }

  const { summary, facts } = parseSummarizerResponse(raw);
  if (!summary) {
    return { ok: false, error: 'empty summary in response', promptTokens };
  }

  // Persist
  const now = Date.now();
  const summaryValue: SummaryValue = {
    text: summary,
    from_ts: fromTs,
    to_ts: toTs,
    message_count: messages.length,
    model: provider.model,
    provider: provider.type,
    ingest_ts: now,
  };
  try {
    await store.putBlock(
      summaryKey(scope, toTs),
      summaryValue,
      { description: summary.slice(0, 120), scope: 'own' },
    );
  } catch (err) {
    return { ok: false, error: 'putBlock summary: ' + (err instanceof Error ? err.message : String(err)), promptTokens };
  }

  // Dual-write: putBlock above gives us upsert/cursor/recall by key,
  // but Temper's KG (Graphiti) only ingests writeEpisode payloads. Push
  // the same summary text as an episode so entity/relation extraction
  // sees chat history. Failure here doesn't block the pass.
  try {
    await store.writeEpisode({
      content: summary,
      source_type: 'text',
      source_description: `chat:${scope} summary @ ${toTs}`,
      tags: ['summarizer', 'session_summary', `chat:${scope}`],
    });
  } catch (err) {
    console.warn(`[temper-summary] writeEpisode(summary) failed for ${scope}:`, err instanceof Error ? err.message : err);
  }

  let factCount = 0;
  for (const f of facts) {
    if (!f.content || !f.subject) continue;
    const v: FactValue = {
      content: f.content,
      subject_kind: f.subject_kind || 'fact',
      subject: f.subject,
      source_ref: `chat:${scope}@${toTs}`,
      confidence: null,
      extracted_by: 'summarizer',
    };
    try {
      await store.putBlock(
        factKey(f.scope || 'other', f.subject, stableHash(f.content)),
        v,
        { description: f.content.slice(0, 120), scope: 'own' },
      );
      factCount += 1;
    } catch (err) {
      console.warn(`[temper-summary] fact putBlock failed for ${f.subject}:`, err instanceof Error ? err.message : err);
    }
    // Episode-mirror each fact so the KG sees structured propositions.
    // Per-fact rather than batched so Graphiti can attribute extraction
    // to the right subject without re-parsing.
    try {
      await store.writeEpisode({
        content: `${f.subject_kind || 'fact'} about ${f.subject}: ${f.content}`,
        source_type: 'text',
        source_description: `chat:${scope} fact: ${f.subject}`,
        tags: ['summarizer', 'fact', f.scope || 'other', f.subject_kind || 'fact'],
      });
    } catch (err) {
      console.warn(`[temper-summary] writeEpisode(fact) failed for ${f.subject}:`, err instanceof Error ? err.message : err);
    }
  }

  // Advance cursor (B9)
  const newCursor: CursorValue = {
    last_ingested_ts: toTs,
    last_run_ts: now,
    ingest_count: (prevCursor?.ingest_count ?? 0) + 1,
  };
  try {
    await store.putBlock(
      cursorKeyName,
      newCursor,
      { description: `summarizer cursor for ${scope}`, scope: 'own' },
    );
  } catch (err) {
    // Cursor failed to advance — next tick will retry the same range.
    return { ok: false, error: 'putBlock cursor: ' + (err instanceof Error ? err.message : String(err)), promptTokens };
  }

  return {
    ok: true,
    summary,
    factCount,
    promptTokens,
    completionTokens: estimateTokens(raw),
  };
}

// ── Per-session scanner (B1 wrapper around summarizeAndIngest) ────────
async function tickSession(store: MemoryStore, sessionId: string, sessionProvider: string | null, sessionModel: string | null): Promise<void> {
  const ck = cursorKey(sessionId);
  const prev = await readCursor(store, ck);
  const lastTs = prev?.last_ingested_ts ?? 0;

  const newMsgs = listMessages(sessionId, { after_ts: lastTs, limit: 5000 });
  if (newMsgs.length === 0) return;

  // Reserve the most recent N messages — they belong to the working
  // window the agent-loop reads directly.
  const candidates = newMsgs.slice(0, Math.max(0, newMsgs.length - WORKING_BUFFER_MSGS));
  if (candidates.length === 0) return;

  // Double-threshold trigger.
  const tokenEstimate = estimateTokens(candidates);
  if (candidates.length < TRIGGER_MIN_MSGS && tokenEstimate < TRIGGER_MIN_TOKENS) return;

  // Cap input by token budget — take from the front until we hit it.
  const slice = takeFromFrontUntilBudget(candidates, SUMMARIZER_INPUT_TOKEN_BUDGET);
  if (slice.length === 0) return;

  // Resolve provider (B6).
  const provider = resolveProvider(sessionProvider, sessionModel);
  if ('error' in provider) {
    await writeHealth(store, sessionId, { error: `provider: ${provider.error}`, ingestCount: prev?.ingest_count ?? 0, tokenEstimate });
    return;
  }

  const toTs = slice[slice.length - 1]!.ts;
  const sliceTokens = estimateTokens(slice);
  console.log(`[temper-summary] session=${sessionId} ingesting ${slice.length}/${candidates.length} msgs (~${sliceTokens}t, candidates ~${tokenEstimate}t) up to ts=${toTs}`);
  const result = await summarizeAndIngest({
    store,
    messages: slice,
    scope: sessionId,
    cursorKeyName: ck,
    prevCursor: prev ?? null,
    provider,
    toTs,
  });

  await writeHealth(store, sessionId, {
    error: result.ok ? null : result.error ?? 'unknown',
    ingestCount: result.ok ? (prev?.ingest_count ?? 0) + 1 : (prev?.ingest_count ?? 0),
    tokenEstimate: result.promptTokens ?? tokenEstimate,
  });

  if (result.ok) {
    console.log(`[temper-summary] session=${sessionId} ok (${result.factCount} fact${result.factCount === 1 ? '' : 's'})`);
  } else {
    console.warn(`[temper-summary] session=${sessionId} failed: ${result.error}`);
  }
}

// ── Sub-task export (B1) ──────────────────────────────────────────────
export function temperSummaryTask(): MemorySubTask {
  return {
    name: 'temper-summary',
    async tick() {
      const store = getMemoryStore();
      if (!store.enabled) {
        console.log('[temper-summary] memory store not enabled, skipping tick');
        return;
      }
      const sessions = listSessions(500);
      for (const s of sessions) {
        try {
          await tickSession(store, s.id, s.provider, s.model);
        } catch (err) {
          console.warn(`[temper-summary] session ${s.id} tick crashed:`, err instanceof Error ? err.message : err);
        }
      }
    },
  };
}

// ── Helpers ────────────────────────────────────────────────────────────

async function readCursor(store: MemoryStore, key: string): Promise<CursorValue | null> {
  try {
    const b = await store.getBlock(key);
    if (!b) return null;
    const v = b.value as Partial<CursorValue> | undefined;
    if (!v || typeof v.last_ingested_ts !== 'number') return null;
    return {
      last_ingested_ts: v.last_ingested_ts,
      last_run_ts: typeof v.last_run_ts === 'number' ? v.last_run_ts : 0,
      ingest_count: typeof v.ingest_count === 'number' ? v.ingest_count : 0,
    };
  } catch (err) {
    console.warn('[temper-summary] readCursor failed:', err instanceof Error ? err.message : err);
    return null;
  }
}

async function writeHealth(
  store: MemoryStore,
  sessionId: string,
  opts: { error: string | null; ingestCount: number; tokenEstimate: number },
): Promise<void> {
  const v: HealthValue = {
    last_run_ts: Date.now(),
    error: opts.error,
    ingest_count: opts.ingestCount,
    last_token_estimate: opts.tokenEstimate,
  };
  try {
    await store.putBlock(
      healthKey(sessionId),
      v,
      { description: opts.error ? `summarizer error: ${opts.error.slice(0, 80)}` : 'summarizer ok', scope: 'own' },
    );
  } catch (err) {
    console.warn('[temper-summary] writeHealth failed:', err instanceof Error ? err.message : err);
  }
}

function takeFromFrontUntilBudget(messages: Message[], budget: number): Message[] {
  const out: Message[] = [];
  let used = 0;
  for (const m of messages) {
    const cost = estimateTokens(m);
    if (used + cost > budget && out.length > 0) break;
    out.push(m);
    used += cost;
  }
  return out;
}

// ── LLM call (B5) ─────────────────────────────────────────────────────
const SUMMARIZER_INSTRUCTIONS = [
  'You are a chat memory ingestor. Read the conversation segment below and produce:',
  '1. A concise prose summary capturing decisions, facts, and the user\'s state at the END of this segment.',
  '2. A JSON list of long-term facts worth remembering across sessions:',
  '   - user identity / preferences / role',
  '   - project decisions / constraints',
  '   - named third-party facts ("Alice prefers Postgres")',
  'DO NOT include speculation. Only what\'s explicitly stated or unambiguously implied.',
  '',
  'OUTPUT FORMAT (strict):',
  '<summary>',
  '...prose summary, max ~800 chars...',
  '</summary>',
  '<facts>',
  '[ {"scope":"user|project|other", "subject_kind":"...", "subject":"...", "content":"..."} ]',
  '</facts>',
].join('\n');

/**
 * Direct HTTP call to the provider, bypassing the ai-sdk wrapper.
 *
 * Why not streamLlm: ai-sdk's streamText injects a `tools` field even
 * when we don't pass one. Strict OpenAI-spec gateways (litellm + vLLM)
 * reject `tools: []` with 400 per the OpenAI Chat Completions spec
 * ("if present, must contain at least one tool"). Summarizer has no
 * tools by design, so we just don't send the field at all.
 *
 * Supports openai-compatible (litellm, openai, deepseek-direct, etc.)
 * and anthropic. Both protocols are simple enough that hand-rolling a
 * single-shot non-streaming call is shorter than fighting the wrapper.
 */
async function callSummarizerLlm(provider: ProviderResolution, transcript: string): Promise<string> {
  const userText = 'CONVERSATION SEGMENT:\n' + transcript;
  if (provider.type === 'anthropic') {
    return callAnthropic(provider, userText);
  }
  return callOpenAI(provider, userText);
}

async function callOpenAI(provider: ProviderResolution, userText: string): Promise<string> {
  const url = joinUrl(provider.baseUrl, '/v1/chat/completions', '/chat/completions');
  const body = {
    model: provider.model,
    messages: [
      { role: 'system', content: SUMMARIZER_INSTRUCTIONS },
      { role: 'user', content: userText },
    ],
    max_tokens: SUMMARIZER_MAX_OUTPUT_TOKENS,
    stream: false,
  };
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      authorization: `Bearer ${provider.apiKey}`,
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`openai ${res.status}: ${text.slice(0, 300)}`);
  }
  const json = await res.json() as { choices?: Array<{ message?: { content?: string } }> };
  const content = json.choices?.[0]?.message?.content;
  if (typeof content !== 'string' || content.length === 0) {
    throw new Error(`openai response missing choices[0].message.content: ${JSON.stringify(json).slice(0, 300)}`);
  }
  return content;
}

async function callAnthropic(provider: ProviderResolution, userText: string): Promise<string> {
  const url = joinUrl(provider.baseUrl, '/v1/messages', '/messages');
  const body = {
    model: provider.model,
    system: SUMMARIZER_INSTRUCTIONS,
    messages: [{ role: 'user', content: [{ type: 'text', text: userText }] }],
    max_tokens: SUMMARIZER_MAX_OUTPUT_TOKENS,
  };
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-api-key': provider.apiKey,
      'anthropic-version': '2023-06-01',
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`anthropic ${res.status}: ${text.slice(0, 300)}`);
  }
  const json = await res.json() as { content?: Array<{ type?: string; text?: string }> };
  const text = (json.content || [])
    .filter((b) => b.type === 'text' && typeof b.text === 'string')
    .map((b) => b.text)
    .join('\n');
  if (!text) {
    throw new Error(`anthropic response missing text blocks: ${JSON.stringify(json).slice(0, 300)}`);
  }
  return text;
}

/** Strip trailing slash + try preferred path first; if base already
 *  ends with /v1 don't double it. */
function joinUrl(base: string, ...candidates: string[]): string {
  const b = base.replace(/\/+$/, '');
  const path = candidates[0]!;
  // If user-configured baseUrl already ends with /v1, fall back to the
  // second candidate (path without the v1 prefix) so we don't get /v1/v1/…
  if (/\/v\d+$/i.test(b) && candidates.length > 1) {
    return b + candidates[1];
  }
  return b + path;
}

// ── Response parser (B7) ──────────────────────────────────────────────
export interface ParsedSummarizerResponse {
  summary: string;
  facts: FactInput[];
}

export function parseSummarizerResponse(raw: string): ParsedSummarizerResponse {
  const summary = extractTag(raw, 'summary').trim();
  const factsRaw = extractTag(raw, 'facts').trim();

  // If both tags missing, treat the whole output as summary.
  if (!summary && !factsRaw) {
    return { summary: raw.trim(), facts: [] };
  }

  let facts: FactInput[] = [];
  if (factsRaw) {
    try {
      const parsed = JSON.parse(factsRaw);
      if (Array.isArray(parsed)) {
        facts = parsed
          .filter((f): f is Record<string, unknown> => typeof f === 'object' && f !== null)
          .map((f) => ({
            scope: typeof f.scope === 'string' ? f.scope : 'other',
            subject_kind: typeof f.subject_kind === 'string' ? f.subject_kind : 'fact',
            subject: typeof f.subject === 'string' ? f.subject : '',
            content: typeof f.content === 'string' ? f.content : '',
          }))
          .filter((f) => f.subject && f.content);
      }
    } catch (err) {
      console.warn('[temper-summary] facts JSON parse failed, ignoring:', err instanceof Error ? err.message : err);
    }
  }

  return { summary: summary || raw.trim(), facts };
}

function extractTag(s: string, tag: string): string {
  const m = s.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'));
  return m ? m[1]! : '';
}
