import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import type { Repository } from '../db/repository.js';
import { registerLocalTaskStreamRoutes, formatDelegateSseFrame } from './local-tasks-stream-api.js';
import { jobEventBus } from './job-events.js';
import type { LocalTasksDeps } from './local-tasks-shared.js';

// Functional tests for GET /api/local/tasks/:taskId/stream (APIC-016, gap: NONE).
// Covers the visibility gate (non-viewer must not leak), the 204 no-running-job
// path, the content-type + SSE-frame mapping for tool-call/text events, and the
// `done` event that ends the stream. The stream is event-driven via jobEventBus
// (no initial frame is written on open), so each test pumps an event through the
// bus and asserts the resulting `data:` frame.

const RUNNING_JOB = { id: 'job-1', status: 'running' as const };

// getLocalTask honors the visibility model: a viewer who cannot see the task
// receives `null`, which the route maps to 404 (no task data leaked). We model
// that by having the fake repo return null when the wrong viewer asks.
function makeRepo(overrides: Partial<Repository> = {}): Repository {
  return {
    getLocalTask: vi.fn().mockResolvedValue({
      id: 1,
      ownerId: 'user-1',
      visibility: 'private',
      latestJob: RUNNING_JOB,
    }),
    ...overrides,
  } as unknown as Repository;
}

function makeUser(overrides: Partial<Express.User> = {}): Express.User {
  return {
    id: 'user-1',
    email: 'u@example.com',
    name: 'User One',
    avatarUrl: null,
    role: 'user',
    status: 'active',
    orgIds: [],
    defaultVisibility: 'private',
    defaultVisibilityOrgId: null,
    ...overrides,
  };
}

function makeApp(repo: Repository, user?: Express.User): express.Application {
  const app = express();
  if (user) {
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
  }
  const deps: LocalTasksDeps = {
    repo,
    userFolderRoot: './data/users',
    noAuthOwner: undefined,
    opts: { repo },
  };
  registerLocalTaskStreamRoutes(app, deps);
  return app;
}

describe('GET /api/local/tasks/:taskId/stream — visibility gate', () => {
  it('returns 404 (no leak) when the viewer cannot see the task', async () => {
    // getLocalTask returns null for a non-viewer (visibility model applied inside repo).
    const repo = makeRepo({ getLocalTask: vi.fn().mockResolvedValue(null) });
    const app = makeApp(repo, makeUser({ id: 'intruder' }));
    const res = await request(app).get('/api/local/tasks/1/stream');
    expect(res.status).toBe(404);
    // No task fields are echoed — only a generic error.
    expect(res.body).toEqual({ error: 'task not found' });
    expect(res.text).not.toContain('user-1');
  });

  it('returns 400 for a malformed taskId', async () => {
    const app = makeApp(makeRepo(), makeUser());
    const res = await request(app).get('/api/local/tasks/not-a-number/stream');
    expect(res.status).toBe(400);
  });

  it('returns 204 when there is no running/dispatching job', async () => {
    const repo = makeRepo({
      getLocalTask: vi.fn().mockResolvedValue({
        id: 1, ownerId: 'user-1', visibility: 'private',
        latestJob: { id: 'job-1', status: 'succeeded' },
      }),
    });
    const app = makeApp(repo, makeUser());
    const res = await request(app).get('/api/local/tasks/1/stream');
    expect(res.status).toBe(204);
  });
});

describe('formatDelegateSseFrame', () => {
  it('lifecycle フレームに originJobId を含む', () => {
    const frame = formatDelegateSseFrame({
      type: 'delegate_lifecycle',
      delegateRunId: 'r1',
      depth: 1,
      description: 'x',
      status: 'running',
      originJobId: 'sub1',
    } as any);
    expect(frame).toMatchObject({ type: 'delegate_lifecycle', delegateRunId: 'r1', originJobId: 'sub1' });
  });

  it('originJobId 無し（親自身の run）は null', () => {
    const frame = formatDelegateSseFrame({
      type: 'delegate_lifecycle',
      delegateRunId: 'r1',
      status: 'running',
    } as any);
    expect(frame).toMatchObject({ originJobId: null });
  });
});

describe('GET /api/local/tasks/:taskId/stream — SSE event mapping', () => {
  // Streaming-with-supertest note: supertest buffers the whole response and only
  // resolves once the connection closes. The stream stays open until a `done`
  // event (which calls res.end()) or the client aborts. So each test schedules
  // events on the bus shortly after the request opens, ENDING with a `done`
  // event so the request terminates deterministically (no hanging connection).
  // The 15s heartbeat never fires within these sub-second tests.

  afterEach(() => {
    // Defensively drop any listeners a test left behind.
    jobEventBus.removeAllListeners(`job:${RUNNING_JOB.id}`);
  });

  it('opens text/event-stream and maps a tool-call delta into a data: frame', async () => {
    const app = makeApp(makeRepo(), makeUser());

    // Pump events once a listener is attached (the route subscribes during the
    // async handler). Poll for the listener, then emit a tool_use_delta + done.
    const pump = setInterval(() => {
      if (jobEventBus.hasListeners(RUNNING_JOB.id)) {
        clearInterval(pump);
        jobEventBus.emitJob(RUNNING_JOB.id, {
          type: 'tool_use_delta', callId: 'c1', name: 'Read', chunk: '{"file_path":"a.txt"}',
        });
        // The tool buffer flushes after 50ms; emit `done` a bit later so the
        // flush lands first, then the stream ends.
        setTimeout(() => {
          jobEventBus.emitJob(RUNNING_JOB.id, { type: 'done' });
        }, 120);
      }
    }, 5);

    const res = await request(app).get('/api/local/tasks/1/stream');
    clearInterval(pump);

    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toContain('text/event-stream');
    expect(res.headers['cache-control']).toContain('no-store');
    // The tool-call delta is reconstructed into an SSE data: frame.
    expect(res.text).toContain('data: ');
    expect(res.text).toContain('"type":"tool_use_delta"');
    expect(res.text).toContain('"name":"Read"');
    expect(res.text).toContain('"callId":"c1"');
    // The terminal done frame is present.
    expect(res.text).toContain('"type":"done"');
  });

  it('maps a text event into a text_delta data: frame', async () => {
    const app = makeApp(makeRepo(), makeUser());

    const pump = setInterval(() => {
      if (jobEventBus.hasListeners(RUNNING_JOB.id)) {
        clearInterval(pump);
        jobEventBus.emitJob(RUNNING_JOB.id, { type: 'text', text: 'hello world' });
        setTimeout(() => jobEventBus.emitJob(RUNNING_JOB.id, { type: 'done' }), 120);
      }
    }, 5);

    const res = await request(app).get('/api/local/tasks/1/stream');
    clearInterval(pump);

    expect(res.status).toBe(200);
    expect(res.text).toContain('"type":"text_delta"');
    expect(res.text).toContain('hello world');
    expect(res.text).toContain('"type":"done"');
  });

  it('cleans up its bus listener after the stream ends', async () => {
    const app = makeApp(makeRepo(), makeUser());
    const pump = setInterval(() => {
      if (jobEventBus.hasListeners(RUNNING_JOB.id)) {
        clearInterval(pump);
        jobEventBus.emitJob(RUNNING_JOB.id, { type: 'done' });
      }
    }, 5);
    await request(app).get('/api/local/tasks/1/stream');
    clearInterval(pump);
    // After `done` → res.end() → cleanup(), the listener is removed.
    expect(jobEventBus.hasListeners(RUNNING_JOB.id)).toBe(false);
  });
});
