import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { Scheduler } from '../scheduler.js';
import { mountScheduledTasksApi } from './scheduled-tasks-api.js';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

let app: express.Application;
let repo: Repository;
let scheduler: Scheduler;
let tempDir: string;

beforeAll(() => {
  tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-api-'));
  repo = new Repository(join(tempDir, 'test.db'));
  scheduler = new Scheduler(repo, join(tempDir, 'workspaces'));
  app = express();
  app.use(express.json());
  mountScheduledTasksApi(app, repo, scheduler);
});

afterAll(() => {
  repo.close();
  try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
});

describe('POST /api/scheduled-tasks with visibility', () => {
  let vTempDir = '';
  let vRepo: Repository;
  let vApp: express.Application;
  let aliceUser: Express.User;

  beforeEach(() => {
    vTempDir = mkdtempSync(join(tmpdir(), 'sched-vis-api-'));
    vRepo = new Repository(join(vTempDir, 'db.sqlite'));
    const real = vRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    aliceUser = {
      ...real,
      orgIds: ['10'],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const vScheduler = new Scheduler(vRepo, join(vTempDir, 'workspaces'));
    vApp = express();
    vApp.use(express.json());
    vApp.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = aliceUser;
      next();
    });
    mountScheduledTasksApi(vApp, vRepo, vScheduler);
  });

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

  it('creates scheduled task with owner_id set from req.user and visibility=org', async () => {
    const res = await request(vApp).post('/api/scheduled-tasks').send({
      body: 'hello',
      scheduleType: 'daily',
      hour: 9,
      minute: 0,
      visibility: 'org',
      visibilityScopeOrgId: '10',
    });
    expect(res.status).toBe(201);
    expect(res.body.task.visibility).toBe('org');
    expect(res.body.task.visibilityScopeOrgId).toBe('10');
    expect(res.body.task.ownerId).toBe(aliceUser.id);
  });

  it('defaults visibility to private and owner from req.user when not provided', async () => {
    const res = await request(vApp).post('/api/scheduled-tasks').send({
      body: 'hello',
      scheduleType: 'daily',
      hour: 10,
    });
    expect(res.status).toBe(201);
    expect(res.body.task.visibility).toBe('private');
    expect(res.body.task.visibilityScopeOrgId).toBeNull();
    expect(res.body.task.ownerId).toBe(aliceUser.id);
  });

  it('rejects visibility=org with org not in user orgs', async () => {
    const res = await request(vApp).post('/api/scheduled-tasks').send({
      body: 'hello',
      scheduleType: 'daily',
      hour: 9,
      visibility: 'org',
      visibilityScopeOrgId: '99',
    });
    expect(res.status).toBe(400);
  });

  it('rejects invalid visibility enum values', async () => {
    const res = await request(vApp).post('/api/scheduled-tasks').send({
      body: 'hello',
      scheduleType: 'daily',
      hour: 9,
      visibility: 'bogus',
    });
    expect(res.status).toBe(400);
  });
});

