import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Repository } from './repository.js';

describe('jobs.task_kind / payload columns', () => {
  let dir: string;
  let r: Repository;

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

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


  it('exist after Repository init', () => {
    const cols = r.getDb()
      .prepare("PRAGMA table_info(jobs)")
      .all() as Array<{ name: string; dflt_value: string | null }>;
    const taskKind = cols.find(c => c.name === 'task_kind');
    const payload = cols.find(c => c.name === 'payload');
    expect(taskKind).toBeTruthy();
    expect(taskKind?.dflt_value).toBe("'agent'");
    expect(payload).toBeTruthy();
  });

  it('createJob accepts taskKind/payload and reads back via getJob', async () => {
    const job = await r.createJob({
      repo: 'local/reflection-x',
      issueNumber: 0,
      instruction: '',
      pieceName: 'reflection',
      role: 'reflection',
      taskKind: 'reflection',
      payload: JSON.stringify({ originalJobId: 'j1', userId: 'u1' }),
    } as any);
    expect(job.taskKind).toBe('reflection');
    expect(job.payload).toBeTruthy();
    expect(JSON.parse(job.payload!).originalJobId).toBe('j1');
  });

  it('createJob defaults taskKind to "agent" and payload to null when omitted', async () => {
    const job = await r.createJob({
      repo: 'local/task-1',
      issueNumber: 1,
      instruction: 'hello',
      pieceName: 'chat',
    } as any);
    expect(job.taskKind).toBe('agent');
    expect(job.payload).toBeNull();
  });
});

describe('reflection_piece_edits table', () => {
  let dir: string;
  let r: Repository;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'rpe-cols-'));
    r = new Repository(join(dir, 'db.sqlite'));
  });

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

  it('table exists with expected columns after Repository init', () => {
    const cols = r.getDb()
      .prepare("PRAGMA table_info(reflection_piece_edits)")
      .all() as Array<{ name: string; notnull: number; pk: number }>;

    const names = cols.map(c => c.name);
    expect(names).toContain('user_id');
    expect(names).toContain('piece_name');
    expect(names).toContain('snapshot_id');
    expect(names).toContain('created_at');

    // Primary key: (user_id, piece_name, created_at)
    const pkCols = cols.filter(c => c.pk > 0).map(c => c.name).sort();
    expect(pkCols).toEqual(['created_at', 'piece_name', 'user_id']);
  });

  it('index idx_rpe_user_piece_time exists', () => {
    const indexes = r.getDb()
      .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='reflection_piece_edits'")
      .all() as Array<{ name: string }>;
    const indexNames = indexes.map(i => i.name);
    expect(indexNames).toContain('idx_rpe_user_piece_time');
  });

  it('recordPieceEdit inserts a row and countRecentPieceEdits counts it', () => {
    r.recordPieceEdit('u1', 'chat', 'snap-1');
    const count = r.countRecentPieceEdits('u1', 'chat', 60_000);
    expect(count).toBe(1);
  });

  it('countRecentPieceEdits is scoped to user_id and piece_name', () => {
    r.recordPieceEdit('u1', 'chat', 'snap-1');
    r.recordPieceEdit('u2', 'chat', 'snap-2');   // different user
    r.recordPieceEdit('u1', 'research', 'snap-3'); // different piece

    expect(r.countRecentPieceEdits('u1', 'chat', 60_000)).toBe(1);
    expect(r.countRecentPieceEdits('u2', 'chat', 60_000)).toBe(1);
    expect(r.countRecentPieceEdits('u1', 'research', 60_000)).toBe(1);
  });

  it('countRecentPieceEdits excludes rows older than sinceMs', () => {
    // Insert a row with a created_at 2 hours in the past by manipulating DB directly.
    const twoHoursAgo = Date.now() - 2 * 3600 * 1000;
    r.getDb()
      .prepare(
        `INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at)
         VALUES (?, ?, ?, ?)`
      )
      .run('u1', 'old-piece', 'snap-old', twoHoursAgo);

    // 1-hour window should not see it.
    expect(r.countRecentPieceEdits('u1', 'old-piece', 3600 * 1000)).toBe(0);
    // 3-hour window should see it.
    expect(r.countRecentPieceEdits('u1', 'old-piece', 3 * 3600 * 1000)).toBe(1);
  });
});

