import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { convertToCron, calcNextRun, toSqliteDatetime, Scheduler } from './scheduler.js';
import { Repository } from './db/repository.js';

describe('convertToCron', () => {
  it('daily 09:00 → 0 9 * * *', () => {
    expect(convertToCron('daily', { hour: 9, minute: 0 })).toBe('0 9 * * *');
  });

  it('weekly 月曜 09:00 → 0 9 * * 1', () => {
    expect(convertToCron('weekly', { hour: 9, minute: 0, dayOfWeek: 1 })).toBe('0 9 * * 1');
  });

  it('monthly 15日 10:30 → 30 10 15 * *', () => {
    expect(convertToCron('monthly', { hour: 10, minute: 30, dayOfMonth: 15 })).toBe('30 10 15 * *');
  });

  it('cron はそのまま返す', () => {
    expect(convertToCron('cron', { cronExpression: '*/5 * * * *' })).toBe('*/5 * * * *');
  });

  it('once は "once" を返す', () => {
    expect(convertToCron('once', {})).toBe('once');
  });
});

describe('toSqliteDatetime', () => {
  it('ISO 8601 の T と Z を除去して SQLite 互換フォーマットにする', () => {
    const result = toSqliteDatetime(new Date('2026-03-25T09:00:00.000Z'));
    expect(result).toBe('2026-03-25 09:00:00');
  });

  it('T, ミリ秒, Z が含まれない', () => {
    const result = toSqliteDatetime(new Date());
    expect(result).not.toContain('T');
    expect(result).not.toContain('Z');
    expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
  });
});

describe('calcNextRun', () => {
  it('cron 式から次回実行時刻を算出する', () => {
    const next = calcNextRun('0 9 * * *');
    expect(next).toBeTruthy();
    expect(new Date(next!).getTime()).toBeGreaterThan(Date.now());
  });

  it('SQLite datetime 互換フォーマットを返す', () => {
    const next = calcNextRun('0 9 * * *');
    expect(next).toBeTruthy();
    // T や Z が含まれず、YYYY-MM-DD HH:MM:SS 形式
    expect(next).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
  });

  it('once の場合は null を返す', () => {
    expect(calcNextRun('once')).toBeNull();
  });
});

