import { appendFileSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import type { Message } from '../../llm/openai-compat.js';
import { logger } from '../../logger.js';

interface TranscriptLine extends Message {
  _meta?: { ts: number };
}

export class Conversation {
  readonly messages: Message[] = [];
  private persistedCount = 0;

  constructor(private readonly transcriptPath?: string) {}

  seed(preamble: string, guidance: string, taskInstruction: string): void {
    this.messages.length = 0;
    this.persistedCount = 0;
    // Single leading system message (preamble + movement-1 guidance). Keeping
    // exactly one system message at index 0 satisfies strict chat templates that
    // raise "System message must be at the beginning" for any system message at
    // a later index; it also matches the legacy non-conversation path in
    // agent-loop.ts which already merges preamble + guidance into one system msg.
    this.messages.push({ role: 'system', content: `${preamble}\n\n${guidance}` });
    this.messages.push({ role: 'user', content: taskInstruction });
    this.flush();
  }

  enterMovement(guidance: string): void {
    // Movement 2+ guidance is injected mid-conversation, so it must NOT be a
    // `system` message: strict templates reject any system message after the
    // first. Use `user`, matching every other mid-loop injection (watchdog,
    // interjection, loop/text-only reminders) which all push `role: 'user'`.
    this.messages.push({ role: 'user', content: guidance });
    this.flush();
  }

  flush(): void {
    if (!this.transcriptPath) {
      this.persistedCount = this.messages.length;
      return;
    }
    if (this.persistedCount >= this.messages.length) return;
    try {
      const lines = this.messages
        .slice(this.persistedCount)
        .map((m) => JSON.stringify({ ...m, _meta: { ts: Date.now() } } as TranscriptLine))
        .join('\n');
      appendFileSync(this.transcriptPath, lines + '\n');
      const appended = this.messages.length - this.persistedCount;
      this.persistedCount = this.messages.length;
      logger.info(`[conversation] flush appended=${appended} path=${this.transcriptPath}`);
    } catch (err) {
      logger.warn(`[conversation] flush failed path=${this.transcriptPath} err=${(err as Error).message}`);
    }
  }

  rewrite(): void {
    if (!this.transcriptPath) {
      this.persistedCount = this.messages.length;
      return;
    }
    try {
      const body = this.messages
        .map((m) => JSON.stringify({ ...m, _meta: { ts: Date.now() } } as TranscriptLine))
        .join('\n');
      writeFileSync(this.transcriptPath, body.length > 0 ? body + '\n' : '');
      this.persistedCount = this.messages.length;
      logger.info(`[conversation] rewrite count=${this.messages.length} path=${this.transcriptPath}`);
    } catch (err) {
      logger.warn(`[conversation] rewrite failed path=${this.transcriptPath} err=${(err as Error).message}`);
    }
  }

  /**
   * Whether a continuation job would actually replay prior turns from `path`.
   * Mirrors piece-runner's replay condition (priorTurns.length > 0) exactly so a
   * caller deciding to suppress redundant context stays consistent with the
   * engine that does the replay. Undefined path or absent/empty file → false.
   */
  static hasReplayableTranscript(path?: string): boolean {
    if (!path) return false;
    return Conversation.replayableTurns(Conversation.loadFrom(path)).length > 0;
  }

  static loadFrom(path: string): Message[] {
    if (!existsSync(path)) return [];
    const out: Message[] = [];
    for (const line of readFileSync(path, 'utf8').split('\n')) {
      if (!line.trim()) continue;
      try {
        const parsed = JSON.parse(line) as TranscriptLine;
        const { _meta, ...message } = parsed;
        void _meta;
        out.push(message as Message);
      } catch {
        /* skip malformed line */
      }
    }
    return out;
  }

  private static readonly CONTROL_TOOLS = new Set(['complete', 'transition']);

  /**
   * Sanitize messages loaded from a prior transcript into turns safe to replay
   * as the head of a new job's thread:
   *  - drop `system` (old job-specific preamble/guidance),
   *  - drop control-flow tool_calls (complete/transition),
   *  - drop dangling tool_calls (no matching tool result) and orphan tool msgs,
   *  - drop assistant messages left with neither content nor tool_calls.
   * Keeps provider validity: every surviving assistant tool_call has a matching
   * tool result, and every tool result has a matching assistant tool_call.
   */
  static replayableTurns(messages: Message[]): Message[] {
    // Pass 1: drop system, strip control tool_calls.
    const stage1: Message[] = [];
    for (const m of messages) {
      if (m.role === 'system') continue;
      if (m.role === 'assistant' && m.tool_calls?.length) {
        const kept = m.tool_calls.filter((tc) => !Conversation.CONTROL_TOOLS.has(tc.function.name));
        if (kept.length === m.tool_calls.length) {
          stage1.push(m);
        } else if (kept.length > 0) {
          stage1.push({ ...m, tool_calls: kept });
        } else {
          // all tool_calls were control-flow: keep only if it still has content.
          const { tool_calls, ...rest } = m;
          void tool_calls;
          stage1.push(rest as Message);
        }
      } else {
        stage1.push(m);
      }
    }
    // Available tool result ids. This may include ids belonging to control-flow
    // (complete/transition) tool results whose assistant tool_call was stripped in
    // Pass 1: those ids never enter `keptCallIds` below, so Pass 3 drops their tool
    // results as orphans. No control-flow result survives.
    const resultIds = new Set(
      stage1.filter((m) => m.role === 'tool' && m.tool_call_id).map((m) => m.tool_call_id as string),
    );
    // Pass 2: strip dangling tool_calls (no matching result), then prune.
    const keptCallIds = new Set<string>();
    const stage2: Message[] = [];
    for (const m of stage1) {
      if (m.role === 'assistant' && m.tool_calls?.length) {
        const kept = m.tool_calls.filter((tc) => resultIds.has(tc.id));
        kept.forEach((tc) => keptCallIds.add(tc.id));
        if (kept.length === m.tool_calls.length) {
          stage2.push(m);
        } else if (kept.length > 0) {
          stage2.push({ ...m, tool_calls: kept });
        } else {
          const hasContent = typeof m.content === 'string' && m.content.trim() !== '';
          if (hasContent) {
            const { tool_calls, ...rest } = m;
            void tool_calls;
            stage2.push(rest as Message);
          }
          // else drop entirely
        }
      } else {
        stage2.push(m);
      }
    }
    // Pass 3: drop orphan tool messages (no kept assistant tool_call).
    // Also drop assistant messages left with neither tool_calls nor meaningful content.
    return stage2.filter((m) => {
      if (m.role === 'tool' && !keptCallIds.has(m.tool_call_id as string)) return false;
      if (m.role === 'assistant' && !m.tool_calls?.length) {
        if (typeof m.content === 'string' && m.content.trim() === '') return false;
        // Drop content-less assistants (no tool_calls + no text). `null` is
        // included defensively: the engine never emits it today, but a strict
        // provider would reject an assistant with neither content nor tool_calls.
        if (m.content === undefined || m.content === null) return false;
      }
      return true;
    });
  }

  seedContinuation(preamble: string, guidance: string, priorTurns: Message[], taskInstruction: string): void {
    this.messages.length = 0;
    this.persistedCount = 0;
    // Single leading system message — see seed() for why position 0 must hold
    // exactly one system message.
    this.messages.push({ role: 'system', content: `${preamble}\n\n${guidance}` });
    for (const turn of priorTurns) this.messages.push(turn);
    this.messages.push({ role: 'user', content: taskInstruction });
    this.rewrite();
  }
}
