import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { runMigrations } from '../db/migrate.js';
import { createBrowserApi } from './browser-api.js';
import {
  type SessionManager,
  type BrowserSession,
  CAPTCHA_POOL_SESSION_ID,
} from '../engine/browser-session.js';
import { unlinkSync } from 'fs';

// dev 環境では vendor/noVNC/vnc.html が存在しないため、
// 既存テストの期待 (available: true) を保つために isNovncStaticInstalled
// を true に固定する。novnc_not_installed 経路は専用テストで上書きする。
vi.mock('./novnc-proxy.js', async () => {
  const actual = await vi.importActual<typeof import('./novnc-proxy.js')>('./novnc-proxy.js');
  return {
    ...actual,
    isNovncStaticInstalled: vi.fn(() => true),
  };
});
const novncProxyMock = await import('./novnc-proxy.js');

/**
 * 2026-05 redesign: API は CAPTCHA Pool (admin only) と Task Session
 * (visibility ベース) を分離。テストダブルも kind / taskId / captchaPending
 * を扱える形にしている。
 */
class FakeSessionManager {
  private sessions = new Map<string, BrowserSession>();
  releaseCalls: string[] = [];
  destroyCalls: string[] = [];

  preload(partial: Partial<BrowserSession> & Pick<BrowserSession, 'id' | 'kind'>): BrowserSession {
    const now = new Date();
    const full: BrowserSession = {
      id: partial.id,
      kind: partial.kind,
      taskId: partial.taskId,
      userId: partial.userId,
      browser: undefined as never,
      context: undefined as never,
      vncPort: 5900,
      novncPort: 6900,
      userDataDir: '/tmp/test',
      state: partial.state ?? 'ready',
      xvfbProcess: undefined as never,
      x11vncProcess: undefined as never,
      websockifyProcess: undefined as never,
      display: ':99',
      createdAt: partial.createdAt ?? now,
      lastActiveAt: partial.lastActiveAt ?? now,
      lockedByJobId: partial.lockedByJobId ?? null,
      captchaPending: partial.captchaPending,
    };
    this.sessions.set(full.id, full);
    return full;
  }

  // --- SessionManager 互換 ---
  listSessions(): BrowserSession[] { return Array.from(this.sessions.values()); }
  getSession(id: string): BrowserSession | undefined { return this.sessions.get(id); }
  async destroySession(id: string): Promise<void> {
    this.destroyCalls.push(id);
    this.sessions.delete(id);
  }
  releaseToAgent(id: string): void { this.releaseCalls.push(id); }
}

function makeApp(
  sessionManager: FakeSessionManager,
  repo: Repository,
  user?: { id: string; role: 'admin' | 'user'; orgIds?: string[] },
): express.Application {
  const app = express();
  app.use(express.json());
  app.use((req, _res, next) => {
    if (user) {
      (req as unknown as { user: unknown }).user = {
        id: user.id,
        role: user.role,
        status: 'active',
        orgIds: user.orgIds ?? [],
        email: `${user.id}@example.com`,
        name: user.id,
        avatarUrl: null,
        defaultVisibility: 'private',
        defaultVisibilityOrgId: null,
      };
    }
    next();
  });
  app.use('/api/local/browser/sessions', createBrowserApi(sessionManager as unknown as SessionManager, repo));
  return app;
}

async function createTask(
  repo: Repository,
  ownerId: string | null,
  visibility: 'private' | 'org' | 'public' = 'private',
  visibilityScopeOrgId: string | null = null,
): Promise<number> {
  const task = await repo.createLocalTask({
    title: 't', body: 'b', pieceName: 'general',
    profile: 'auto', outputFormat: 'plain', askPolicy: 'allow',
    priority: 0, ownerId, visibility, visibilityScopeOrgId,
  });
  return task.id;
}

