/**
 * Temper memory client — used by Forge chat for pinned/recall/episode I/O.
 *
 * Ported from the extension's pre-Phase-4 implementation. Auth: X-API-Key.
 * Default namespace comes from the key — only set the override via
 * settings.temperNamespace if you intentionally want a different scope.
 *
 * The chat agent loop calls listBlocks + search on every turn (silent on
 * failure) and exposes memory_* tools to the LLM for explicit reads /
 * writes. If url or key is missing, the client reports `enabled = false`
 * and every method is a no-op returning the empty/false fallback.
 */

export interface SearchHit {
  id: string;
  kind: 'fact' | 'entity' | string;
  fact?: string;
  score?: number;
  valid_at?: string | null;
  invalid_at?: string | null;
  source_node_uuid?: string;
  target_node_uuid?: string;
  namespace?: string;
}

export interface MemoryBlock {
  key: string;
  value: unknown;
  pinned?: boolean;
  priority?: number;
  description?: string;
  scope?: 'own' | 'global' | 'both';
}

interface RawBlock {
  block_key?: string;
  block_value?: unknown;
  key?: string;
  value?: unknown;
  pinned?: boolean;
  priority?: number;
  description?: string;
  scope?: 'own' | 'global' | 'both';
}

function normalizeBlock(raw: RawBlock): MemoryBlock | null {
  const key = raw.block_key ?? raw.key;
  if (!key) return null;
  const value = 'block_value' in raw ? raw.block_value : raw.value;
  return {
    key,
    value,
    pinned: raw.pinned,
    priority: raw.priority,
    description: raw.description,
    scope: raw.scope,
  };
}

export type SourceType = 'message' | 'text' | 'json';

export interface EpisodeInput {
  content: string;
  source_type?: SourceType;
  source_description?: string;
  reference_time?: string;
  tags?: string[];
  saga?: string;
  namespace?: string;
}

export class TemperClient {
  constructor(
    private readonly baseUrl: string,
    private readonly apiKey: string = '',
    /** Optional namespace override. Empty = use the key's default. */
    private readonly namespace: string = '',
  ) {}

  get enabled(): boolean {
    return this.baseUrl.length > 0 && this.apiKey.length > 0;
  }

  get currentNamespace(): string {
    return this.namespace;
  }

  private url(path: string): string {
    return this.baseUrl.replace(/\/+$/, '') + path;
  }

  private headers(extra: Record<string, string> = {}): Record<string, string> {
    return { 'X-API-Key': this.apiKey, ...extra };
  }

  private withNs(u: URL): URL {
    if (this.namespace) u.searchParams.set('namespace', this.namespace);
    return u;
  }

  async search(query: string, limit = 8): Promise<SearchHit[]> {
    if (!this.enabled) return [];
    try {
      const u = this.withNs(new URL(this.url('/v1/search')));
      u.searchParams.set('query', query);
      u.searchParams.set('limit', String(limit));
      const r = await fetch(u.toString(), { headers: this.headers() });
      if (!r.ok) return [];
      const j = await r.json();
      if (Array.isArray(j)) return j as SearchHit[];
      if (j && Array.isArray((j as { hits?: SearchHit[] }).hits)) {
        return (j as { hits: SearchHit[] }).hits;
      }
      if (j && Array.isArray((j as { results?: SearchHit[] }).results)) {
        return (j as { results: SearchHit[] }).results;
      }
      return [];
    } catch (err) {
      console.warn('[temper] search failed', err);
      return [];
    }
  }

  async writeEpisode(ep: EpisodeInput, asyncExtract = true): Promise<boolean> {
    if (!this.enabled) return false;
    try {
      const u = new URL(this.url('/v1/episodes'));
      if (asyncExtract) u.searchParams.set('async_extract', 'true');
      const body: EpisodeInput = {
        source_type: 'text',
        reference_time: new Date().toISOString(),
        ...(this.namespace && !ep.namespace ? { namespace: this.namespace } : {}),
        ...ep,
      };
      const r = await fetch(u.toString(), {
        method: 'POST',
        headers: this.headers({ 'content-type': 'application/json' }),
        body: JSON.stringify(body),
      });
      return r.ok;
    } catch (err) {
      console.warn('[temper] writeEpisode failed', err);
      return false;
    }
  }

  async listBlocks(opts: { pinned?: boolean; scope?: 'own' | 'global' | 'both' } = {}): Promise<MemoryBlock[]> {
    if (!this.enabled) return [];
    try {
      const u = this.withNs(new URL(this.url('/v1/memory/blocks')));
      if (opts.pinned) u.searchParams.set('pinned', 'true');
      u.searchParams.set('scope', opts.scope ?? 'both');
      const r = await fetch(u.toString(), { headers: this.headers() });
      if (!r.ok) return [];
      const j = await r.json();
      const raws: RawBlock[] = Array.isArray(j)
        ? (j as RawBlock[])
        : j && Array.isArray((j as { blocks?: RawBlock[] }).blocks)
          ? (j as { blocks: RawBlock[] }).blocks
          : [];
      return raws
        .map(normalizeBlock)
        .filter((b): b is MemoryBlock => b !== null);
    } catch (err) {
      console.warn('[temper] listBlocks failed', err);
      return [];
    }
  }

