/**
 * autobiographical-textonly — deployment-local strategy for text-only models.
 *
 * Subclass of AutobiographicalStrategy that renders EVERY image block as a
 * loud text placeholder — live window and compression prompts — regardless of
 * how the image is sourced. The stock byte-budget knobs are not enough here:
 * `imageBlockBytes()` measures non-base64 sources (chronicle blob references,
 * URL sources) as 0 bytes, so store-blob images sail through any budget and
 * only materialize to base64 later on the way to the wire. We override the
 * two protected strip hooks instead and drop images unconditionally.
 *
 * The store is untouched: switch the recipe back to a multimodal model and
 * the same memories render with their images again.
 *
 * Why: NanoGPT's xiaomi/mimo-v2.5-pro:thinking rejects any request carrying
 * image blocks (400 image_input_not_supported), and the host does not yet
 * expose an image policy in recipes — see context-manager issue #100 for the
 * upstream design. Delete this extension and use the recipe flag when that
 * lands.
 *
 * Known conservative drift: the planner prices entries at post-strip weight
 * using the stock policy; we strip strictly more, so token estimates may
 * slightly OVERCOUNT (compiles come out a bit under budget). Safe direction.
 *
 * Extension config (`extensions.<name>.config` in the recipe):
 *   - `nativeVision: true` — for multimodal models (e.g. mimo-v2.6-pro on the
 *     official Xiaomi API): keep the stock image policy (tune it with the
 *     strategy's `maxLiveImages` / `imageStripDepthTokens`) and skip the
 *     caption sidecar. The segmentation below still applies.
 *   - `clockEveryMinutes` (default 60, 0 = off) — ClockModule `[clock]` notices.
 *
 * Also: conversation segmentation (`segmentGapMinutes`, `segmentMinTokens` in
 * the strategy config) — see chunkBoundaryHint.
 */
import { AutobiographicalStrategy } from '@animalabs/context-manager';
import type { ContentBlock } from '@animalabs/context-manager';

const PLACEHOLDER = '[image omitted: text-only model]';

type AnyBlock = ContentBlock & { content?: unknown };

function stripAllImages(blocks: ContentBlock[]): { blocks: ContentBlock[]; dropped: number } {
  let dropped = 0;
  const walk = (list: ContentBlock[]): ContentBlock[] =>
    list.map((b) => {
      const block = b as AnyBlock;
      if (block.type === 'image') {
        dropped++;
        return { type: 'text', text: PLACEHOLDER } as ContentBlock;
      }
      if (block.type === 'tool_result' && Array.isArray(block.content)) {
        return { ...block, content: walk(block.content as ContentBlock[]) } as ContentBlock;
      }
      return b;
    });
  return { blocks: walk(blocks), dropped };
}

/**
 * Conversation segmentation: the stock chunker closes L1 chunks on token size
 * only, so one memory can fuse a late-night thread with the next morning's
 * unrelated one. Close the running chunk at a quiet gap instead — once it
 * holds at least `segmentMinTokens`, so bursts of chatter don't mint a flood of
 * tiny L1s. Uses stored message timestamps (historical for imported history).
 */
interface SegmentationConfig {
  /** Minimum silence between two messages that ends a chunk. 0 disables. Default 60. */
  segmentGapMinutes?: number;
  /** Running-chunk size (≈chars/4) required before a gap may close it. Default 500. */
  segmentMinTokens?: number;
}

function approxTokens(messages: readonly { content: ContentBlock[] }[]): number {
  let chars = 0;
  for (const m of messages) {
    for (const b of m.content) {
      if (b.type === 'text') chars += (b as { text: string }).text.length;
    }
  }
  return Math.ceil(chars / 4);
}

/** The slice of MessageStoreView the head boundary needs. */
type HeadStore = { getAll(): Array<{ id: string }>; estimateTokens(message: unknown): number };

class TextOnlyAutobiographicalStrategy extends AutobiographicalStrategy {
  private readonly segmentGapMs: number;
  private readonly segmentMinTokens: number;
  /** Multimodal model: keep the stock image policy instead of stripping. */
  private readonly nativeVision: boolean;

  constructor(
    opts: ConstructorParameters<typeof AutobiographicalStrategy>[0],
    segmentation: SegmentationConfig = {},
    nativeVision = false,
  ) {
    super(opts);
    this.segmentGapMs = (segmentation.segmentGapMinutes ?? 60) * 60_000;
    this.segmentMinTokens = segmentation.segmentMinTokens ?? 500;
    this.nativeVision = nativeVision;
  }