describe('Scheduler.executeScheduledTask: ownership inheritance', () => {
  // Regression: scheduler used to call createLocalTask() without passing
  // ownerId / visibility / visibilityScopeOrgId from the schedule, so the
  // spawned local_task ended up as { owner_id: NULL, visibility: 'private' }
  // and only admins could see it. Non-admin users couldn't find their own
  // schedule's results.
  let tempDir: string;
  let repo: Repository;
  let scheduler: Scheduler;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-exec-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    scheduler = new Scheduler(repo, join(tempDir, 'workspaces'));
  });

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

  it('propagates ownerId / visibility / visibilityScopeOrgId from schedule to spawned local_task', async () => {
    const owner = repo.createUser({ email: 'alice@x.com', name: 'alice', role: 'user', status: 'active' });
    const sched = await repo.createScheduledTask({
      body: 'do the thing',
      pieceName: 'auto',
      profile: 'auto',
      outputFormat: 'markdown',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId: owner.id,
      visibility: 'org',
      visibilityScopeOrgId: '42',
    });

    await scheduler.executeById(sched.id);

    const after = await repo.getScheduledTask(sched.id);
    expect(after?.lastJobId).toBeTruthy();

    const job = await repo.getJob(after!.lastJobId!);
    expect(job).not.toBeNull();
    const localTaskId = job!.issueNumber;
    const task = await repo.getLocalTask(localTaskId);

    expect(task?.ownerId).toBe(owner.id);
    expect(task?.visibility).toBe('org');
    expect(task?.visibilityScopeOrgId).toBe('42');
  });

  it('keeps ownerId NULL when schedule itself has no owner (system schedule)', async () => {
    const sched = await repo.createScheduledTask({
      body: 'system thing',
      pieceName: 'auto',
      profile: 'auto',
      outputFormat: 'markdown',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
    });

    await scheduler.executeById(sched.id);

    const after = await repo.getScheduledTask(sched.id);
    const job = await repo.getJob(after!.lastJobId!);
    const task = await repo.getLocalTask(job!.issueNumber);
    expect(task?.ownerId).toBeNull();
  });

  // Regression: scheduler used to forward pieceName='auto' as-is to
  // createLocalTask/createJob, but pieces/auto.yaml does not exist, so
  // worker always failed with `Piece not found: auto`. Fix injects a
  // selectPiece classifier (same as UI path) and resolves 'auto' before
  // creating the task/job.
  it('resolves pieceName="auto" via selectPiece before creating task/job', async () => {
    let classifierCalled = false;
    const selectPiece = async (body: string, _fileNames: string[]): Promise<string> => {
      classifierCalled = true;
      expect(body).toBe('research the market');
      return 'research';
    };
    const schedWithClassifier = new Scheduler(repo, join(tempDir, 'workspaces'), { selectPiece });

    const owner = repo.createUser({ email: 'carol@x.com', name: 'carol', role: 'user', status: 'active' });
    const sched = await repo.createScheduledTask({
      body: 'research the market',
      pieceName: 'auto',
      profile: 'auto',
      outputFormat: 'markdown',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId: owner.id,
    });

    await schedWithClassifier.executeById(sched.id);

    expect(classifierCalled).toBe(true);
    const after = await repo.getScheduledTask(sched.id);
    const job = await repo.getJob(after!.lastJobId!);
    const task = await repo.getLocalTask(job!.issueNumber);
    expect(job?.pieceName).toBe('research');
    expect(task?.pieceName).toBe('research');
  });

  it('falls back to "chat" when selectPiece is not configured for pieceName="auto"', async () => {
    const sched = await repo.createScheduledTask({
      body: 'no classifier here',
      pieceName: 'auto',
      profile: 'auto',
      outputFormat: 'markdown',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
    });

    await scheduler.executeById(sched.id);

    const after = await repo.getScheduledTask(sched.id);
    const job = await repo.getJob(after!.lastJobId!);
    const task = await repo.getLocalTask(job!.issueNumber);
    expect(job?.pieceName).toBe('chat');
    expect(task?.pieceName).toBe('chat');
  });

  it('preserves explicit pieceName (does not run classifier when piece is not "auto")', async () => {
    let classifierCalled = false;
    const selectPiece = async (): Promise<string> => {
      classifierCalled = true;
      return 'research';
    };
    const schedWithClassifier = new Scheduler(repo, join(tempDir, 'workspaces'), { selectPiece });

    const sched = await repo.createScheduledTask({
      body: 'use chat explicitly',
      pieceName: 'chat',
      profile: 'auto',
      outputFormat: 'markdown',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
    });

    await schedWithClassifier.executeById(sched.id);

    expect(classifierCalled).toBe(false);
    const after = await repo.getScheduledTask(sched.id);
    const job = await repo.getJob(after!.lastJobId!);
    expect(job?.pieceName).toBe('chat');
  });

  it('falls back to "chat" when selectPiece throws', async () => {
    const selectPiece = async (): Promise<string> => {
      throw new Error('classifier exploded');
    };
    const schedWithClassifier = new Scheduler(repo, join(tempDir, 'workspaces'), { selectPiece });

    const sched = await repo.createScheduledTask({
      body: 'classifier will explode',
      pieceName: 'auto',
      profile: 'auto',
      outputFormat: 'markdown',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
    });

    await schedWithClassifier.executeById(sched.id);

    const after = await repo.getScheduledTask(sched.id);
    const job = await repo.getJob(after!.lastJobId!);
    expect(job?.pieceName).toBe('chat');
  });

  // Regression: cron-fired path goes through tick() → getScheduledTasksDue()
  // which uses SELECT * (no JOIN), unlike executeById() which uses
  // getScheduledTask() (with LEFT JOIN users). Make sure ownership is
  // still propagated through this path. Reported symptom: scheduled task
  // owned by user, but cron-fired execution showing as "system".
  it('cron tick() propagates ownerId / visibility from schedule to spawned local_task', async () => {
    const owner = repo.createUser({ email: 'bob@x.com', name: 'bob', role: 'user', status: 'active' });
    const sched = await repo.createScheduledTask({
      body: 'do the thing via cron',
      pieceName: 'auto',
      profile: 'auto',
      outputFormat: 'markdown',
      cronExpression: '0 9 * * *',
      // Past time so getScheduledTasksDue returns it
      nextRunAt: '2000-01-01 09:00:00',
      ownerId: owner.id,
      visibility: 'private',
    });

    const executed = await scheduler.tick();
    expect(executed).toBe(1);

    const after = await repo.getScheduledTask(sched.id);
    expect(after?.lastJobId).toBeTruthy();

    const job = await repo.getJob(after!.lastJobId!);
    expect(job?.ownerId).toBe(owner.id);
    const task = await repo.getLocalTask(job!.issueNumber);
    expect(task?.ownerId).toBe(owner.id);
    expect(task?.visibility).toBe('private');
  });
});