describe('POST /api/scheduled-tasks with spaceId', () => {
  let sTempDir = '';
  let sRepo: Repository;
  let sApp: express.Application;
  let user: Express.User;
  let mySpaceId: string;
  let otherSpaceId: string;
  let publicSpaceId: string;

  beforeEach(async () => {
    sTempDir = mkdtempSync(join(tmpdir(), 'sched-space-api-'));
    sRepo = new Repository(join(sTempDir, 'db.sqlite'));
    const real = sRepo.createUser({ email: 'me@x.com', name: 'me', role: 'user', status: 'active' });
    user = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
    // A case space I own (visible to me) + a private case space owned by someone else.
    mySpaceId = (await sRepo.createSpace({ kind: 'case', title: 'Mine', ownerId: real.id, visibility: 'private' })).id;
    const stranger = sRepo.createUser({ email: 'x@x.com', name: 'x', role: 'user', status: 'active' });
    otherSpaceId = (await sRepo.createSpace({ kind: 'case', title: 'Theirs', ownerId: stranger.id, visibility: 'private' })).id;
    // A PUBLIC space owned by someone else: visible to `user` but not editable.
    publicSpaceId = (await sRepo.createSpace({ kind: 'case', title: 'Public', ownerId: stranger.id, visibility: 'public' })).id;
    const sScheduler = new Scheduler(sRepo, join(sTempDir, 'workspaces'));
    sApp = express();
    sApp.use(express.json());
    sApp.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = user; next(); });
    mountScheduledTasksApi(sApp, sRepo, sScheduler);
  });

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

  it('stores spaceId and forces visibility=private when created in a visible space', async () => {
    const res = await request(sApp).post('/api/scheduled-tasks').send({
      body: 'space job', scheduleType: 'daily', hour: 9, spaceId: mySpaceId, visibility: 'public',
    });
    expect(res.status).toBe(201);
    expect(res.body.task.spaceId).toBe(mySpaceId);
    expect(res.body.task.visibility).toBe('private'); // forced private inside a space
  });

  it('falls back to NULL space (personal) when spaceId is not visible to the user', async () => {
    const res = await request(sApp).post('/api/scheduled-tasks').send({
      body: 'sneaky', scheduleType: 'daily', hour: 9, spaceId: otherSpaceId,
    });
    expect(res.status).toBe(201);
    expect(res.body.task.spaceId).toBeNull(); // not visible → dropped, not leaked into their space
  });

  it('falls back to NULL when the user can SEE but not EDIT the space (read-only viewer)', async () => {
    // Regression: a space schedule writes to the shared files tree each run, so a
    // read-only viewer of a public space must not be able to bind one (that would
    // be an automated write channel bypassing canEditInSpace).
    const res = await request(sApp).post('/api/scheduled-tasks').send({
      body: 'viewer write attempt', scheduleType: 'daily', hour: 9, spaceId: publicSpaceId,
    });
    expect(res.status).toBe(201);
    expect(res.body.task.spaceId).toBeNull();
  });

  it('leaves spaceId NULL when none is supplied (global create)', async () => {
    const res = await request(sApp).post('/api/scheduled-tasks').send({
      body: 'global', scheduleType: 'daily', hour: 9,
    });
    expect(res.status).toBe(201);
    expect(res.body.task.spaceId).toBeNull();
  });

  it('lets a space editor (not the creator) pause/delete a space schedule — survives creator leaving', async () => {
    // Creator (the space owner) makes a space schedule.
    const created = (await request(sApp).post('/api/scheduled-tasks').send({
      body: 'space job', scheduleType: 'daily', hour: 9, spaceId: mySpaceId,
    })).body.task;

    // A different user who is an EDITOR member of the space.
    const editor = sRepo.createUser({ email: 'ed@x.com', name: 'ed', role: 'user', status: 'active' });
    await sRepo.addSpaceMember({ spaceId: mySpaceId, userId: editor.id, role: 'editor' });
    const editorUser: Express.User = { ...editor, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
    const edApp = express();
    edApp.use(express.json());
    edApp.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = editorUser; next(); });
    mountScheduledTasksApi(edApp, sRepo, new Scheduler(sRepo, join(sTempDir, 'workspaces')));

    // The editor can pause it (manage), even though they didn't create it.
    const patch = await request(edApp).patch(`/api/scheduled-tasks/${created.id}`).send({ isActive: false });
    expect(patch.status).toBe(200);
    expect(patch.body.task.isActive).toBe(false);
    // ...and delete it.
    const del = await request(edApp).delete(`/api/scheduled-tasks/${created.id}`);
    expect(del.status).toBe(200);
  });

  it('a creator who LOST space edit rights can no longer trigger/edit the space schedule', async () => {
    // An editor member creates a space schedule.
    const editor = sRepo.createUser({ email: 'wased@x.com', name: 'wasEd', role: 'user', status: 'active' });
    await sRepo.addSpaceMember({ spaceId: mySpaceId, userId: editor.id, role: 'editor' });
    const edUser: Express.User = { ...editor, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
    const mkApp = (u: Express.User) => {
      const a = express(); a.use(express.json());
      a.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = u; next(); });
      mountScheduledTasksApi(a, sRepo, new Scheduler(sRepo, join(sTempDir, 'workspaces')));
      return a;
    };
    const created = (await request(mkApp(edUser)).post('/api/scheduled-tasks').send({
      body: 'editor job', scheduleType: 'daily', hour: 9, spaceId: mySpaceId,
    })).body.task;
    expect(created.spaceId).toBe(mySpaceId);

    // The editor is demoted to viewer (loses edit rights).
    await sRepo.addSpaceMember({ spaceId: mySpaceId, userId: editor.id, role: 'viewer' });

    // Even though they are still the schedule's ownerId, they can no longer
    // trigger (self-write to the shared files tree) or edit it.
    const trig = await request(mkApp(edUser)).post(`/api/scheduled-tasks/${created.id}/trigger`);
    expect(trig.status).toBe(404);
    const patch = await request(mkApp(edUser)).patch(`/api/scheduled-tasks/${created.id}`).send({ isActive: false });
    expect(patch.status).toBe(404);
    // The space owner can still manage it (it's a space asset).
    const ownerPatch = await request(sApp).patch(`/api/scheduled-tasks/${created.id}`).send({ isActive: false });
    expect(ownerPatch.status).toBe(200);
  });

  it('does NOT let a non-member manage a space schedule', async () => {
    const created = (await request(sApp).post('/api/scheduled-tasks').send({
      body: 'space job', scheduleType: 'daily', hour: 9, spaceId: mySpaceId,
    })).body.task;
    const outsider = sRepo.createUser({ email: 'out@x.com', name: 'out', role: 'user', status: 'active' });
    const outUser: Express.User = { ...outsider, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
    const outApp = express();
    outApp.use(express.json());
    outApp.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = outUser; next(); });
    mountScheduledTasksApi(outApp, sRepo, new Scheduler(sRepo, join(sTempDir, 'workspaces')));
    const patch = await request(outApp).patch(`/api/scheduled-tasks/${created.id}`).send({ isActive: false });
    expect(patch.status).toBe(404); // not visible / not manageable
  });

  it('PATCH keeps a space-bound schedule private (cannot be opened to public/org)', async () => {
    const created = (await request(sApp).post('/api/scheduled-tasks').send({
      body: 'space job', scheduleType: 'daily', hour: 9, spaceId: mySpaceId,
    })).body.task;
    expect(created.spaceId).toBe(mySpaceId);
    // Owner tries to widen visibility via edit — must stay private (space is the boundary).
    const res = await request(sApp).patch(`/api/scheduled-tasks/${created.id}`).send({ visibility: 'public' });
    expect(res.status).toBe(200);
    const after = (await request(sApp).get(`/api/scheduled-tasks/${created.id}`)).body.task;
    expect(after.visibility).toBe('private');
    expect(after.spaceId).toBe(mySpaceId);
  });

  it('GET /api/scheduled-tasks?spaceId=<id> returns only that space\'s schedules', async () => {
    await request(sApp).post('/api/scheduled-tasks').send({ body: 'in-space', scheduleType: 'daily', hour: 9, spaceId: mySpaceId });
    await request(sApp).post('/api/scheduled-tasks').send({ body: 'global', scheduleType: 'daily', hour: 9 });
    const scoped = await request(sApp).get(`/api/scheduled-tasks?spaceId=${mySpaceId}`);
    expect(scoped.status).toBe(200);
    expect(scoped.body.tasks).toHaveLength(1);
    expect(scoped.body.tasks[0].spaceId).toBe(mySpaceId);
    // Unscoped list still returns both.
    const all = await request(sApp).get('/api/scheduled-tasks');
    expect(all.body.tasks.length).toBe(2);
  });
});

