import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
import type { Repository } from '../db/repository.js';

function makeRepo(overrides: Partial<Repository> = {}): Repository {
  return {
    getLocalTask: vi.fn(),
    getLatestJobForIssue: vi.fn(),
    getSubJobs: vi.fn(),
    getJob: vi.fn(),
    ...overrides,
  } as unknown as Repository;
}

const DUMMY_TASK = { id: 1, title: 'test task', workspacePath: '/tmp/workspace' };
const DUMMY_LATEST_JOB = { id: 'job-parent-1', issueNumber: 1 };

describe('Subtask Activity API', () => {
  let app: express.Application;
  let repo: Repository;
  let tmpDirs: string[] = [];

  function makeTmpDir(): string {
    const dir = mkdtempSync(join(tmpdir(), 'subtask-activity-test-'));
    tmpDirs.push(dir);
    return dir;
  }

  beforeEach(() => {
    tmpDirs = [];
    repo = makeRepo();
    app = express();
    app.use(express.json());
    app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
  });

  afterEach(() => {
    for (const dir of tmpDirs) {
      try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
    }
  });

  // -------------------------------------------------------------------
  // GET /:id/subtasks/activities
  // -------------------------------------------------------------------

  describe('GET /:id/subtasks/activities', () => {
    it('returns subtask list with currentMovement from DB', async () => {
      vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
      vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
      vi.mocked(repo.getSubJobs).mockResolvedValue([
        { id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: null },
        { id: 'sub-2', issueNumber: 3, status: 'succeeded', currentMovement: null, worktreePath: null },
      ] as never);

      const res = await request(app).get('/api/local/tasks/1/subtasks/activities');

      expect(res.status).toBe(200);
      expect(res.body.subtasks).toHaveLength(2);
      expect(res.body.subtasks[0].jobId).toBe('sub-1');
      expect(res.body.subtasks[0].currentMovement).toBe('execute');
      expect(res.body.subtasks[1].currentMovement).toBeNull();
    });

    it('includes nested subtasks when parent is waiting_subtasks', async () => {
      vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
      vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
      vi.mocked(repo.getSubJobs)
        .mockResolvedValueOnce([
          { id: 'sub-1', issueNumber: 1, status: 'waiting_subtasks', currentMovement: null, worktreePath: null },
        ] as never)
        .mockResolvedValueOnce([
          { id: 'grand-1', issueNumber: 1, status: 'running', currentMovement: 'execute', worktreePath: null },
          { id: 'grand-2', issueNumber: 2, status: 'queued', currentMovement: null, worktreePath: null },
        ] as never);

      const res = await request(app).get('/api/local/tasks/1/subtasks/activities');

      expect(res.status).toBe(200);
      expect(res.body.subtasks).toHaveLength(3); // sub-1, grand-1, grand-2
      expect(res.body.subtasks.map((s: { jobId: string }) => s.jobId)).toEqual(['sub-1', 'grand-1', 'grand-2']);
    });

    it('returns 404 when task not found', async () => {
      vi.mocked(repo.getLocalTask).mockResolvedValue(null as never);

      const res = await request(app).get('/api/local/tasks/99/subtasks/activities');

      expect(res.status).toBe(404);
      expect(res.body.error).toBe('Task not found');
    });

    it('returns 404 when no job found', async () => {
      vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
      vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(null as never);

      const res = await request(app).get('/api/local/tasks/1/subtasks/activities');

      expect(res.status).toBe(404);
      expect(res.body.error).toBe('No job found');
    });

    it('returns empty activityLog when worktreePath is null', async () => {
      vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
      vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
      vi.mocked(repo.getSubJobs).mockResolvedValue([
        { id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: null },
      ] as never);

      const res = await request(app).get('/api/local/tasks/1/subtasks/activities');

      expect(res.status).toBe(200);
      expect(res.body.subtasks[0].activityLog).toBe('');
    });

    it('returns activity log content when file exists', async () => {
      const worktree = makeTmpDir();
      const logsDir = join(worktree, 'logs');
      mkdirSync(logsDir);
      writeFileSync(join(logsDir, 'activity.log'), 'step 1\nstep 2\n');

      vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
      vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
      vi.mocked(repo.getSubJobs).mockResolvedValue([
        { id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: worktree },
      ] as never);

      const res = await request(app).get('/api/local/tasks/1/subtasks/activities');

      expect(res.status).toBe(200);
      expect(res.body.subtasks[0].activityLog).toBe('step 1\nstep 2\n');
    });

    it('truncates activity log to 4000 chars in bulk API', async () => {
      const worktree = makeTmpDir();
      const logsDir = join(worktree, 'logs');
      mkdirSync(logsDir);
      const longContent = 'x'.repeat(5000);
      writeFileSync(join(logsDir, 'activity.log'), longContent);

      vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
      vi.mocked(repo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
      vi.mocked(repo.getSubJobs).mockResolvedValue([
        { id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: null, worktreePath: worktree },
      ] as never);

      const res = await request(app).get('/api/local/tasks/1/subtasks/activities');

      expect(res.status).toBe(200);
      expect(res.body.subtasks[0].activityLog).toHaveLength(4000);
      // Should be the last 4000 chars
      expect(res.body.subtasks[0].activityLog).toBe('x'.repeat(4000));
    });
  });

  // -------------------------------------------------------------------
  // GET /:id/subtasks/:jobId/activity
  // -------------------------------------------------------------------

  describe('GET /:id/subtasks/:jobId/activity', () => {
    it('returns individual activity log via getJob', async () => {
      const worktree = makeTmpDir();
      const logsDir = join(worktree, 'logs');
      mkdirSync(logsDir);
      writeFileSync(join(logsDir, 'activity.log'), 'individual log content');

      vi.mocked(repo.getLocalTask).mockResolvedValue({ ...DUMMY_TASK, workspacePath: worktree } as never);
      vi.mocked(repo.getJob).mockResolvedValue(
        { id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: 'execute', worktreePath: worktree } as never,
      );

      const res = await request(app).get('/api/local/tasks/1/subtasks/sub-1/activity');

      expect(res.status).toBe(200);
      expect(res.body.activityLog).toBe('individual log content');
    });

    it('returns full (non-truncated) activity log for individual endpoint', async () => {
      const worktree = makeTmpDir();
      const logsDir = join(worktree, 'logs');
      mkdirSync(logsDir);
      const longContent = 'y'.repeat(5000);
      writeFileSync(join(logsDir, 'activity.log'), longContent);

      vi.mocked(repo.getLocalTask).mockResolvedValue({ ...DUMMY_TASK, workspacePath: worktree } as never);
      vi.mocked(repo.getJob).mockResolvedValue(
        { id: 'sub-1', issueNumber: 2, status: 'running', currentMovement: null, worktreePath: worktree } as never,
      );

      const res = await request(app).get('/api/local/tasks/1/subtasks/sub-1/activity');

      expect(res.status).toBe(200);
      // Individual endpoint does NOT truncate (maxChars = 0)
      expect(res.body.activityLog).toHaveLength(5000);
    });

    it('returns 404 when task not found', async () => {
      vi.mocked(repo.getLocalTask).mockResolvedValue(null as never);

      const res = await request(app).get('/api/local/tasks/99/subtasks/sub-1/activity');

      expect(res.status).toBe(404);
      expect(res.body.error).toBe('Task not found');
    });

    it('returns 404 when subtask not found', async () => {
      vi.mocked(repo.getLocalTask).mockResolvedValue(DUMMY_TASK as never);
      vi.mocked(repo.getJob).mockResolvedValue(null as never);

      const res = await request(app).get('/api/local/tasks/1/subtasks/nonexistent/activity');

      expect(res.status).toBe(404);
      expect(res.body.error).toBe('Subtask not found');
    });
  });

  // -------------------------------------------------------------------
  // Visibility gate regression (bulk activities)
  // -------------------------------------------------------------------

  describe('GET /:id/subtasks/activities visibility gate', () => {
    // When getLocalTask returns null (viewer does not have access),
    // canViewTask rejects with 404. This simulates B3: a non-owner user
    // asking for another user's private task's bulk subtask activities.
    it('returns 404 when viewer cannot see the parent task', async () => {
      const privateRepo = makeRepo();
      const privateApp = express();
      privateApp.use(express.json());
      privateApp.use((req, _res, next) => {
        (req as unknown as { user: Express.User }).user = {
          id: 'bob-id', email: 'b@x.com', name: 'b', avatarUrl: null,
          role: 'user', status: 'active', orgIds: [],
          defaultVisibility: 'private', defaultVisibilityOrgId: null,
        };
        next();
      });
      privateApp.use('/api/local/tasks', createSubtaskActivityRouter(privateRepo));
      // getLocalTask returns null because the viewer filter matches no rows
      vi.mocked(privateRepo.getLocalTask).mockResolvedValue(null as never);

      const res = await request(privateApp).get('/api/local/tasks/1/subtasks/activities');
      expect(res.status).toBe(404);
      expect(res.body.error).toBe('Task not found');
      // Should never reach getLatestJobForIssue
      expect(privateRepo.getLatestJobForIssue).not.toHaveBeenCalled();
    });

    it('allows access when visibility=public even for non-owner', async () => {
      const pubRepo = makeRepo();
      const pubApp = express();
      pubApp.use(express.json());
      pubApp.use((req, _res, next) => {
        (req as unknown as { user: Express.User }).user = {
          id: 'bob-id', email: 'b@x.com', name: 'b', avatarUrl: null,
          role: 'user', status: 'active', orgIds: [],
          defaultVisibility: 'private', defaultVisibilityOrgId: null,
        };
        next();
      });
      pubApp.use('/api/local/tasks', createSubtaskActivityRouter(pubRepo));
      vi.mocked(pubRepo.getLocalTask).mockResolvedValue({
        id: 1, title: 'pub', workspacePath: '/tmp/w',
        ownerId: 'alice-id', visibility: 'public', visibilityScopeOrgId: null,
      } as never);
      vi.mocked(pubRepo.getLatestJobForIssue).mockResolvedValue(DUMMY_LATEST_JOB as never);
      vi.mocked(pubRepo.getSubJobs).mockResolvedValue([] as never);

      const res = await request(pubApp).get('/api/local/tasks/1/subtasks/activities');
      expect(res.status).toBe(200);
    });
  });
});