  async getBlock(key: string, scope: 'own' | 'global' = 'own'): Promise<MemoryBlock | null> {
    if (!this.enabled) return null;
    try {
      const u = this.withNs(new URL(this.url(`/v1/memory/blocks/${encodeURIComponent(key)}`)));
      u.searchParams.set('scope', scope);
      const r = await fetch(u.toString(), { headers: this.headers() });
      if (r.status === 404) return null;
      if (!r.ok) return null;
      const j = (await r.json()) as RawBlock | { block?: RawBlock };
      const raw = 'block' in (j as Record<string, unknown>) ? (j as { block?: RawBlock }).block : (j as RawBlock);
      if (!raw) return null;
      const normalized = normalizeBlock(raw) ?? normalizeBlock({ ...raw, block_key: key });
      return normalized;
    } catch (err) {
      console.warn('[temper] getBlock failed', err);
      return null;
    }
  }

  async putBlock(
    key: string,
    value: unknown,
    extras: { pinned?: boolean; priority?: number; description?: string; scope?: 'own' | 'global' } = {},
  ): Promise<boolean> {
    if (!this.enabled) return false;
    try {
      const u = this.withNs(new URL(this.url(`/v1/memory/blocks/${encodeURIComponent(key)}`)));
      const r = await fetch(u.toString(), {
        method: 'PUT',
        headers: this.headers({ 'content-type': 'application/json' }),
        body: JSON.stringify({ value, ...extras }),
      });
      return r.ok;
    } catch (err) {
      console.warn('[temper] putBlock failed', err);
      return false;
    }
  }

  /** Reachability probe — returns ok + pinned count. */
  async ping(): Promise<{ ok: boolean; message: string; pinned: number }> {
    if (!this.baseUrl) return { ok: false, message: 'URL is empty', pinned: 0 };
    if (!this.apiKey) return { ok: false, message: 'API key is empty', pinned: 0 };
    try {
      const u = this.withNs(new URL(this.url('/v1/memory/blocks')));
      u.searchParams.set('pinned', 'true');
      u.searchParams.set('scope', 'both');
      const r = await fetch(u.toString(), { headers: this.headers() });
      if (!r.ok) {
        const text = await r.text().catch(() => '');
        return {
          ok: false,
          message: `${r.status} ${r.statusText}${text ? ': ' + text.slice(0, 200) : ''}`,
          pinned: 0,
        };
      }
      const j = await r.json();
      const arr = Array.isArray(j)
        ? (j as unknown[])
        : j && Array.isArray((j as { blocks?: unknown[] }).blocks)
          ? (j as { blocks: unknown[] }).blocks
          : [];
      const count = arr.length;
      return { ok: true, message: `OK · ${count} pinned block${count === 1 ? '' : 's'}`, pinned: count };
    } catch (err) {
      return { ok: false, message: err instanceof Error ? err.message : String(err), pinned: 0 };
    }
  }
}

// ─── System-prompt rendering ──────────────────────────────

function valueShort(v: unknown, max = 200): string {
  const s = typeof v === 'string' ? v : JSON.stringify(v);
  return s.length > max ? s.slice(0, max) + '…' : s;
}

const MAX_INLINED_BLOCKS = 50;

/**
 * Inline all known block values into the system prompt. Smaller models
 * won't proactively call memory_get_block from a key listing — they'll
 * just answer what's asked. So we hand them the values directly. The
 * memory_get_block tool is still available for explicit lookups (a
 * value too long to inline, scopes not auto-loaded), but the chat loop
 * does not depend on the LLM choosing to use it.
 */
export function renderMemoryContext(blocks: MemoryBlock[], hits: SearchHit[]): string {
  const out: string[] = [];
  if (blocks.length > 0) {
    out.push('Known facts about the user (from memory):');
    const inlined = blocks.slice(0, MAX_INLINED_BLOCKS);
    for (const b of inlined) {
      const desc = b.description ? `  (note: ${b.description})` : '';
      out.push(`- ${b.key} = ${valueShort(b.value)}${desc}`);
    }
    if (blocks.length > inlined.length) {
      out.push(`- … and ${blocks.length - inlined.length} more (use memory_list_blocks to enumerate)`);
    }
    out.push('');
  }

  const factHits = hits.filter((h) => h.fact && h.invalid_at == null);
  if (factHits.length > 0) {
    out.push('Recalled facts (semantic graph search):');
    for (const h of factHits.slice(0, 6)) {
      out.push(`- ${h.fact}`);
    }
  }

  return out.join('\n');
}