describe('POST /api/scheduled-tasks in no-auth mode (synthetic local owner)', () => {
  let nTempDir = '';
  let nRepo: Repository;
  let nApp: express.Application;

  beforeEach(() => {
    nTempDir = mkdtempSync(join(tmpdir(), 'sched-noauth-api-'));
    nRepo = new Repository(join(nTempDir, 'db.sqlite'));
    const nScheduler = new Scheduler(nRepo, join(nTempDir, 'workspaces'));
    nApp = express();
    nApp.use(express.json());
    // No user middleware: no-auth deployment (req.user undefined). authActive:false
    // tells the router to register the scheduled task under the 'local' owner so
    // the jobs the scheduler later spawns inherit it (not NULL).
    mountScheduledTasksApi(nApp, nRepo, nScheduler, { authActive: false });
  });

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

  it('registers the scheduled task under owner "local" instead of NULL', async () => {
    const res = await request(nApp).post('/api/scheduled-tasks').send({
      body: 'nightly thing',
      scheduleType: 'daily',
      hour: 9,
      minute: 0,
    });
    expect(res.status).toBe(201);
    expect(res.body.task.ownerId).toBe('local');
  });
});

describe('POST /api/scheduled-tasks', () => {
  it('should create a daily schedule', async () => {
    const res = await request(app)
      .post('/api/scheduled-tasks')
      .send({
        title: 'テスト日次',
        body: 'テストプロンプト',
        scheduleType: 'daily',
        hour: 9,
        minute: 0,
      });
    expect(res.status).toBe(201);
    expect(res.body.task.cronExpression).toBe('0 9 * * *');
    expect(res.body.task.isActive).toBe(true);
  });

  it('should require body', async () => {
    const res = await request(app).post('/api/scheduled-tasks').send({ scheduleType: 'daily' });
    expect(res.status).toBe(400);
  });
});