describe('Scheduler.tick: skip-on-in-progress', () => {
  // Regression: a scheduled task's previous job ending in `waiting_human` (ASK)
  // used to be treated as "in progress" and block all future scheduled runs
  // forever — waiting_human has no auto-recovery, and an unattended schedule has
  // nobody to answer the ASK, so the schedule silently died. Now waiting_human is
  // excluded from the skip set: each scheduled run is an independent task, so we
  // leave the stale waiting_human task as-is and start a fresh run.
  let tempDir: string;
  let repo: Repository;
  let scheduler: Scheduler;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-skip-'));
    repo = new Repository(join(tempDir, 'db.sqlite'));
    scheduler = new Scheduler(repo, join(tempDir, 'workspaces'));
  });

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

  // Run a first tick to produce a job, force that job into `status`, then re-arm
  // the schedule (past next_run_at) and return its id + the first job id.
  async function armWithLastJobStatus(status: string): Promise<{ schedId: number; firstJobId: string }> {
    const sched = await repo.createScheduledTask({
      body: 'recurring thing',
      pieceName: 'chat',
      profile: 'auto',
      outputFormat: 'markdown',
      cronExpression: '0 9 * * *',
      nextRunAt: '2000-01-01 09:00:00',
    });
    const first = await scheduler.tick();
    expect(first).toBe(1);
    const after = await repo.getScheduledTask(sched.id);
    const firstJobId = after!.lastJobId!;
    await repo.updateJob(firstJobId, { status: status as any });
    // Re-arm: getScheduledTasksDue advanced next_run_at to the future.
    await repo.updateScheduledTask(sched.id, { nextRunAt: '2000-01-01 09:00:00' });
    return { schedId: sched.id, firstJobId };
  }

  it('starts a NEW run when the previous job is waiting_human (no longer blocks)', async () => {
    const { schedId, firstJobId } = await armWithLastJobStatus('waiting_human');

    const executed = await scheduler.tick();
    expect(executed).toBe(1);

    const after = await repo.getScheduledTask(schedId);
    expect(after?.lastJobId).toBeTruthy();
    expect(after?.lastJobId).not.toBe(firstJobId); // a fresh job was created
  });

  it('still skips when the previous job is running (genuinely in progress)', async () => {
    const { schedId, firstJobId } = await armWithLastJobStatus('running');

    const executed = await scheduler.tick();
    expect(executed).toBe(0);

    const after = await repo.getScheduledTask(schedId);
    expect(after?.lastJobId).toBe(firstJobId); // unchanged — skipped
  });

  it('still skips when the previous job is waiting_subtasks (auto-recovering transient)', async () => {
    const { schedId, firstJobId } = await armWithLastJobStatus('waiting_subtasks');

    const executed = await scheduler.tick();
    expect(executed).toBe(0);

    const after = await repo.getScheduledTask(schedId);
    expect(after?.lastJobId).toBe(firstJobId); // unchanged — skipped
  });
});

