import { describe, it, expect } from 'vitest';
import type { Message, ToolCall } from '../../llm/openai-compat.js';
import type { EventLogger, EmitOptions } from '../../progress/event-log.js';
import type { Movement } from './types.js';
import {
  buildMaxIterationsAbortMessage,
  fingerprintToolCalls,
  buildToolLoopAbortMessage,
  createToolLoopTracker,
  handleTextOnlyResponse,
} from './loop-safety.js';

function call(name: string, args: string, id = 'c'): ToolCall {
  return { id, type: 'function', function: { name, arguments: args } } as ToolCall;
}

/** EventLogger that records emit() calls. */
function captureLogger(): EventLogger & { emitted: Array<{ kind: string; payload: unknown }> } {
  const emitted: Array<{ kind: string; payload: unknown }> = [];
  return {
    emitted,
    emit(kind: string, payload: unknown, _opts?: EmitOptions): string {
      emitted.push({ kind, payload });
      return 'evt';
    },
  };
}

function mv(over: Partial<Movement> = {}): Movement {
  return {
    name: 'm1',
    edit: false,
    persona: 'p',
    instruction: 'i',
    allowedTools: [],
    rules: [{ condition: 'c', next: 'm2' }],
    ...over,
  };
}

describe('buildMaxIterationsAbortMessage', () => {
  it('includes movement name, limit, and tools used', () => {
    const s = buildMaxIterationsAbortMessage('analyze', 200, ['Read', 'Grep']);
    expect(s).toContain('"analyze"');
    expect(s).toContain('200');
    expect(s).toContain('Read, Grep');
  });
  it('says "none" when no tools were used', () => {
    expect(buildMaxIterationsAbortMessage('m', 10, [])).toContain('none');
  });
});

describe('fingerprintToolCalls', () => {
  it('is insensitive to JSON key order', () => {
    const a = fingerprintToolCalls([call('Read', '{"a":1,"b":2}')]);
    const b = fingerprintToolCalls([call('Read', '{"b":2,"a":1}')]);
    expect(a).toBe(b);
  });
  it('differs when arguments differ', () => {
    const a = fingerprintToolCalls([call('Read', '{"file":"x"}')]);
    const b = fingerprintToolCalls([call('Read', '{"file":"y"}')]);
    expect(a).not.toBe(b);
  });
  it('differs when tool name differs', () => {
    expect(fingerprintToolCalls([call('Read', '{}')]))
      .not.toBe(fingerprintToolCalls([call('Grep', '{}')]));
  });
  it('falls back to raw string on invalid JSON (no throw)', () => {
    const fp = fingerprintToolCalls([call('X', 'not json')]);
    expect(fp).toBe('X(not json)');
  });
  it('joins multiple calls with |', () => {
    const fp = fingerprintToolCalls([call('A', '{}'), call('B', '{}')]);
    expect(fp).toBe('A({})|B({})');
  });
});

describe('buildToolLoopAbortMessage', () => {
  it('reports tool names, repeats and limit', () => {
    const s = buildToolLoopAbortMessage('exec', 5, 5, [call('Read', '{}'), call('Grep', '{}')]);
    expect(s).toContain('Read, Grep');
    expect(s).toContain('5 times');
    expect(s).toContain('limit: 5');
  });
});