describe('GET /api/scheduled-tasks', () => {
  it('should list all scheduled tasks', async () => {
    const res = await request(app).get('/api/scheduled-tasks');
    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.tasks)).toBe(true);
  });
});

describe('PATCH /api/scheduled-tasks/:id', () => {
  it('should pause and resume', async () => {
    const createRes = await request(app)
      .post('/api/scheduled-tasks')
      .send({ title: 'pause-test', body: 'test', scheduleType: 'daily', hour: 10 });
    const id = createRes.body.task.id;

    const pauseRes = await request(app).patch(`/api/scheduled-tasks/${id}`).send({ isActive: false });
    expect(pauseRes.body.task.isActive).toBe(false);

    const resumeRes = await request(app).patch(`/api/scheduled-tasks/${id}`).send({ isActive: true });
    expect(resumeRes.body.task.isActive).toBe(true);
  });
});

describe('DELETE /api/scheduled-tasks/:id', () => {
  it('should delete a scheduled task', async () => {
    const createRes = await request(app)
      .post('/api/scheduled-tasks')
      .send({ title: 'delete-test', body: 'test', scheduleType: 'daily', hour: 10 });
    const id = createRes.body.task.id;

    const delRes = await request(app).delete(`/api/scheduled-tasks/${id}`);
    expect(delRes.status).toBe(200);

    const getRes = await request(app).get(`/api/scheduled-tasks/${id}`);
    expect(getRes.status).toBe(404);
  });
});