describe('Scheduler.executeScheduledTask: task_kind="script"', () => {
  // Scheduler runs a user-authored script directly (no LLM agent loop) when
  // task_kind='script'. The job row gets created in a pre-completed state.
  let tempDir: string;
  let userFolderRoot: string;
  let repo: Repository;
  let scheduler: Scheduler;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-script-'));
    userFolderRoot = join(tempDir, 'users');
    mkdirSync(userFolderRoot, { recursive: true });
    repo = new Repository(join(tempDir, 'db.sqlite'));
    scheduler = new Scheduler(repo, join(tempDir, 'workspaces'), {
      userFolderRoot,
      masterKeyPath: join(tempDir, 'master.key'),
      getUserScriptGate: () => ({ enabled: true }),
    });
  });

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

  function writeScript(userId: string, name: string, source: string): void {
    const dir = join(userFolderRoot, userId, 'browser-macros');
    mkdirSync(dir, { recursive: true });
    writeFileSync(join(dir, name), source, 'utf-8');
  }

  it('runs a browser-macro and marks the job succeeded with stdout saved to workspace', async () => {
    const owner = repo.createUser({ email: 'eve@x.com', name: 'eve', role: 'user', status: 'active' });
    writeScript(owner.id, 'hello.js', `---
params:
  - name: name
    type: string
---
async function main({ params }) {
  console.log('hello from script');
  return { greeting: 'hello', name: params.name };
}
module.exports = main;
`);

    const sched = await repo.createScheduledTask({
      body: '', // body is unused for script kind
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId: owner.id,
      visibility: 'private',
      taskKind: 'script',
      scriptName: 'hello',
      scriptParams: JSON.stringify({ name: 'world' }),
    });

    await scheduler.executeById(sched.id);

    const after = await repo.getScheduledTask(sched.id);
    expect(after?.lastJobId).toBeTruthy();
    const job = await repo.getJob(after!.lastJobId!);
    expect(job?.status).toBe('succeeded');

    const task = await repo.getLocalTask(job!.issueNumber);
    const workspacePath = task!.workspacePath!;
    expect(existsSync(join(workspacePath, 'output', 'script-output.txt'))).toBe(true);
    const output = readFileSync(join(workspacePath, 'output', 'script-output.txt'), 'utf-8');
    expect(output).toContain('hello');
    expect(output).toContain('world');
  });

  it('marks the job failed when the script throws and saves the error to logs/', async () => {
    const owner = repo.createUser({ email: 'mallory@x.com', name: 'mallory', role: 'user', status: 'active' });
    writeScript(owner.id, 'boom.js', `async function main() { throw new Error('intentional explosion'); }
module.exports = main;
`);

    const sched = await repo.createScheduledTask({
      body: '',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId: owner.id,
      taskKind: 'script',
      scriptName: 'boom',
    });

    await scheduler.executeById(sched.id);

    const after = await repo.getScheduledTask(sched.id);
    const job = await repo.getJob(after!.lastJobId!);
    expect(job?.status).toBe('failed');
    expect(job?.errorSummary ?? '').toContain('intentional explosion');

    const task = await repo.getLocalTask(job!.issueNumber);
    const errLog = readFileSync(join(task!.workspacePath!, 'logs', 'script-error.log'), 'utf-8');
    expect(errLog).toContain('intentional explosion');
  });

  it('marks the job failed when the script does not exist', async () => {
    const owner = repo.createUser({ email: 'oscar@x.com', name: 'oscar', role: 'user', status: 'active' });
    const sched = await repo.createScheduledTask({
      body: '',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId: owner.id,
      taskKind: 'script',
      scriptName: 'does-not-exist',
    });

    await scheduler.executeById(sched.id);

    const after = await repo.getScheduledTask(sched.id);
    const job = await repo.getJob(after!.lastJobId!);
    expect(job?.status).toBe('failed');
    expect(job?.errorSummary ?? '').toMatch(/not found/i);
  });

  it('throws when scriptName is null for task_kind=script (defensive guard)', async () => {
    const owner = repo.createUser({ email: 'peggy@x.com', name: 'peggy', role: 'user', status: 'active' });
    const sched = await repo.createScheduledTask({
      body: '',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId: owner.id,
      taskKind: 'script',
      // scriptName intentionally omitted
    });

    await expect(scheduler.executeById(sched.id)).rejects.toThrow(/script_name is null/);
  });

  it('runs as the "local" user when the schedule has no owner (no-auth mode)', async () => {
    // Regression: no-auth deployments store scheduled tasks with ownerId=null
    // (scheduled-tasks-api has no authenticated user). executeScriptScheduledTask
    // used to throw "requires an owner_id", so no-auth scheduled scripts could
    // never run. Now it falls back to the 'local' namespace (same as the
    // RunUserScript tool), resolving the macro from data/users/local/browser-macros/.
    writeScript('local', 'noauth.js', `async function main() {
  return { ran: 'no-auth-local' };
}
module.exports = main;
`);

    const sched = await repo.createScheduledTask({
      body: '',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      // ownerId intentionally omitted → null, as in no-auth mode
      taskKind: 'script',
      scriptName: 'noauth',
    });

    await scheduler.executeById(sched.id);

    const after = await repo.getScheduledTask(sched.id);
    expect(after?.lastJobId).toBeTruthy();
    const job = await repo.getJob(after!.lastJobId!);
    expect(job?.status).toBe('succeeded');

    const task = await repo.getLocalTask(job!.issueNumber);
    const output = readFileSync(join(task!.workspacePath!, 'output', 'script-output.txt'), 'utf-8');
    expect(output).toContain('no-auth-local'); // script's return value was saved

    // The audit row records the resolved 'local' owner, not null.
    const audits = repo.getDb()
      .prepare("SELECT detail FROM audit_log WHERE job_id = ? AND action = 'user_script_run'")
      .all(after!.lastJobId!) as Array<{ detail: string }>;
    expect(audits).toHaveLength(1);
    expect((JSON.parse(audits[0].detail) as { userId: string }).userId).toBe('local');
  });

  it('refuses to run when the user-script gate is disabled', async () => {
    const gatedScheduler = new Scheduler(repo, join(tempDir, 'workspaces'), {
      userFolderRoot,
      masterKeyPath: join(tempDir, 'master.key'),
      getUserScriptGate: () => ({ enabled: false }),
    });
    const owner = repo.createUser({ email: 'denied@x.com', name: 'denied', role: 'user', status: 'active' });
    writeScript(owner.id, 'noop.js', `async function main(){return 0;}\nmodule.exports=main;\n`);
    const sched = await repo.createScheduledTask({
      body: '',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId: owner.id,
      taskKind: 'script',
      scriptName: 'noop',
    });
    await expect(gatedScheduler.executeById(sched.id)).rejects.toThrow(/user_scripts_enabled=false/);
  });

  it('refuses to run when the owner is not in the per-user allowlist', async () => {
    const gatedScheduler = new Scheduler(repo, join(tempDir, 'workspaces'), {
      userFolderRoot,
      masterKeyPath: join(tempDir, 'master.key'),
      getUserScriptGate: () => ({ enabled: true, allowUserids: ['someone-else'] }),
    });
    const owner = repo.createUser({ email: 'outsider@x.com', name: 'outsider', role: 'user', status: 'active' });
    writeScript(owner.id, 'noop.js', `async function main(){return 0;}\nmodule.exports=main;\n`);
    const sched = await repo.createScheduledTask({
      body: '',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId: owner.id,
      taskKind: 'script',
      scriptName: 'noop',
    });
    await expect(gatedScheduler.executeById(sched.id)).rejects.toThrow(/not in tools.user_scripts_allow_userids/);
  });

  it('records a user_script_run audit row after a successful script execution', async () => {
    const owner = repo.createUser({ email: 'audit@x.com', name: 'audit', role: 'user', status: 'active' });
    writeScript(owner.id, 'tick.js', `async function main(){return 'ok';}\nmodule.exports=main;\n`);
    const sched = await repo.createScheduledTask({
      body: '',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId: owner.id,
      taskKind: 'script',
      scriptName: 'tick',
    });
    await scheduler.executeById(sched.id);

    const after = await repo.getScheduledTask(sched.id);
    const audits = repo.getDb()
      .prepare("SELECT action, actor, detail FROM audit_log WHERE job_id = ? ORDER BY id")
      .all(after!.lastJobId!) as Array<{ action: string; actor: string; detail: string }>;
    const userScriptRow = audits.find(a => a.action === 'user_script_run');
    expect(userScriptRow).toBeDefined();
    expect(userScriptRow!.actor).toBe('scheduler');
    const detail = JSON.parse(userScriptRow!.detail) as { ok: boolean; scriptName: string; userId: string };
    expect(detail.ok).toBe(true);
    expect(detail.scriptName).toBe('tick');
    expect(detail.userId).toBe(owner.id);
  });

  it('agent task_kind (existing default behaviour) still goes through the worker queue', async () => {
    const owner = repo.createUser({ email: 'trent@x.com', name: 'trent', role: 'user', status: 'active' });
    const sched = await repo.createScheduledTask({
      body: 'do agent-y stuff',
      pieceName: 'chat',
      cronExpression: '0 9 * * *',
      nextRunAt: '2099-01-01 09:00:00',
      ownerId: owner.id,
      // taskKind omitted → defaults to 'agent'
    });

    await scheduler.executeById(sched.id);
    const after = await repo.getScheduledTask(sched.id);
    const job = await repo.getJob(after!.lastJobId!);
    // Agent path leaves the job queued — worker picks it up later.
    expect(job?.status).toBe('queued');
    expect(after?.taskKind).toBe('agent');
  });
});