describe('createToolLoopTracker', () => {
  const ev = captureLogger();
  const same = [call('Read', '{"f":"a"}')];

  it('returns none on the first occurrence and warns near the limit', () => {
    const t = createToolLoopTracker({ movementName: 'exec', limit: 5 });
    const base = { hasFlowControlCalls: false, toolsUsed: ['Read'], eventLogger: ev };
    expect(t.evaluate({ regularCalls: same, iteration: 0, ...base }).kind).toBe('none'); // 1
    expect(t.evaluate({ regularCalls: same, iteration: 1, ...base }).kind).toBe('none'); // 2
    const warn = t.evaluate({ regularCalls: same, iteration: 2, ...base }); // 3 -> warnAt
    expect(warn.kind).toBe('warn');
    if (warn.kind === 'warn') {
      expect(warn.repeats).toBe(3);
      expect(warn.remaining).toBe(2);
    }
    expect(t.evaluate({ regularCalls: same, iteration: 3, ...base }).kind).toBe('none'); // 4 (already warned)
    const abort = t.evaluate({ regularCalls: same, iteration: 4, ...base }); // 5 -> abort
    expect(abort.kind).toBe('abort');
    if (abort.kind === 'abort') {
      expect(abort.result.next).toBe('ABORT');
      expect(abort.result.abortCode).toBe('tool_loop_detected');
    }
  });

  it('emits a tool_loop_detected event on abort', () => {
    const local = captureLogger();
    const t = createToolLoopTracker({ movementName: 'exec', limit: 2 });
    const base = { hasFlowControlCalls: false, toolsUsed: ['Read'], eventLogger: local };
    t.evaluate({ regularCalls: same, iteration: 0, ...base });
    t.evaluate({ regularCalls: same, iteration: 1, ...base }); // 2 -> abort (limit 2)
    expect(local.emitted.some((e) => e.kind === 'tool_loop_detected')).toBe(true);
  });

  it('resets the counter when the batch changes', () => {
    // limit 5 → warnAt = max(2, 3) = 3, so two repeats stay 'none'.
    const t = createToolLoopTracker({ movementName: 'exec', limit: 5 });
    const base = { hasFlowControlCalls: false, toolsUsed: ['Read'], eventLogger: ev };
    t.evaluate({ regularCalls: same, iteration: 0, ...base }); // repeats 1
    t.evaluate({ regularCalls: same, iteration: 1, ...base }); // repeats 2
    // different batch resets the counter back to 1
    const other = [call('Read', '{"f":"b"}')];
    expect(t.evaluate({ regularCalls: other, iteration: 2, ...base }).kind).toBe('none'); // reset -> 1
    expect(t.evaluate({ regularCalls: other, iteration: 3, ...base }).kind).toBe('none'); // 2 (< warnAt 3)
  });

  it('returns none when there are no regular calls or flow-control is present', () => {
    const t = createToolLoopTracker({ movementName: 'exec', limit: 2 });
    const base = { toolsUsed: [], eventLogger: ev };
    expect(t.evaluate({ regularCalls: [], hasFlowControlCalls: false, iteration: 0, ...base }).kind).toBe('none');
    expect(t.evaluate({ regularCalls: same, hasFlowControlCalls: true, iteration: 1, ...base }).kind).toBe('none');
  });
});

describe('handleTextOnlyResponse', () => {
  it('reminds (continue) without incrementing retry on empty text', () => {
    const messages: Message[] = [];
    const retry = { value: 0 };
    const out = handleTextOnlyResponse('   ', mv(), [], messages, retry);
    expect(out.kind).toBe('continue');
    expect(retry.value).toBe(0);
    expect(messages).toHaveLength(1);
    expect(messages[0].role).toBe('user');
  });

  it('reminds with valid targets and increments retry on non-empty text below the limit', () => {
    const messages: Message[] = [];
    const retry = { value: 0 };
    const out = handleTextOnlyResponse('some prose', mv({ rules: [{ condition: 'c', next: 'next-step' }] }), [], messages, retry);
    expect(out.kind).toBe('continue');
    expect(retry.value).toBe(1);
    expect(String(messages[0].content)).toContain('next-step');
  });

  it('aborts with text_only_limit after 3 reminders in a non-terminal movement', () => {
    const messages: Message[] = [];
    const retry = { value: 2 }; // next call makes it 3
    const out = handleTextOnlyResponse('prose', mv(), ['Read'], messages, retry);
    expect(out.kind).toBe('abort');
    if (out.kind === 'abort') {
      expect(out.result.next).toBe('ABORT');
      expect(out.result.abortCode).toBe('text_only_limit');
    }
  });

  it('salvages prose as completion in a strictly terminal movement', () => {
    const messages: Message[] = [];
    const retry = { value: 2 };
    const terminal = mv({ defaultNext: 'COMPLETE', rules: [] });
    const out = handleTextOnlyResponse('final answer', terminal, [], messages, retry);
    expect(out.kind).toBe('abort');
    if (out.kind === 'abort') {
      expect(out.result.next).toBe('COMPLETE');
      expect(out.result.output).toBe('final answer');
    }
  });
});
