/**
 * reflection-api.test.ts
 *
 * Unit tests for the reflection history REST API.
 * Uses an in-memory SQLite repository + temp filesystem for snapshots.
 */

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { writeSnapshot, type WriteSnapshotMeta } from '../engine/reflection/snapshot.js';
import { createReflectionApi } from './reflection-api.js';

// ── Fixtures ──────────────────────────────────────────────────────────────────

const OWNER_ID = 'u-reflect-owner';
const OTHER_ID = 'u-reflect-other';

function makeMeta(overrides: Partial<WriteSnapshotMeta> = {}): WriteSnapshotMeta {
  return {
    originalJobId: 'j-test-001',
    userId: OWNER_ID,
    pieceName: 'chat',
    outcome: 'applied',
    reasoning: 'User prefers concise answers.',
    modelUsed: 'qwen2.5:3b',
    tokensIn: 800,
    tokensOut: 60,
    ratingAtTime: null,
    memoryChanges: 1,
    pieceEdited: false,
    ...overrides,
  };
}

// Create an Express app wired up with the reflection API, with auth disabled
// (auth gate injects synthetic user from X-Test-User-Id header via a middleware).
function buildApp(dataDir: string, repo: Repository, userId: string = OWNER_ID) {
  const app = express();
  app.use(express.json());

  // Inject user from query param for tests (simulates requireAuth)
  app.use((req, _res, next) => {
    const testUserId = req.headers['x-test-user-id'] as string | undefined;
    if (testUserId) {
      (req as any).user = { id: testUserId, role: testUserId === 'admin' ? 'admin' : 'user' };
    }
    next();
  });

  app.use(
    '/api/local/reflection',
    createReflectionApi({ dataDir, repo, authActive: false }),
  );

  return app;
}

// Helper to set the user for a request
function asUser(agent: request.SuperTest<request.Test>, userId: string) {
  return { userId };
}

// ── Test suite ────────────────────────────────────────────────────────────────