  protected override chunkBoundaryHint(
    prev: { timestamp: Date },
    next: { timestamp: Date },
    currentChunk: readonly { content: ContentBlock[] }[],
  ): boolean {
    if (this.segmentGapMs <= 0) return false;
    const gap = new Date(next.timestamp).getTime() - new Date(prev.timestamp).getTime();
    return gap >= this.segmentGapMs && approxTokens(currentChunk) >= this.segmentMinTokens;
  }

  /**
   * Stable head: end the head window where chunk coverage begins.
   *
   * The stock boundary is "first headWindowTokens tokens" priced with the
   * live calibration multiplier, which moves on every inference. When it rose,
   * the head shrank and the message that fell out was minted into a one-message
   * L1 at once. It was owned from then on, so the head never grew back (a
   * ratchet). Those July L1s then merged with whatever L1 frontier was open
   * (September), and the resulting L2/L3s rendered at July's position in the
   * window. Here the boundary follows ownership instead: uncovered messages
   * right after the token boundary stay in the head, and owned messages are
   * never also rendered raw. With no coverage yet (fresh session), or when the
   * first owned message is implausibly far away, the stock boundary stands.
   */
  private headCache: { key: string; end: number } | null = null;
  protected override getHeadWindowEnd(store: HeadStore): number {
    const base = super.getHeadWindowEnd(store as never);
    if (base <= 0) return base;
    const messages = store.getAll();
    const start = this.getHeadWindowStartIndex(store as never);
    const key = `${base}:${start}:${messages.length}:${this.chunks.length}:${this.summaries.length}`;
    if (this.headCache?.key === key) return this.headCache.end;
    const owned = new Set<string>();
    for (const ch of this.chunks) for (const m of ch.messages) owned.add(String(m.id));
    for (const s of this.summaries) if (s.level === 1) for (const id of s.sourceIds) owned.add(String(id));
    let end = base;
    for (let i = start; i < messages.length; i++) {
      if (!owned.has(String(messages[i]!.id))) continue;
      if (i > base) {
        // Keep the uncovered stretch only while it stays head-sized.
        let tokens = 0;
        for (let j = start; j < i; j++) tokens += store.estimateTokens(messages[j]);
        end = tokens <= 2 * this.config.headWindowTokens ? i : base;
      } else {
        end = Math.max(start, i);
      }
      break;
    }
    this.headCache = { key, end };
    return end;
  }

  /** Live window: replace every image block, whatever its source shape. */
  protected override applyImageStripping(
    entries: Array<{ content: ContentBlock[] }>,
    store: unknown,
  ): void {
    if (this.nativeVision) {
      // @ts-expect-error — parent signature uses internal types; runtime-compatible.
      return super.applyImageStripping(entries, store);
    }
    let dropped = 0;
    for (const entry of entries) {
      if (!Array.isArray(entry.content)) continue;
      const result = stripAllImages(entry.content);
      entry.content = result.blocks;
      dropped += result.dropped;
    }
    if (dropped > 0) {
      console.error(`[textonly] live window: ${dropped} image block(s) → placeholder (text-only model)`);
    }
  }

  /**
   * The real wire seam for mint/merge prompts. capCompressionImageBytes runs
   * on `llmMessages` AFTER the wire copy (`cleaned`) was derived from them, so
   * messages rebuilt by splitMixedToolMessages/collapse (tool rounds — exactly
   * where images ride) ship uncapped regardless of budget — an upstream
   * ordering bug. applyMintCacheSeams receives the FINAL wire messages at all
   * three mint/merge build sites, so strip here too, before the seams.
   */
  protected override applyMintCacheSeams(
    messages: Array<{ content: ContentBlock[] }>,
    recallLadder: unknown,
    capped: boolean,
  ): boolean {
    if (this.nativeVision) {
      // @ts-expect-error — parent signature uses internal types; runtime-compatible.
      return super.applyMintCacheSeams(messages, recallLadder, capped);
    }
    let dropped = 0;
    for (const m of messages) {
      if (!Array.isArray(m.content)) continue;
      const result = stripAllImages(m.content);
      m.content = result.blocks;
      dropped += result.dropped;
    }
    if (dropped > 0) {
      console.error(`[textonly] mint/merge wire: ${dropped} image block(s) → placeholder (text-only model)`);
    }
    // @ts-expect-error — parent signature uses internal types; runtime-compatible.
    return super.applyMintCacheSeams(messages, recallLadder, capped);
  }

  /** Compression/merge prompts: same unconditional strip. */
  protected override capCompressionImageBytes(
    messages: Array<{ content: ContentBlock[] }>,
    capBytes: number,
  ): number {
    if (this.nativeVision) {
      // @ts-expect-error — parent signature uses internal types; runtime-compatible.
      return super.capCompressionImageBytes(messages, capBytes);
    }
    let dropped = 0;
    for (const m of messages) {
      if (!Array.isArray(m.content)) continue;
      const result = stripAllImages(m.content);
      m.content = result.blocks;
      dropped += result.dropped;
    }
    if (dropped > 0) {
      console.error(`[textonly] compression prompt: ${dropped} image block(s) → placeholder (text-only model)`);
    }
    return dropped;
  }
}


