import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createHash } from 'crypto';
import { Repository } from '../../db/repository.js';
import { loadReflectionInputs } from './load-inputs.js';
import { upsertMemoryEntry } from '../../user-folder/memory.js';

function sha1(s: string): string {
  return createHash('sha1').update(s).digest('hex');
}

describe('loadReflectionInputs', () => {
  let dir: string;
  let repo: Repository;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'reflect-load-'));
    repo = new Repository(join(dir, 'db.sqlite'));
  });

  afterEach(() => {
    repo.close?.();
    rmSync(dir, { recursive: true, force: true });
  });

  // Helper: default ctx
  function ctx(overrides?: Partial<{ builtinPiecesDir: string; activityLogMaxBytes: number }>) {
    return {
      dataDir: dir,
      builtinPiecesDir: join(dir, 'pieces'),
      activityLogMaxBytes: 4096,
      ...overrides,
    };
  }

  it('produces a full ReflectionInput from DB + filesystem', async () => {
    // Create a local task with feedback
    const task = await repo.createLocalTask({
      title: 'do thing',
      body: 'please do it',
      pieceName: 'chat',
      ownerId: 'u-1',
    } as any);
    await repo.updateFeedback(task.id, { rating: 'bad', comment: 'wrong answer', tags: ['quality'] });

    // Create a job that references the task
    const job = await repo.createJob({
      repo: `local/task-${task.id}`,
      issueNumber: task.id,
      instruction: 'do thing',
      pieceName: 'chat',
      ownerId: 'u-1',
    } as any);

    // Write a memory entry for user u-1
    upsertMemoryEntry(dir, 'u-1', {
      name: 'my_fact',
      type: 'user',
      description: 'a test fact',
      body: 'The user prefers markdown output.',
    });

    const input = await loadReflectionInputs(repo, {
      originalJobId: job.id,
      userId: 'u-1',
      pieceName: 'chat',
      outcome: 'failed',
    }, ctx());

    expect(input.taskBody).toBe('please do it');
    expect(input.taskTitle).toBe('do thing');
    expect(input.feedback.rating).toBe('bad');
    expect(input.feedback.comment).toBe('wrong answer');
    expect(input.feedback.tags).toEqual(['quality']);
    expect(input.originalJobId).toBe(job.id);
    expect(input.userId).toBe('u-1');
    expect(input.pieceName).toBe('chat');
    expect(input.outcome).toBe('failed');
    expect(input.observedRevisions).toBeDefined();
    expect(input.memoryEntries).toHaveLength(1);
    expect(input.memoryEntries[0]!.name).toBe('my_fact');
  });

  it('builds observedRevisions as sha1 of each entry body (parsed body)', async () => {
    // Create a local task and job
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      pieceName: 'chat',
      ownerId: 'u-2',
    } as any);
    const job = await repo.createJob({
      repo: `local/task-${task.id}`,
      issueNumber: task.id,
      instruction: 'b',
      pieceName: 'chat',
      ownerId: 'u-2',
    } as any);

    const body1 = 'First fact content.';
    const body2 = 'Second fact content.';

    upsertMemoryEntry(dir, 'u-2', {
      name: 'fact_one',
      type: 'reference',
      description: 'fact one',
      body: body1,
    });
    upsertMemoryEntry(dir, 'u-2', {
      name: 'fact_two',
      type: 'feedback',
      description: 'fact two',
      body: body2,
    });

    const input = await loadReflectionInputs(repo, {
      originalJobId: job.id,
      userId: 'u-2',
      pieceName: 'chat',
      outcome: 'succeeded',
    }, ctx());

    // observedRevisions maps each entry name to sha1(parsedBody).
    // Gray-matter round-trips the body, so the actual body in memoryEntries
    // (from parseMemoryEntry) is what gets hashed — verify consistency.
    const entryByName = Object.fromEntries(
      input.memoryEntries.map(e => [e.name, e.body]),
    );
    expect(input.observedRevisions['fact_one']).toBe(sha1(entryByName['fact_one']!));
    expect(input.observedRevisions['fact_two']).toBe(sha1(entryByName['fact_two']!));
    // Also verify the hashes are non-empty hex strings
    expect(input.observedRevisions['fact_one']).toMatch(/^[0-9a-f]{40}$/);
    expect(input.observedRevisions['fact_two']).toMatch(/^[0-9a-f]{40}$/);
    // And that the two entries have different revisions
    expect(input.observedRevisions['fact_one']).not.toBe(input.observedRevisions['fact_two']);
  });

  it('sets pieceSource=builtin when no custom override exists', async () => {
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      pieceName: 'chat',
      ownerId: 'u-3',
    } as any);
    const job = await repo.createJob({
      repo: `local/task-${task.id}`,
      issueNumber: task.id,
      instruction: 'b',
      pieceName: 'chat',
      ownerId: 'u-3',
    } as any);

    // Write a builtin piece YAML but no custom override
    const piecesDir = join(dir, 'pieces');
    mkdirSync(piecesDir, { recursive: true });
    writeFileSync(join(piecesDir, 'chat.yaml'), 'movements: []\n');

    const input = await loadReflectionInputs(repo, {
      originalJobId: job.id,
      userId: 'u-3',
      pieceName: 'chat',
      outcome: 'succeeded',
    }, ctx({ builtinPiecesDir: piecesDir }));

    expect(input.pieceSource).toBe('builtin');
    expect(input.pieceYaml).toBe('movements: []\n');
  });

  it('sets pieceSource=custom when a per-user override file exists', async () => {
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      pieceName: 'chat',
      ownerId: 'u-4',
    } as any);
    const job = await repo.createJob({
      repo: `local/task-${task.id}`,
      issueNumber: task.id,
      instruction: 'b',
      pieceName: 'chat',
      ownerId: 'u-4',
    } as any);

    // Write a custom piece override for u-4
    const customPiecesDir = join(dir, 'u-4', 'pieces');
    mkdirSync(customPiecesDir, { recursive: true });
    writeFileSync(join(customPiecesDir, 'chat.yaml'), 'movements: [custom]\n');

    const input = await loadReflectionInputs(repo, {
      originalJobId: job.id,
      userId: 'u-4',
      pieceName: 'chat',
      outcome: 'succeeded',
    }, ctx());

    expect(input.pieceSource).toBe('custom');
    expect(input.pieceYaml).toBe('movements: [custom]\n');
  });

  it('filters post-completion comments: only returns comments strictly after job.updatedAt', async () => {
    // Create task and job
    const task = await repo.createLocalTask({
      title: 'filter test',
      body: 'body',
      pieceName: 'chat',
      ownerId: 'u-5',
    } as any);
    const job = await repo.createJob({
      repo: `local/task-${task.id}`,
      issueNumber: task.id,
      instruction: 'body',
      pieceName: 'chat',
      ownerId: 'u-5',
    } as any);

    // Simulate job finishing and read back the final job.updatedAt
    await repo.updateJob(job.id, { status: 'succeeded' });
    const finishedJob = await repo.getJob(job.id);
    const completionTime = finishedJob!.updatedAt;

    // Add comments and call loadReflectionInputs
    await repo.addLocalTaskComment(task.id, 'alice', 'pre-completion note');
    await repo.addLocalTaskComment(task.id, 'bob', 'another note');

    const input = await loadReflectionInputs(repo, {
      originalJobId: job.id,
      userId: 'u-5',
      pieceName: 'chat',
      outcome: 'succeeded',
    }, ctx());

    // All returned post-completion comments must have createdAt > completionTime
    for (const c of input.postCompletionComments) {
      expect(c.createdAt > completionTime).toBe(true);
    }

    // The pre-completion note (added in the same second as updateJob) should
    // be absent because `createdAt <= updatedAt`. Comments added in a later
    // second would appear. Either 0 or N comments is correct here; the
    // invariant is that none with createdAt <= completionTime slips through.
    expect(
      input.postCompletionComments.every(c => c.createdAt > completionTime),
    ).toBe(true);
  });

  it('throws when originalJobId is not found', async () => {
    await expect(
      loadReflectionInputs(repo, {
        originalJobId: 'nonexistent-job-id',
        userId: 'u-9',
        pieceName: 'chat',
        outcome: 'failed',
      }, ctx()),
    ).rejects.toThrow('originalJob not found');
  });

  it('returns empty memoryEntries and empty observedRevisions when user has no memory', async () => {
    const task = await repo.createLocalTask({
      title: 't',
      body: 'b',
      pieceName: 'chat',
      ownerId: 'u-6',
    } as any);
    const job = await repo.createJob({
      repo: `local/task-${task.id}`,
      issueNumber: task.id,
      instruction: 'b',
      pieceName: 'chat',
      ownerId: 'u-6',
    } as any);

    const input = await loadReflectionInputs(repo, {
      originalJobId: job.id,
      userId: 'u-6',
      pieceName: 'chat',
      outcome: 'succeeded',
    }, ctx());

    expect(input.memoryEntries).toEqual([]);
    expect(input.observedRevisions).toEqual({});
    expect(input.memoryIndex).toBe('');
  });
});