describe('reflection-api', () => {
  let tmpDir: string;
  let repo: Repository;
  let dbPath: string;
  let app: express.Application;

  beforeEach(() => {
    tmpDir = mkdtempSync(join(tmpdir(), 'reflect-api-'));
    dbPath = join(tmpDir, 'test.db');
    repo = new Repository(dbPath);
    app = buildApp(tmpDir, repo);
  });

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

  // ── GET /history — paged listing ──────────────────────────────────────────

  describe('GET /history', () => {
    it('returns empty list when no snapshots exist', async () => {
      const res = await request(app)
        .get('/api/local/reflection/history')
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(200);
      expect(res.body.items).toEqual([]);
      expect(res.body.nextCursor).toBeNull();
    });

    it('returns items most-recent first with correct paging', async () => {
      const deps = { dataDir: tmpDir };
      const dateA = new Date('2026-05-10T08:00:00Z');
      const dateB = new Date('2026-05-11T09:00:00Z');
      const dateC = new Date('2026-05-12T10:00:00Z');

      await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-a' }), undefined, undefined, dateA);
      await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-b' }), undefined, undefined, dateB);
      await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-c' }), undefined, undefined, dateC);

      const res = await request(app)
        .get('/api/local/reflection/history?limit=2')
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(200);
      const { items, nextCursor } = res.body;

      // Most recent first
      expect(items).toHaveLength(2);
      expect(items[0].jobId).toBe('j-c');
      expect(items[1].jobId).toBe('j-b');

      // nextCursor should be the ts of the last returned item
      expect(nextCursor).toBe(items[1].ts);
    });

    it('pages correctly with before cursor', async () => {
      const deps = { dataDir: tmpDir };
      const dateA = new Date('2026-05-10T08:00:00Z');
      const dateB = new Date('2026-05-11T09:00:00Z');
      const dateC = new Date('2026-05-12T10:00:00Z');

      await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-a' }), undefined, undefined, dateA);
      await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-b' }), undefined, undefined, dateB);
      await writeSnapshot(deps, {}, {}, makeMeta({ originalJobId: 'j-c' }), undefined, undefined, dateC);

      // First page: limit=2 gives j-c, j-b; cursor = j-b.ts
      const page1 = await request(app)
        .get('/api/local/reflection/history?limit=2')
        .set('x-test-user-id', OWNER_ID);

      const cursor = page1.body.nextCursor;

      // Second page: before=cursor should give j-a
      const page2 = await request(app)
        .get(`/api/local/reflection/history?limit=2&before=${encodeURIComponent(cursor)}`)
        .set('x-test-user-id', OWNER_ID);

      expect(page2.status).toBe(200);
      expect(page2.body.items).toHaveLength(1);
      expect(page2.body.items[0].jobId).toBe('j-a');
      expect(page2.body.nextCursor).toBeNull(); // last page
    });

    it('returns 401 when unauthenticated (auth active)', async () => {
      // Build an app with authActive=true (no synthetic user injection)
      const authApp = express();
      authApp.use(express.json());
      authApp.use('/api/local/reflection', createReflectionApi({ dataDir: tmpDir, repo, authActive: true }));

      const res = await request(authApp).get('/api/local/reflection/history');
      expect(res.status).toBe(401);
    });
  });

  // ── GET /history/:snapshotId — detail ─────────────────────────────────────

  describe('GET /history/:snapshotId', () => {
    it('returns full detail for owner', async () => {
      const deps = { dataDir: tmpDir };
      const before = { 'pref-terse.md': 'old content' };
      const after  = { 'pref-terse.md': 'new content' };
      const fixedDate = new Date('2026-05-11T12:00:00Z');

      const { snapshotId } = await writeSnapshot(deps, before, after, makeMeta(), undefined, undefined, fixedDate);

      const res = await request(app)
        .get(`/api/local/reflection/history/${snapshotId}`)
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(200);
      expect(res.body.snapshotId).toBe(snapshotId);
      expect(res.body.outcome).toBe('applied');
      expect(res.body.beforeFiles).toHaveProperty('pref-terse.md', 'old content');
      expect(res.body.afterFiles).toHaveProperty('pref-terse.md', 'new content');
      expect(typeof res.body.diff).toBe('string');
    });

    it('returns 404 for non-owner (no existence leak)', async () => {
      const deps = { dataDir: tmpDir };
      const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());

      const res = await request(app)
        .get(`/api/local/reflection/history/${snapshotId}`)
        .set('x-test-user-id', OTHER_ID);

      expect(res.status).toBe(404);
    });

    it('returns 404 for non-existent snapshot', async () => {
      const res = await request(app)
        .get('/api/local/reflection/history/99999999T000000Z-ghost')
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(404);
    });
  });

  // ── POST /history/:snapshotId/revert — idempotent revert ──────────────────

  describe('POST /history/:snapshotId/revert', () => {
    it('reverts successfully on first call', async () => {
      const deps = { dataDir: tmpDir };
      const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());

      const res = await request(app)
        .post(`/api/local/reflection/history/${snapshotId}/revert`)
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(200);
      expect(res.body.reverted).toBe(true);
    });

    it('is idempotent: second call returns { reverted: false }', async () => {
      const deps = { dataDir: tmpDir };
      const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());

      await request(app)
        .post(`/api/local/reflection/history/${snapshotId}/revert`)
        .set('x-test-user-id', OWNER_ID);

      const res2 = await request(app)
        .post(`/api/local/reflection/history/${snapshotId}/revert`)
        .set('x-test-user-id', OWNER_ID);

      expect(res2.status).toBe(200);
      expect(res2.body.reverted).toBe(false);
    });

    it('returns 404 for non-owner', async () => {
      const deps = { dataDir: tmpDir };
      const { snapshotId } = await writeSnapshot(deps, {}, {}, makeMeta(), undefined, undefined, new Date());

      const res = await request(app)
        .post(`/api/local/reflection/history/${snapshotId}/revert`)
        .set('x-test-user-id', OTHER_ID);

      expect(res.status).toBe(404);
    });
  });

  // ── GET /metrics — graceful when table absent ─────────────────────────────

  describe('GET /metrics', () => {
    it('returns zero counts when reflection_metrics table does not exist', async () => {
      const res = await request(app)
        .get('/api/local/reflection/metrics')
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(200);
      expect(res.body).toMatchObject({
        applied: 0,
        partial: 0,
        abstained: 0,
        rejected: 0,
        failed: 0,
        tokensIn: 0,
        tokensOut: 0,
        pieceEdits: 0,
      });
    });

    it('returns zero counts for a user with no rows (table exists)', async () => {
      // Create the table manually
      repo.getDb().exec(`
        CREATE TABLE IF NOT EXISTS reflection_metrics (
          id          INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id     TEXT NOT NULL,
          outcome     TEXT NOT NULL,
          piece_edited INTEGER NOT NULL DEFAULT 0,
          tokens_in   INTEGER,
          tokens_out  INTEGER,
          created_at  TEXT NOT NULL
        )
      `);

      const res = await request(app)
        .get('/api/local/reflection/metrics')
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(200);
      expect(res.body.applied).toBe(0);
      expect(res.body.tokensIn).toBe(0);
    });

    it('aggregates correctly when rows exist', async () => {
      // Phase 8.1's reflection_metrics table is auto-created by Repository
      // init (NOT NULL on reflection_job_id, INTEGER created_at). Insert
      // through that real schema.
      const now = Date.now();
      const ins = repo.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      `);
      ins.run('r-1', 'j-1', OWNER_ID, 'chat', 'applied', 2, 1, 500, 40, 100, now);
      ins.run('r-2', 'j-2', OWNER_ID, 'chat', 'rejected', 0, 0, 300, 20, 80, now);
      // Other user's row — should NOT appear
      ins.run('r-3', 'j-3', OTHER_ID, 'chat', 'applied', 1, 0, 999, 99, 50, now);

      const res = await request(app)
        .get('/api/local/reflection/metrics')
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(200);
      expect(res.body.applied).toBe(1);
      expect(res.body.rejected).toBe(1);
      expect(res.body.pieceEdits).toBe(1);
      expect(res.body.tokensIn).toBe(800);
      expect(res.body.tokensOut).toBe(60);
      // Other user's rows excluded
      expect(res.body.tokensIn).not.toBe(1799);
    });
  });

  // ── GET /latest-for-task/:taskId — ReflectionBadge feed ──────────────────

  describe('GET /latest-for-task/:taskId', () => {
    it('returns null when no snapshot for the task', async () => {
      const res = await request(app)
        .get('/api/local/reflection/latest-for-task/42')
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(200);
      expect(res.body).toBeNull();
    });

    it('returns null for invalid taskId', async () => {
      const res = await request(app)
        .get('/api/local/reflection/latest-for-task/notanumber')
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(400);
    });

    it('returns snapshot summary when a matching job+snapshot exists', async () => {
      // Insert a job row with repo='local/task-7' owned by OWNER_ID
      const db = repo.getDb();
      const now = new Date().toISOString();
      db.prepare(`
        INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class,
          instruction, attempt, max_attempts, ask_count, subtask_depth, task_kind, created_at, updated_at, owner_id)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      `).run('j-task7-001', 'local/task-7', 1, 'succeeded', 'chat', 'default', 'auto',
        'Test task', 1, 1, 0, 0, 'agent', now, now, OWNER_ID);

      // Write a snapshot referencing that job
      const deps = { dataDir: tmpDir };
      const { snapshotId } = await writeSnapshot(
        deps,
        { 'pref.md': 'before' },
        { 'pref.md': 'after' },
        makeMeta({ originalJobId: 'j-task7-001', memoryChanges: 3, pieceEdited: true }),
        undefined,
        undefined,
        new Date('2026-05-11T15:00:00Z'),
      );

      const res = await request(app)
        .get('/api/local/reflection/latest-for-task/7')
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(200);
      expect(res.body).not.toBeNull();
      expect(res.body.snapshotId).toBe(snapshotId);
      expect(res.body.outcome).toBe('applied');
      expect(res.body.memoryChanges).toBe(3);
      expect(res.body.pieceEdited).toBe(true);
    });

    it('no-auth: returns the snapshot for a NULL-owner task job via the synthetic local user', async () => {
      // Reproduces the real no-auth deployment: task jobs are created with
      // owner_id = NULL (req.user is undefined), while reflection snapshots are
      // written under the 'local' namespace (reflectionOwner = job.ownerId ??
      // 'local'). The badge query filtered `owner_id = 'local'`, which never
      // matches a NULL owner, so the 🧠 badge stayed silently empty even though
      // reflection ran and wrote to data/users/local/.reflection-history/.
      const db = repo.getDb();
      const now = new Date().toISOString();
      db.prepare(`
        INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class,
          instruction, attempt, max_attempts, ask_count, subtask_depth, task_kind, created_at, updated_at, owner_id)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      `).run('j-task9-001', 'local/task-9', 1, 'succeeded', 'chat', 'default', 'auto',
        'No-auth task', 1, 1, 0, 0, 'agent', now, now, null);

      const deps = { dataDir: tmpDir };
      const { snapshotId } = await writeSnapshot(
        deps,
        { 'pref.md': 'before' },
        { 'pref.md': 'after' },
        makeMeta({ originalJobId: 'j-task9-001', userId: 'local', memoryChanges: 2, pieceEdited: false }),
        undefined,
        undefined,
        new Date('2026-05-11T16:00:00Z'),
      );

      // No x-test-user-id header → the auth gate injects the synthetic 'local' user.
      const res = await request(app).get('/api/local/reflection/latest-for-task/9');

      expect(res.status).toBe(200);
      expect(res.body).not.toBeNull();
      expect(res.body.snapshotId).toBe(snapshotId);
      expect(res.body.memoryChanges).toBe(2);
    });

    it('returns null when the task job is owned by another user', async () => {
      // Insert a job owned by OTHER_ID for task 8
      const db = repo.getDb();
      const now = new Date().toISOString();
      db.prepare(`
        INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class,
          instruction, attempt, max_attempts, ask_count, subtask_depth, task_kind, created_at, updated_at, owner_id)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      `).run('j-task8-001', 'local/task-8', 1, 'succeeded', 'chat', 'default', 'auto',
        'Other task', 1, 1, 0, 0, 'agent', now, now, OTHER_ID);

      // Write a snapshot for OTHER_ID
      const deps = { dataDir: tmpDir };
      await writeSnapshot(
        deps,
        {},
        {},
        makeMeta({ originalJobId: 'j-task8-001', userId: OTHER_ID }),
        undefined,
        undefined,
        new Date(),
      );

      // OWNER_ID should not see OTHER_ID's task
      const res = await request(app)
        .get('/api/local/reflection/latest-for-task/8')
        .set('x-test-user-id', OWNER_ID);

      expect(res.status).toBe(200);
      expect(res.body).toBeNull();
    });
  });
});