/**
 * VisionCaptionModule — same-turn sight for a text-only model.
 *
 * When an incoming channel message carries base64 images, call a fast vision
 * model (NanoGPT glm-5v-turbo by default) and add its description to the
 * conversation as an "[Application visual analysis]" message — the exact
 * contract JJ's system prompt already documents. The analysis is stored in
 * chronicle as plain text, so it flows through compiles, compression and
 * memory like any other message, while the textonly strategy strips the
 * image block itself. Fail-open: any error or timeout just logs and the
 * turn proceeds without analysis.
 */
interface VisionConfig {
  visionModel?: string;
  baseUrl?: string;
  maxImages?: number;
  timeoutMs?: number;
}

class VisionCaptionModule {
  readonly name = 'vision-caption';
  private readonly model: string;
  private readonly baseUrl: string;
  private readonly maxImages: number;
  private readonly timeoutMs: number;

  constructor(config: VisionConfig) {
    this.model = config.visionModel ?? 'z-ai/glm-5v-turbo';
    this.baseUrl = (config.baseUrl ?? 'https://nano-gpt.com/api/v1').replace(/\/$/, '');
    this.maxImages = config.maxImages ?? 3;
    this.timeoutMs = config.timeoutMs ?? 25_000;
  }

  async start(): Promise<void> {
    console.error(`[vision-caption] active: ${this.model} via ${this.baseUrl}`);
  }
  async stop(): Promise<void> {}
  getTools(): never[] { return []; }
  async handleToolCall(): Promise<{ success: boolean; error: string; isError: boolean }> {
    return { success: false, error: 'vision-caption has no tools', isError: true };
  }

  async onProcess(event: Record<string, unknown>): Promise<Record<string, unknown>> {
    if (event.type !== 'mcpl:channel-incoming') return {};
    const content = event.content as Array<Record<string, unknown>> | undefined;
    if (!Array.isArray(content)) return {};
    const images = content
      .filter((b) => b.type === 'image')
      .map((b) => b.source as { type?: string; mediaType?: string; data?: string } | undefined)
      .filter((src): src is { type: string; mediaType?: string; data: string } =>
        !!src && src.type === 'base64' && typeof src.data === 'string' && src.data.length > 0)
      .slice(0, this.maxImages);
    if (images.length === 0) return {};

    const author = (event.author as { name?: string } | undefined)?.name ?? 'someone';
    try {
      const description = await this.describe(images);
      if (!description) return {};
      console.error(`[vision-caption] ${images.length} image(s) from ${author} -> ${description.length} chars`);
      return {
        addMessages: [{
          participant: 'Application',
          content: [{
            type: 'text',
            text: `[Application visual analysis] Image(s) attached by ${author}: ${description}`,
          }],
          metadata: { channelId: event.channelId },
        }],
      };
    } catch (err) {
      console.error(`[vision-caption] failed (turn proceeds without analysis): ${err instanceof Error ? err.message : String(err)}`);
      return {};
    }
  }

  private async describe(images: Array<{ mediaType?: string; data: string }>): Promise<string> {
    // The provider key may belong to another vendor (e.g. the official Xiaomi
    // MiMo API): never send it to NanoGPT — use NanoGPT's own key there.
    const key = /(^|\.)nano-gpt\.com$/.test(new URL(this.baseUrl).hostname)
      ? process.env.NANOGPT_API_KEY || ''
      : process.env.OPENAI_COMPATIBLE_API_KEY || process.env.NANOGPT_API_KEY || '';
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), this.timeoutMs);
    try {
      const res = await fetch(`${this.baseUrl}/chat/completions`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) },
        signal: controller.signal,
        body: JSON.stringify({
          model: this.model,
          max_tokens: 400,
          messages: [{
            role: 'user',
            content: [
              {
                type: 'text',
                text: 'Describe the attached image(s) concisely for a text-only assistant that cannot see them: subject, notable details, and transcribe any visible text verbatim. Plain prose, no preamble.',
              },
              ...images.map((img) => ({
                type: 'image_url',
                image_url: { url: `data:${img.mediaType ?? 'image/png'};base64,${img.data}` },
              })),
            ],
          }],
        }),
      });
      if (!res.ok) throw new Error(`vision model HTTP ${res.status}: ${(await res.text()).slice(0, 160)}`);
      const data = await res.json() as { choices?: Array<{ message?: { content?: string } }> };
      return (data.choices?.[0]?.message?.content ?? '').trim();
    } finally {
      clearTimeout(timer);
    }
  }
}