describe('PATCH/DELETE /api/scheduled-tasks/:id owner-or-admin', () => {
  let pTempDir = '';
  let pRepo: Repository;

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

  function buildAppForUser(user: Express.User): express.Application {
    const pScheduler = new Scheduler(pRepo, join(pTempDir, 'workspaces'));
    const pApp = express();
    pApp.use(express.json());
    pApp.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
    mountScheduledTasksApi(pApp, pRepo, pScheduler);
    return pApp;
  }

  function seedTask(ownerId: string, visibility: 'private' | 'org' | 'public' = 'public') {
    return pRepo.createScheduledTask({
      title: 't',
      body: 'b',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId,
      visibility,
    });
  }

  it('non-owner non-admin gets 404 on PATCH (even when visibility=public)', async () => {
    pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
    pRepo = new Repository(join(pTempDir, 'db.sqlite'));

    const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await seedTask(alice.id, 'public');

    const bobUser: Express.User = {
      id: 'bob-id',
      email: 'b@x.com',
      name: 'b',
      avatarUrl: null,
      role: 'user',
      status: 'active',
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const pApp = buildAppForUser(bobUser);

    const res = await request(pApp)
      .patch(`/api/scheduled-tasks/${task.id}`)
      .send({ title: 'edited' });
    expect(res.status).toBe(404);

    // Task title not changed
    const after = await pRepo.getScheduledTask(task.id);
    expect(after?.title).toBe('t');
  });

  it('non-owner non-admin gets 404 on DELETE', async () => {
    pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
    pRepo = new Repository(join(pTempDir, 'db.sqlite'));

    const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await seedTask(alice.id, 'public');

    const bobUser: Express.User = {
      id: 'bob-id',
      email: 'b@x.com',
      name: 'b',
      avatarUrl: null,
      role: 'user',
      status: 'active',
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const pApp = buildAppForUser(bobUser);

    const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
    expect(res.status).toBe(404);

    const after = await pRepo.getScheduledTask(task.id);
    expect(after).not.toBeNull();
  });

  it('admin can PATCH any scheduled task', async () => {
    pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
    pRepo = new Repository(join(pTempDir, 'db.sqlite'));

    const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await seedTask(alice.id, 'private');

    const adminUser: Express.User = {
      id: 'admin-id',
      email: 'admin@x.com',
      name: 'admin',
      avatarUrl: null,
      role: 'admin',
      status: 'active',
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const pApp = buildAppForUser(adminUser);

    const res = await request(pApp)
      .patch(`/api/scheduled-tasks/${task.id}`)
      .send({ title: 'edited-by-admin' });
    expect(res.status).toBe(200);
    expect(res.body.task.title).toBe('edited-by-admin');
  });

  it('admin can DELETE any scheduled task', async () => {
    pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
    pRepo = new Repository(join(pTempDir, 'db.sqlite'));

    const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const task = await seedTask(alice.id, 'private');

    const adminUser: Express.User = {
      id: 'admin-id',
      email: 'admin@x.com',
      name: 'admin',
      avatarUrl: null,
      role: 'admin',
      status: 'active',
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const pApp = buildAppForUser(adminUser);

    const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
    expect(res.status).toBe(200);
    expect(res.body.ok).toBe(true);

    const after = await pRepo.getScheduledTask(task.id);
    expect(after).toBeNull();
  });

  it('owner can PATCH own scheduled task', async () => {
    pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
    pRepo = new Repository(join(pTempDir, 'db.sqlite'));

    const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice,
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const task = await seedTask(alice.id, 'private');

    const pApp = buildAppForUser(aliceUser);
    const res = await request(pApp)
      .patch(`/api/scheduled-tasks/${task.id}`)
      .send({ title: 'edited-by-owner' });
    expect(res.status).toBe(200);
    expect(res.body.task.title).toBe('edited-by-owner');
  });

  it('owner can DELETE own scheduled task', async () => {
    pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
    pRepo = new Repository(join(pTempDir, 'db.sqlite'));

    const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice,
      orgIds: [],
      defaultVisibility: 'private',
      defaultVisibilityOrgId: null,
    };
    const task = await seedTask(alice.id, 'private');

    const pApp = buildAppForUser(aliceUser);
    const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
    expect(res.status).toBe(200);
    expect(res.body.ok).toBe(true);
  });
});

describe('GET /api/scheduled-tasks visibility filter', () => {
  let lTempDir = '';
  let lRepo: Repository;

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

  function buildAppForUser(user: Express.User): express.Application {
    const lScheduler = new Scheduler(lRepo, join(lTempDir, 'workspaces'));
    const lApp = express();
    lApp.use(express.json());
    lApp.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
    mountScheduledTasksApi(lApp, lRepo, lScheduler);
    return lApp;
  }

  it('non-owner does not see private scheduled tasks in list', async () => {
    lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
    lRepo = new Repository(join(lTempDir, 'db.sqlite'));
    const alice = lRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    await lRepo.createScheduledTask({
      title: 'alice-private', body: 'b',
      cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
      ownerId: alice.id, visibility: 'private',
    });

    const bobUser: Express.User = {
      id: 'bob-id', email: 'b@x.com', name: 'b', avatarUrl: null,
      role: 'user', status: 'active', orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
    const res = await request(buildAppForUser(bobUser)).get('/api/scheduled-tasks');
    expect(res.status).toBe(200);
    expect(res.body.tasks.map((t: { title: string }) => t.title)).not.toContain('alice-private');
  });

  it('owner sees own private scheduled tasks', async () => {
    lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
    lRepo = new Repository(join(lTempDir, 'db.sqlite'));
    const alice = lRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    const aliceUser: Express.User = {
      ...alice, orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
    await lRepo.createScheduledTask({
      title: 'alice-private', body: 'b',
      cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
      ownerId: alice.id, visibility: 'private',
    });

    const res = await request(buildAppForUser(aliceUser)).get('/api/scheduled-tasks');
    expect(res.status).toBe(200);
    expect(res.body.tasks.map((t: { title: string }) => t.title)).toContain('alice-private');
  });

  it('admin sees all scheduled tasks regardless of visibility', async () => {
    lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
    lRepo = new Repository(join(lTempDir, 'db.sqlite'));
    const alice = lRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    await lRepo.createScheduledTask({
      title: 'alice-private', body: 'b',
      cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
      ownerId: alice.id, visibility: 'private',
    });

    const adminUser: Express.User = {
      id: 'admin-id', email: 'admin@x.com', name: 'admin', avatarUrl: null,
      role: 'admin', status: 'active', orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
    const res = await request(buildAppForUser(adminUser)).get('/api/scheduled-tasks');
    expect(res.status).toBe(200);
    expect(res.body.tasks.map((t: { title: string }) => t.title)).toContain('alice-private');
  });
});

describe('POST /api/scheduled-tasks browserSessionProfileId owner check', () => {
  let bTempDir = '';
  let bRepo: Repository;
  let bSessRepo: BrowserSessionRepo;
  let alice: { id: string };
  let bob: { id: string };
  let aliceProfileId: number;
  let bobProfileId: number;

  beforeEach(() => {
    bTempDir = mkdtempSync(join(tmpdir(), 'sched-bsp-'));
    bRepo = new Repository(join(bTempDir, 'db.sqlite'));
    bSessRepo = new BrowserSessionRepo(bRepo.getDb());
    alice = bRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    bob = bRepo.createUser({ email: 'b@x.com', name: 'b', role: 'user', status: 'active' });
    aliceProfileId = bSessRepo.createProfile({
      ownerId: alice.id,
      label: 'alice-twitter',
      startUrl: 'https://twitter.com/home',
      matchPatterns: ['https://twitter.com/**'],
      storageOrigins: ['https://twitter.com'],
      loggedInSelector: null,
      loginUrlPatterns: [],
    });
    bobProfileId = bSessRepo.createProfile({
      ownerId: bob.id,
      label: 'bob-twitter',
      startUrl: 'https://twitter.com/home',
      matchPatterns: ['https://twitter.com/**'],
      storageOrigins: ['https://twitter.com'],
      loggedInSelector: null,
      loginUrlPatterns: [],
    });
  });

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

  function buildAppForUser(user: Express.User): express.Application {
    const bScheduler = new Scheduler(bRepo, join(bTempDir, 'workspaces'));
    const bApp = express();
    bApp.use(express.json());
    bApp.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
    mountScheduledTasksApi(bApp, bRepo, bScheduler, { sessRepo: bSessRepo });
    return bApp;
  }

  function asUser(u: { id: string }, email: string): Express.User {
    return {
      id: u.id, email, name: 'x', avatarUrl: null,
      role: 'user', status: 'active', orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
  }

  it('accepts a valid profile owned by the requesting user (201)', async () => {
    const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
      .post('/api/scheduled-tasks')
      .send({
        body: 'hello',
        scheduleType: 'daily',
        hour: 9,
        browserSessionProfileId: aliceProfileId,
      });
    expect(res.status).toBe(201);
    expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
  });

  it('rejects a profile owned by a different user (400)', async () => {
    const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
      .post('/api/scheduled-tasks')
      .send({
        body: 'hello',
        scheduleType: 'daily',
        hour: 9,
        browserSessionProfileId: bobProfileId,
      });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/not owned by you|not found/i);
  });

  it('rejects a positive integer that does not match any profile (400)', async () => {
    const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
      .post('/api/scheduled-tasks')
      .send({
        body: 'hello',
        scheduleType: 'daily',
        hour: 9,
        browserSessionProfileId: 999999,
      });
    expect(res.status).toBe(400);
  });
});

// Confused-deputy guard: a SPACE schedule may be managed by any space
// owner/admin/editor, but the scheduler runs the job as the schedule OWNER and
// reuses the stored browser profile. A non-owner manager who changes WHAT the
// schedule runs must not keep the owner's browser session attached, or their
// instructions would execute inside the owner's authenticated session.
describe('scheduled-tasks browserSessionProfileId confused-deputy guard', () => {
  let sTempDir = '';
  let sRepo: Repository;
  let sSessRepo: BrowserSessionRepo;
  let alice: { id: string }; // space owner + schedule owner
  let bob: { id: string };   // space editor (non-owner manager)
  let mySpaceId = '';
  let aliceProfileId: number; // owned by alice

  beforeEach(async () => {
    sTempDir = mkdtempSync(join(tmpdir(), 'sched-bsp-deputy-'));
    sRepo = new Repository(join(sTempDir, 'db.sqlite'));
    sSessRepo = new BrowserSessionRepo(sRepo.getDb());
    alice = sRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
    bob = sRepo.createUser({ email: 'b@x.com', name: 'b', role: 'user', status: 'active' });
    mySpaceId = (await sRepo.createSpace({ kind: 'case', title: 'Mine', ownerId: alice.id, visibility: 'private' })).id;
    await sRepo.addSpaceMember({ spaceId: mySpaceId, userId: bob.id, role: 'editor' });
    aliceProfileId = sSessRepo.createProfile({
      ownerId: alice.id, spaceId: null, label: 'alice-twitter',
      startUrl: 'https://twitter.com/home',
      matchPatterns: ['https://twitter.com/**'],
      storageOrigins: ['https://twitter.com'],
      loggedInSelector: null,
      loginUrlPatterns: [],
    });
  });

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

  function asUser(u: { id: string }, email: string): Express.User {
    return {
      id: u.id, email, name: 'x', avatarUrl: null,
      role: 'user', status: 'active', orgIds: [],
      defaultVisibility: 'private', defaultVisibilityOrgId: null,
    };
  }

  function buildAppForUser(user: Express.User): express.Application {
    const sScheduler = new Scheduler(sRepo, join(sTempDir, 'workspaces'));
    const sApp = express();
    sApp.use(express.json());
    sApp.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
    mountScheduledTasksApi(sApp, sRepo, sScheduler, { sessRepo: sSessRepo });
    return sApp;
  }

  async function createSpaceScheduleWithProfile(): Promise<number> {
    const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
      .post('/api/scheduled-tasks')
      .send({ body: 'orig', scheduleType: 'daily', hour: 9, spaceId: mySpaceId, browserSessionProfileId: aliceProfileId });
    expect(res.status).toBe(201);
    expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
    return res.body.task.id;
  }

  it('rejects a non-owner editor changing the body (403)', async () => {
    const id = await createSpaceScheduleWithProfile();
    const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
      .patch(`/api/scheduled-tasks/${id}`)
      .send({ body: 'malicious instructions' });
    expect(res.status).toBe(403);
    // Definition unchanged: body and profile both intact.
    const after = await sRepo.getScheduledTask(id);
    expect(after?.body).toBe('orig');
    expect(after?.browserSessionProfileId).toBe(aliceProfileId);
  });

  it('rejects a non-owner editor changing only the title (403)', async () => {
    // title is included in the runtime instruction, so it is content.
    const id = await createSpaceScheduleWithProfile();
    const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
      .patch(`/api/scheduled-tasks/${id}`)
      .send({ title: 'renamed' });
    expect(res.status).toBe(403);
  });

  it('rejects a non-owner editor changing script fields (403)', async () => {
    const id = await createSpaceScheduleWithProfile();
    const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
      .patch(`/api/scheduled-tasks/${id}`)
      .send({ taskKind: 'script', scriptName: 'evil', scriptParams: '{"x":1}' });
    expect(res.status).toBe(403);
  });

  it('rejects a non-owner editor attaching their own profile (403)', async () => {
    const id = await createSpaceScheduleWithProfile();
    const bobProfileId = sSessRepo.createProfile({
      ownerId: bob.id, spaceId: null, label: 'bob-twitter',
      startUrl: 'https://twitter.com/home',
      matchPatterns: ['https://twitter.com/**'],
      storageOrigins: ['https://twitter.com'],
      loggedInSelector: null,
      loginUrlPatterns: [],
    });
    const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
      .patch(`/api/scheduled-tasks/${id}`)
      .send({ browserSessionProfileId: bobProfileId });
    expect(res.status).toBe(403);
    const after = await sRepo.getScheduledTask(id);
    expect(after?.browserSessionProfileId).toBe(aliceProfileId); // unchanged
  });

  it('lets a non-owner manager pause a space schedule (lifecycle, 200)', async () => {
    const id = await createSpaceScheduleWithProfile();
    const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
      .patch(`/api/scheduled-tasks/${id}`)
      .send({ isActive: false });
    expect(res.status).toBe(200);
    expect(res.body.task.isActive).toBe(false);
    expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
  });

  it('lets a non-owner manager reschedule a space schedule (lifecycle, 200)', async () => {
    const id = await createSpaceScheduleWithProfile();
    const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
      .patch(`/api/scheduled-tasks/${id}`)
      .send({ scheduleType: 'daily', hour: 18 });
    expect(res.status).toBe(200);
    expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
  });

  it('lets a non-owner reschedule even when the UI resends the whole unchanged form (200)', async () => {
    // The editor PATCHes the full form; unchanged content fields must not 403.
    const id = await createSpaceScheduleWithProfile();
    const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
      .patch(`/api/scheduled-tasks/${id}`)
      .send({
        title: null, body: 'orig', piece: 'auto', profile: 'auto', outputFormat: 'markdown',
        taskKind: 'agent', scriptName: null, scriptParams: null,
        browserSessionProfileId: aliceProfileId, // unchanged
        scheduleType: 'daily', hour: 18, // the only real change
      });
    expect(res.status).toBe(200);
    expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
  });

  it('still rejects a non-owner whose full-form PATCH actually changes the body (403)', async () => {
    const id = await createSpaceScheduleWithProfile();
    const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
      .patch(`/api/scheduled-tasks/${id}`)
      .send({
        title: null, body: 'CHANGED', piece: 'auto', profile: 'auto', outputFormat: 'markdown',
        taskKind: 'agent', scriptName: null, scriptParams: null,
        browserSessionProfileId: aliceProfileId,
        scheduleType: 'daily', hour: 18,
      });
    expect(res.status).toBe(403);
    expect(res.body.error).toMatch(/body/);
  });

  it('lets the OWNER edit their own space schedule content (200)', async () => {
    const id = await createSpaceScheduleWithProfile();
    const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
      .patch(`/api/scheduled-tasks/${id}`)
      .send({ body: 'owner revises' });
    expect(res.status).toBe(200);
    expect(res.body.task.body).toBe('owner revises');
    expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
  });

  it('lets the no-auth single operator edit a space schedule body (200)', async () => {
    // No-auth (authActive:false, no req.user) is the sole principal — no confused
    // deputy is possible, so the content guard must not block content edits.
    const noAuthApp = (() => {
      const sScheduler = new Scheduler(sRepo, join(sTempDir, 'workspaces'));
      const a = express();
      a.use(express.json());
      mountScheduledTasksApi(a, sRepo, sScheduler, { sessRepo: sSessRepo, authActive: false });
      return a;
    })();
    const created = await request(noAuthApp)
      .post('/api/scheduled-tasks')
      .send({ body: 'orig', scheduleType: 'daily', hour: 9, spaceId: mySpaceId });
    expect(created.status).toBe(201);
    expect(created.body.task.spaceId).toBe(mySpaceId);
    const res = await request(noAuthApp)
      .patch(`/api/scheduled-tasks/${created.body.task.id}`)
      .send({ body: 'edited by operator' });
    expect(res.status).toBe(200);
    expect(res.body.task.body).toBe('edited by operator');
  });
});