describe('reflection_metrics table', () => {
  let dir: string;
  let r: Repository;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'rm-cols-'));
    r = new Repository(join(dir, 'db.sqlite'));
  });

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

  it('table exists with expected columns after Repository init', () => {
    const cols = r.getDb()
      .prepare('PRAGMA table_info(reflection_metrics)')
      .all() as Array<{ name: string }>;
    const names = cols.map(c => c.name);
    expect(names).toContain('id');
    expect(names).toContain('reflection_job_id');
    expect(names).toContain('original_job_id');
    expect(names).toContain('user_id');
    expect(names).toContain('piece_name');
    expect(names).toContain('outcome');
    expect(names).toContain('memory_changes');
    expect(names).toContain('piece_edited');
    expect(names).toContain('tokens_in');
    expect(names).toContain('tokens_out');
    expect(names).toContain('duration_ms');
    expect(names).toContain('created_at');
  });

  it('index idx_rm_user_time exists', () => {
    const indexes = r.getDb()
      .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='reflection_metrics'")
      .all() as Array<{ name: string }>;
    expect(indexes.map(i => i.name)).toContain('idx_rm_user_time');
  });

  it('recordReflectionMetric inserts a row', () => {
    r.recordReflectionMetric({
      reflection_job_id: 'rj-1',
      original_job_id: 'oj-1',
      user_id: 'u1',
      piece_name: 'chat',
      outcome: 'applied',
      memory_changes: 2,
      piece_edited: 0,
      tokens_in: 500,
      tokens_out: 80,
      duration_ms: 1234,
    });

    const rows = r.getDb()
      .prepare('SELECT * FROM reflection_metrics WHERE reflection_job_id = ?')
      .all('rj-1') as Array<Record<string, unknown>>;
    expect(rows).toHaveLength(1);
    expect(rows[0]!.outcome).toBe('applied');
    expect(rows[0]!.memory_changes).toBe(2);
    expect(rows[0]!.tokens_in).toBe(500);
    expect(rows[0]!.tokens_out).toBe(80);
    expect(rows[0]!.duration_ms).toBe(1234);
    expect(rows[0]!.piece_edited).toBe(0);
  });

  it('aggregateReflectionMetrics sums correctly across outcomes', () => {
    const now = Date.now();
    r.recordReflectionMetric({
      reflection_job_id: 'rj-a',
      original_job_id: 'oj-a',
      user_id: 'u1',
      piece_name: 'chat',
      outcome: 'applied',
      memory_changes: 2,
      piece_edited: 1,
      tokens_in: 100,
      tokens_out: 20,
      duration_ms: 500,
    });
    r.recordReflectionMetric({
      reflection_job_id: 'rj-b',
      original_job_id: 'oj-b',
      user_id: 'u1',
      piece_name: 'chat',
      outcome: 'abstained',
      memory_changes: 0,
      piece_edited: 0,
      tokens_in: 50,
      tokens_out: 10,
      duration_ms: 300,
    });
    // Different user — should not be counted
    r.recordReflectionMetric({
      reflection_job_id: 'rj-c',
      original_job_id: 'oj-c',
      user_id: 'u2',
      piece_name: 'chat',
      outcome: 'applied',
      memory_changes: 1,
      piece_edited: 0,
      tokens_in: 999,
      tokens_out: 999,
      duration_ms: 999,
    });

    const agg = r.aggregateReflectionMetrics('u1', now - 60_000);
    expect(agg.applied).toBe(1);
    expect(agg.abstained).toBe(1);
    expect(agg.failed).toBe(0);
    expect(agg.totalRuns).toBe(2);
    expect(agg.tokensIn).toBe(150);
    expect(agg.tokensOut).toBe(30);
    expect(agg.pieceEdits).toBe(1);
  });

  it('aggregateReflectionMetrics excludes rows before sinceMs', () => {
    // Insert an old row directly
    const twoHoursAgo = Date.now() - 2 * 3600 * 1000;
    r.getDb()
      .prepare(
        `INSERT INTO reflection_metrics
         (reflection_job_id, original_job_id, user_id, piece_name, outcome,
          memory_changes, piece_edited, tokens_in, tokens_out, duration_ms, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
      )
      .run('rj-old', 'oj-old', 'u1', 'chat', 'applied', 1, 0, 100, 20, 500, twoHoursAgo);

    // 1-hour window should exclude it
    const agg = r.aggregateReflectionMetrics('u1', Date.now() - 3600 * 1000);
    expect(agg.totalRuns).toBe(0);
    expect(agg.applied).toBe(0);
  });

  it('recordReflectionRun with pieceEdit inserts both rows atomically', () => {
    r.recordReflectionRun(
      {
        reflection_job_id: 'rj-tx',
        original_job_id: 'oj-tx',
        user_id: 'u1',
        piece_name: 'chat',
        outcome: 'applied',
        memory_changes: 1,
        piece_edited: 1,
        tokens_in: 200,
        tokens_out: 40,
        duration_ms: 800,
      },
      { pieceName: 'chat', snapshotId: 'snap-tx' },
    );

    // Both tables should have a row
    const metricRows = r.getDb()
      .prepare('SELECT * FROM reflection_metrics WHERE reflection_job_id = ?')
      .all('rj-tx');
    expect(metricRows).toHaveLength(1);

    const editCount = r.countRecentPieceEdits('u1', 'chat', 60_000);
    expect(editCount).toBe(1);
  });
});

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

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'rr-int-'));
    repo = new Repository(join(dir, 'db.sqlite'));
    // Seed a minimal built-in piece YAML
    const piecesDir = join(dir, 'pieces');
    mkdirSync(piecesDir, { recursive: true });
    writeFileSync(
      join(piecesDir, 'chat.yaml'),
      'name: chat\ndescription: basic chat\nmovements:\n  - name: respond\n    rules: []\n',
    );
  });

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

  it('records a metric row with real outcome and writes a snapshot directory', async () => {
    // Seed a minimal original job + local task so loadReflectionInputs can find them
    const originalJob = await repo.createJob({
      repo: 'local/task-1',
      issueNumber: 1,
      instruction: 'do the thing',
      pieceName: 'chat',
      taskKind: 'agent',
    } as any);

    // Create a reflection job
    const reflJob = await repo.createJob({
      repo: 'local/task-1',
      issueNumber: 0,
      instruction: '',
      pieceName: 'reflection',
      role: 'reflection',
      taskKind: 'reflection',
      payload: JSON.stringify({
        originalJobId: originalJob.id,
        userId: 'u-int-test',
        pieceName: 'chat',
        outcome: 'succeeded',
      }),
    } as any);

    // Mock fetch so callReflectionLlm (now streaming via OpenAICompatClient)
    // returns a valid abstain result as an SSE submit_reflection tool_call.
    const reflectionArgs = JSON.stringify({
      memory_changes: [],
      piece_changes: { should_edit: false },
      reasoning: 'nothing to learn',
      abstain_reason: 'task completed successfully without issues',
    });
    const sseLines = [
      `data: ${JSON.stringify({ model: 'reflect-model', choices: [{ delta: { tool_calls: [{ index: 0, id: 'r1', function: { name: 'submit_reflection', arguments: reflectionArgs } }] }, finish_reason: null }] })}\n\n`,
      `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] })}\n\n`,
      `data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 123, completion_tokens: 45 } })}\n\n`,
      'data: [DONE]\n\n',
    ];
    const mockFetch = vi.fn().mockImplementation(async () => {
      const encoder = new TextEncoder();
      let i = 0;
      return {
        ok: true,
        status: 200,
        headers: { get: () => null },
        body: {
          getReader: () => ({
            read: async () =>
              i < sseLines.length
                ? { done: false, value: encoder.encode(sseLines[i++]) }
                : { done: true, value: undefined },
            releaseLock: () => {},
          }),
        },
      } as any;
    });
    vi.stubGlobal('fetch', mockFetch);

    const { runReflectionJob } = await import('../engine/reflection/reflection-runner.js');

    const outcome = await runReflectionJob(
      {
        repo,
        config: {
          userFolderRoot: dir,
          reflection: {
            enabled: true,
            workerRequired: false,
            maxMemoryChangesPerJob: 3,
            maxEntryBodyBytes: 8192,
            pieceEditCooldownHours: 24,
            snapshotRetentionDays: 90,
            activityLogMaxBytes: 4096,
            abstainRateFloor: 0.2,
            perUserDailyBudgetTokens: 100_000,
            snapshotMaxBytesPerUser: 10 * 1024 * 1024,
            snapshotMaxBytesPerEntry: 512 * 1024,
            storeLlmRaw: false,
          },
        } as any,
        llmEndpoint: 'http://localhost:11434',
        llmModel: 'test-model',
      },
      reflJob,
    );

    // Outcome should be 'abstained' (no changes, abstain_reason present)
    expect(outcome).toBe('abstained');

    // Metric row must exist
    const rows = repo.getDb()
      .prepare('SELECT * FROM reflection_metrics WHERE reflection_job_id = ?')
      .all(reflJob.id) as Array<Record<string, unknown>>;
    expect(rows).toHaveLength(1);
    expect(rows[0]!.outcome).toBe('abstained');
    expect(rows[0]!.memory_changes).toBe(0);
    expect(rows[0]!.piece_edited).toBe(0);
    expect(rows[0]!.tokens_in).toBe(123);
    expect(rows[0]!.tokens_out).toBe(45);

    // Snapshot directory must exist under data/users/{userId}/.reflection-history/
    const historyDir = join(dir, 'u-int-test', '.reflection-history');
    expect(existsSync(historyDir)).toBe(true);
    // index.jsonl should have one entry
    const indexPath = join(historyDir, 'index.jsonl');
    expect(existsSync(indexPath)).toBe(true);
  });
});