/**
 * ClockModule — keeps the agent's sense of time current.
 *
 * The host only announces the time once, at session start, so a long-running
 * session drifts: JJ judged "bedtime" from a notice written the night before.
 * On an incoming channel message, add a `[clock]` notice (the same form the
 * imported history carries) when the local day changed or at least
 * `clockEveryMinutes` passed since the last one — at most one per interval, and
 * only while someone is talking, so quiet hours cost nothing.
 */
interface ClockConfig {
  /** Minimum minutes between notices. 0 disables the module. Default 60. */
  clockEveryMinutes?: number;
  /** IANA zone. Default AGENT_TIMEZONE, else Europe/Madrid. */
  clockTimeZone?: string;
}

interface ClockState {
  lastAt?: number;
  lastDay?: string;
}

interface StatefulContext {
  getState<T>(): T | undefined;
}

export class ClockModule {
  readonly name = 'clock';
  private ctx: StatefulContext | null = null;
  private readonly everyMs: number;
  private readonly timeZone: string;
  private readonly now: () => number;

  constructor(config: ClockConfig, now: () => number = Date.now) {
    this.everyMs = (config.clockEveryMinutes ?? 60) * 60_000;
    this.timeZone = config.clockTimeZone ?? process.env.AGENT_TIMEZONE ?? 'Europe/Madrid';
    this.now = now;
  }

  async start(ctx: StatefulContext): Promise<void> { this.ctx = ctx; }
  async stop(): Promise<void> { this.ctx = null; }
  getTools(): never[] { return []; }
  async handleToolCall(): Promise<{ success: boolean; error: string; isError: boolean }> {
    return { success: false, error: 'clock has no tools', isError: true };
  }

  async onProcess(event: Record<string, unknown>): Promise<Record<string, unknown>> {
    if (event.type !== 'mcpl:channel-incoming' && event.type !== 'mcpl:push-event') return {};
    const at = this.now();
    const day = new Intl.DateTimeFormat('en-CA', { timeZone: this.timeZone }).format(at);
    const last = this.ctx?.getState<ClockState>() ?? {};
    if (last.lastAt !== undefined && last.lastDay === day && at - last.lastAt < this.everyMs) return {};
    const when = new Intl.DateTimeFormat('en-GB', {
      timeZone: this.timeZone, weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
      hour: '2-digit', minute: '2-digit',
    }).format(at);
    return {
      addMessages: [{
        participant: 'user',
        content: [{ type: 'text', text: `[clock] ${when} (${this.timeZone}).` }],
        metadata: { system: true, kind: 'time-marker' },
      }],
      stateUpdate: { lastAt: at, lastDay: day } satisfies ClockState,
    };
  }
}

interface StrategyFactoryContext {
  config: Record<string, unknown>;
  model: string;
  timeZone: string;
}

interface ExtensionApi {
  registerStrategy(type: string, factory: (ctx: StrategyFactoryContext) => unknown): void;
  registerModule(factory: (ctx: unknown) => unknown): void;
}

interface ExtensionConfig extends VisionConfig, ClockConfig {
  /** The model sees images itself: no stripping, no caption sidecar. Default false. */
  nativeVision?: boolean;
}

export function register(api: ExtensionApi, config: Record<string, unknown> = {}): void {
  const ext = config as ExtensionConfig;
  const nativeVision = ext.nativeVision === true;
  if (!nativeVision) api.registerModule(() => new VisionCaptionModule(ext) as never);
  if ((ext.clockEveryMinutes ?? 60) > 0) api.registerModule(() => new ClockModule(ext) as never);

  api.registerStrategy('autobiographical-textonly', ({ config, model }) => {
    const { type: _type, segmentGapMinutes, segmentMinTokens, ...recipeOverrides } = config as
      Record<string, unknown> & SegmentationConfig;
    const opts = {
      // Mirror connectome-host's framework-strategy defaults for the built-in type:
      headWindowTokens: 4000,
      recentWindowTokens: 30000,
      compressionModel: model,
      autoTickOnNewMessage: true,
      maxMessageTokens: 10000,
      adaptiveResolution: true,
      foldingStrategy: 'kv-stable',
      ...recipeOverrides,
      // Text-only: belt-and-braces under the overrides (1, not 0 — 0 disables
      // the policy). Native vision keeps the recipe's / library's image policy.
      ...(nativeVision ? {} : { maxLiveImageBytes: 1, maxCompressionImageBytes: 1 }),
    };
    return new TextOnlyAutobiographicalStrategy(
      opts as ConstructorParameters<typeof AutobiographicalStrategy>[0],
      { segmentGapMinutes, segmentMinTokens },
      nativeVision,
    );
  });
}