describe('Browser API', () => {
  let repo: Repository;
  const dbPath = './_test_browser_api.db';

  beforeEach(() => {
    repo = new Repository(dbPath);
    runMigrations(repo.getDb());
  });

  afterEach(() => {
    repo.close();
    try { unlinkSync(dbPath); } catch { /* ignore */ }
  });

  describe('GET /captcha-pool', () => {
    it('returns available:false when no pool exists', async () => {
      const sm = new FakeSessionManager();
      const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
      const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
      expect(res.status).toBe(200);
      expect(res.body).toEqual({ available: false });
    });

    it('returns pool info to admin', async () => {
      const sm = new FakeSessionManager();
      sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool', captchaPending: true });
      const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
      const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
      expect(res.status).toBe(200);
      expect(res.body.available).toBe(true);
      expect(res.body.captchaPending).toBe(true);
      expect(res.body.novncPath).toContain(CAPTCHA_POOL_SESSION_ID);
    });

    it('rejects non-admin', async () => {
      const sm = new FakeSessionManager();
      sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
      const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
      const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
      expect(res.status).toBe(403);
    });
  });

  describe('DELETE /captcha-pool', () => {
    it('admin can destroy the pool', async () => {
      const sm = new FakeSessionManager();
      sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
      const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
      const res = await request(app).delete('/api/local/browser/sessions/captcha-pool');
      expect(res.status).toBe(200);
      expect(sm.destroyCalls).toContain(CAPTCHA_POOL_SESSION_ID);
    });

    it('rejects non-admin', async () => {
      const sm = new FakeSessionManager();
      sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
      const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
      const res = await request(app).delete('/api/local/browser/sessions/captcha-pool');
      expect(res.status).toBe(403);
      expect(sm.destroyCalls).toEqual([]);
    });
  });

  describe('GET /task-session/:taskId', () => {
    it('returns available:false when no session for that task', async () => {
      const sm = new FakeSessionManager();
      const taskId = await createTask(repo, 'alice');
      const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
      const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
      expect(res.status).toBe(200);
      expect(res.body).toEqual({ available: false });
    });

    it('owner can see their own task session', async () => {
      const sm = new FakeSessionManager();
      const taskId = await createTask(repo, 'alice');
      sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
      const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
      const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
      expect(res.status).toBe(200);
      expect(res.body.available).toBe(true);
      expect(res.body.sessionId).toBe('sess-1');
    });

    it('non-owner is told available:false (no info leak)', async () => {
      const sm = new FakeSessionManager();
      const taskId = await createTask(repo, 'alice', 'private');
      sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
      const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
      const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
      expect(res.status).toBe(200);
      expect(res.body).toEqual({ available: false });
    });

    it('admin can see any task session', async () => {
      const sm = new FakeSessionManager();
      const taskId = await createTask(repo, 'alice', 'private');
      sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
      const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
      const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
      expect(res.status).toBe(200);
      expect(res.body.available).toBe(true);
    });

    it('public task session is visible to anyone', async () => {
      const sm = new FakeSessionManager();
      const taskId = await createTask(repo, 'alice', 'public');
      sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
      const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
      const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
      expect(res.status).toBe(200);
      expect(res.body.available).toBe(true);
    });

    it('returns reason:novnc_not_installed when vnc.html is missing', async () => {
      const spy = vi.mocked(novncProxyMock.isNovncStaticInstalled).mockReturnValueOnce(false);
      try {
        const sm = new FakeSessionManager();
        const taskId = await createTask(repo, 'alice');
        sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
        const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
        const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
        expect(res.status).toBe(200);
        expect(res.body).toEqual({ available: false, reason: 'novnc_not_installed' });
      } finally {
        spy.mockRestore();
      }
    });
  });

  describe('POST /task-session/:taskId/release', () => {
    it('owner can release their own task session', async () => {
      const sm = new FakeSessionManager();
      const taskId = await createTask(repo, 'alice');
      sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
      const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
      const res = await request(app).post(`/api/local/browser/sessions/task-session/${taskId}/release`);
      expect(res.status).toBe(200);
      expect(sm.destroyCalls).toEqual(['sess-1']);
    });

    it('non-owner is rejected', async () => {
      const sm = new FakeSessionManager();
      const taskId = await createTask(repo, 'alice');
      sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
      const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
      const res = await request(app).post(`/api/local/browser/sessions/task-session/${taskId}/release`);
      expect(res.status).toBe(403);
      expect(sm.destroyCalls).toEqual([]);
    });
  });

  describe('GET /', () => {
    it('lists only visible task sessions and excludes the pool', async () => {
      const sm = new FakeSessionManager();
      const aliceTaskId = await createTask(repo, 'alice', 'private');
      const bobTaskId = await createTask(repo, 'bob', 'private');
      sm.preload({ id: 'pool', kind: 'pool' }); // 除外されるはず
      sm.preload({ id: 'sess-alice', kind: 'task', taskId: String(aliceTaskId), userId: 'alice' });
      sm.preload({ id: 'sess-bob', kind: 'task', taskId: String(bobTaskId), userId: 'bob' });

      const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
      const res = await request(app).get('/api/local/browser/sessions/');
      expect(res.status).toBe(200);
      const ids = res.body.sessions.map((s: { id: string }) => s.id).sort();
      expect(ids).toEqual(['sess-alice']);
    });

    it('admin sees all task sessions but not the pool', async () => {
      const sm = new FakeSessionManager();
      const aliceTaskId = await createTask(repo, 'alice');
      const bobTaskId = await createTask(repo, 'bob');
      sm.preload({ id: 'pool', kind: 'pool' });
      sm.preload({ id: 'sess-alice', kind: 'task', taskId: String(aliceTaskId) });
      sm.preload({ id: 'sess-bob', kind: 'task', taskId: String(bobTaskId) });

      const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
      const res = await request(app).get('/api/local/browser/sessions/');
      expect(res.status).toBe(200);
      const ids = res.body.sessions.map((s: { id: string }) => s.id).sort();
      expect(ids).toEqual(['sess-alice', 'sess-bob']);
    });
  });

  describe('GET /:id', () => {
    it('returns 404 for the pool sessionId (must use /captcha-pool)', async () => {
      const sm = new FakeSessionManager();
      sm.preload({ id: CAPTCHA_POOL_SESSION_ID, kind: 'pool' });
      const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
      const res = await request(app).get(`/api/local/browser/sessions/${CAPTCHA_POOL_SESSION_ID}`);
      expect(res.status).toBe(404);
    });
  });

  describe('DELETE /:id', () => {
    it('non-owner cannot destroy', async () => {
      const sm = new FakeSessionManager();
      const taskId = await createTask(repo, 'alice');
      sm.preload({ id: 'sess-1', kind: 'task', taskId: String(taskId), userId: 'alice' });
      const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
      const res = await request(app).delete('/api/local/browser/sessions/sess-1');
      expect(res.status).toBe(404);
      expect(sm.destroyCalls).toEqual([]);
    });
  });
});
