import { CronExpressionParser } from 'cron-parser';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { Repository, type ScheduledTask } from './db/repository.js';
import type { BrowserSessionRepo } from './db/browser-session-repo.js';
import { logger } from './logger.js';
import { loadConfig } from './config.js';
import { resolveAndRunUserScript } from './user-folder/script-orchestrator.js';

// 進行中とみなすステータス（これらの場合は次回スケジュール実行をスキップ）。
// これらはいずれも「真に実行中」か「自動回復する一時状態」のみ:
//   queued/dispatching/running … 実行中
//   waiting_subtasks           … 並列サブタスク完了待ち。requeueWaitingSubtasks が自動回復させる
// waiting_human (ASK) は意図的に含めない。自動回復もタイムアウトも無いため、
// 無人実行のスケジュールタスクが一度 ASK を出すとその後の実行が永久にスキップされ、
// スケジュールが事実上死ぬ。各スケジュール実行は独立した新規タスクなので、
// 前回の waiting_human タスクは残置し（ユーザーが後から回答 or 放置できる）、新規実行を走らせる。
const IN_PROGRESS_STATUSES = new Set([
  'queued', 'dispatching', 'running', 'waiting_subtasks',
]);

export interface ScheduleInput {
  hour?: number;
  minute?: number;
  dayOfWeek?: number;
  dayOfMonth?: number;
  cronExpression?: string;
}

/**
 * プリセット（daily/weekly/monthly）を cron 式に変換する。
 * cron タイプはそのまま返し、once は 'once' を返す。
 */
export function convertToCron(scheduleType: string, input: ScheduleInput): string {
  const m = input.minute ?? 0;
  const h = input.hour ?? 0;

  switch (scheduleType) {
    case 'daily':
      return `${m} ${h} * * *`;
    case 'weekly':
      return `${m} ${h} * * ${input.dayOfWeek ?? 0}`;
    case 'monthly':
      return `${m} ${h} ${input.dayOfMonth ?? 1} * *`;
    case 'cron':
      if (!input.cronExpression) throw new Error('cronExpression is required for cron type');
      return input.cronExpression;
    case 'once':
      return 'once';
    default:
      throw new Error(`Unknown schedule type: ${scheduleType}`);
  }
}

/**
 * Date を SQLite datetime() 互換フォーマット (YYYY-MM-DD HH:MM:SS) に変換する。
 * SQLite の datetime('now') は UTC で 'YYYY-MM-DD HH:MM:SS' を返すため、
 * toISOString() の 'T' や 'Z'、ミリ秒部分を除去して一致させる。
 */
export function toSqliteDatetime(date: Date): string {
  return date.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
}

/**
 * cron 式から次回実行時刻（SQLite datetime 互換 UTC）を算出する。
 * 'once' の場合は null を返す。
 */
export function calcNextRun(cronExpression: string): string | null {
  if (cronExpression === 'once') return null;
  const interval = CronExpressionParser.parse(cronExpression, { tz: 'UTC' });
  return toSqliteDatetime(interval.next().toDate());
}

export interface SchedulerOptions {
  /**
   * 'auto' を実 piece 名に解決するクラシファイア。UI 経路 (local-tasks-api) と同じ
   * 関数を渡す。未指定または例外時は 'chat' に fallback する。
   * これを渡さないと pieceName='auto' のスケジュールが worker で
   * `Piece not found: auto` で必ず失敗する。
   */
  selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
  /**
   * task_kind='script' をサポートするための依存。未指定なら script kind の実行は
   * "Scheduler not configured for script kind" でその場で fail させる。
   */
  sessRepo?: BrowserSessionRepo;
  masterKeyPath?: string;
  userFolderRoot?: string;
  /**
   * Override how the user-script security gate reads config. Production
   * leaves this undefined (defaults to loadConfig()). Tests inject their own
   * to avoid relying on a real config.yaml on disk, since vitest workers
   * can't process.chdir().
   */
  getUserScriptGate?: () => { enabled: boolean; allowUserids?: string[] };
}

export class Scheduler {
  private timer: ReturnType<typeof setTimeout> | null = null;
  private running = false;
  private readonly pollIntervalMs = 60_000;
  private readonly selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
  private readonly sessRepo?: BrowserSessionRepo;
  private readonly masterKeyPath?: string;
  private readonly userFolderRoot?: string;
  private readonly getUserScriptGate: () => { enabled: boolean; allowUserids?: string[] };

  constructor(
    private readonly repo: Repository,
    private readonly worktreeDir: string,
    options?: SchedulerOptions,
  ) {
    this.selectPiece = options?.selectPiece;
    this.sessRepo = options?.sessRepo;
    this.masterKeyPath = options?.masterKeyPath;
    this.userFolderRoot = options?.userFolderRoot;
    this.getUserScriptGate = options?.getUserScriptGate ?? (() => {
      const cfg = loadConfig();
      return {
        enabled: cfg.tools?.userScriptsEnabled === true,
        allowUserids: cfg.tools?.userScriptsAllowUserids,
      };
    });
  }

  start(): void {
    if (this.running) return;
    this.running = true;
    logger.info('[scheduler] started');
    // 起動時に即座に1回 tick（再起動後の溜まり分を処理）
    void this.tick().catch(err => logger.error(`[scheduler] initial tick error: ${err}`));
    this.scheduleTick();
  }

  stop(): void {
    this.running = false;
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
    logger.info('[scheduler] stopped');
  }

  private scheduleTick(): void {
    if (!this.running) return;
    this.timer = setTimeout(async () => {
      try {
        await this.tick();
      } catch (err) {
        logger.error(`[scheduler] tick error: ${err}`);
      }
      this.scheduleTick();
    }, this.pollIntervalMs);
  }

  async tick(): Promise<number> {
    const dueItems = await this.repo.getScheduledTasksDue();
    let executed = 0;

    for (const item of dueItems) {
      try {
        // 前回ジョブが進行中ならスキップ
        if (item.lastJobId) {
          const lastJob = await this.repo.getJob(item.lastJobId);
          if (lastJob && IN_PROGRESS_STATUSES.has(lastJob.status)) {
            logger.info(`[scheduler] skipping scheduled_task=${item.id}: last job ${item.lastJobId} is ${lastJob.status}`);
            // next_run_at だけ次回に更新
            const nextRun = calcNextRun(item.cronExpression);
            if (nextRun) {
              await this.repo.updateScheduledTask(item.id, { nextRunAt: nextRun });
            }
            continue;
          }
        }

        await this.executeScheduledTask(item);
        executed++;
      } catch (err) {
        logger.error(`[scheduler] failed to execute scheduled_task=${item.id}: ${err}`);
        // claim（next_run_at を 9999-12-31 に設定）後の失敗時は次回実行時刻を復元し、
        // タスクが永久にロックされるのを防ぐ
        try {
          const nextRun = calcNextRun(item.cronExpression);
          if (nextRun) {
            await this.repo.updateScheduledTask(item.id, { nextRunAt: nextRun });
            logger.info(`[scheduler] restored next_run_at for scheduled_task=${item.id}`);
          }
        } catch (restoreErr) {
          logger.error(`[scheduler] failed to restore next_run_at for scheduled_task=${item.id}: ${restoreErr}`);
        }
      }
    }

    return executed;
  }

  private async executeScheduledTask(item: ScheduledTask): Promise<void> {
    if (item.taskKind === 'script') {
      await this.executeScriptScheduledTask(item);
      return;
    }
    await this.executeAgentScheduledTask(item);
  }

  private async executeAgentScheduledTask(item: ScheduledTask): Promise<void> {
    const now = toSqliteDatetime(new Date());

    // タイトルに日時を付加
    const dateStr = new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
    const title = item.title ? `${item.title} (${dateStr})` : `スケジュール実行 (${dateStr})`;

    // 'auto' は piece-classifier で実 piece 名に解決する。
    // pieces/auto.yaml は存在しないので、'auto' をそのまま流すと worker が
    // loadPiece('auto') で `Piece not found: auto` を投げて即失敗する。
    // UI 経路 (local-tasks-api) と同じ classifier をコンストラクタ経由で受け取る。
    let resolvedPiece = item.pieceName;
    if (resolvedPiece === 'auto') {
      if (this.selectPiece) {
        try {
          resolvedPiece = await this.selectPiece(item.body, [], item.ownerId ?? undefined);
        } catch (err) {
          logger.warn(`[scheduler] piece classification failed for scheduled_task=${item.id}: ${err}, falling back to 'chat'`);
          resolvedPiece = 'chat';
        }
      } else {
        logger.warn(`[scheduler] selectPiece not configured, falling back to 'chat' for scheduled_task=${item.id}`);
        resolvedPiece = 'chat';
      }
    }

    // 新規ローカルタスク作成。
    // 所有権・可視性はスケジュール定義から継承する。継承しないと local_task.owner_id が
    // NULL (system 扱い)・visibility が 'private' (admin のみ閲覧可) になり、
    // 作成者本人が自分のスケジュール実行結果を見られなくなる。
    const task = await this.repo.createLocalTask({
      title,
      body: item.body,
      pieceName: resolvedPiece,
      profile: item.profile as 'auto' | 'fast' | 'quality',
      outputFormat: item.outputFormat as 'text' | 'markdown' | 'json',
      ownerId: item.ownerId,
      visibility: item.visibility,
      visibilityScopeOrgId: item.visibilityScopeOrgId,
      browserSessionProfileId: item.browserSessionProfileId ?? null,
    });

    // ワークスペース作成
    const workspacePath = join(this.worktreeDir, 'local', String(task.id));
    mkdirSync(join(workspacePath, 'input'), { recursive: true });
    mkdirSync(join(workspacePath, 'output'), { recursive: true });
    mkdirSync(join(workspacePath, 'logs'), { recursive: true });
    await this.repo.updateLocalTask(task.id, { workspacePath });

    // ジョブ作成
    const metadataBlock = [
      '---',
      `ui_profile: ${item.profile}`,
      `ui_output_format: ${item.outputFormat}`,
      `ui_ask_policy: low`,
      `ui_priority: medium`,
      '---',
    ].join('\n');
    const instruction = `${title}\n\n${item.body}\n\n${metadataBlock}`.trim();

    const job = await this.repo.createJob({
      repo: `local/task-${task.id}`,
      issueNumber: task.id,
      instruction,
      pieceName: resolvedPiece,
      role: item.profile as any,
      ownerId: item.ownerId,
      visibility: item.visibility,
      visibilityScopeOrgId: item.visibilityScopeOrgId,
      browserSessionProfileId: item.browserSessionProfileId ?? null,
    });

    await this.repo.addAuditLog(job.id, 'job_queued_scheduled', 'scheduler', {
      scheduledTaskId: item.id,
      taskId: task.id,
      requestedPiece: item.pieceName,
      resolvedPiece,
    });

    // scheduled_tasks を更新
    const nextRun = calcNextRun(item.cronExpression);
    await this.repo.updateScheduledTask(item.id, {
      lastRunAt: now,
      lastJobId: job.id,
      nextRunAt: nextRun ?? item.nextRunAt,
      isActive: item.cronExpression === 'once' ? false : undefined,
    });

    logger.info(`[scheduler] executed scheduled_task=${item.id} → task=${task.id} job=${job.id}`);
  }

  private async executeScriptScheduledTask(item: ScheduledTask): Promise<void> {
    const now = toSqliteDatetime(new Date());

    if (!item.scriptName) {
      throw new Error(`scheduled_task=${item.id}: task_kind='script' but script_name is null`);
    }
    if (!this.userFolderRoot) {
      throw new Error(`scheduled_task=${item.id}: task_kind='script' but Scheduler.userFolderRoot was not configured`);
    }
    // No-auth mode stores scheduled tasks with ownerId=null (scheduled-tasks-api
    // has no authenticated user). Macros are per-user (data/users/{id}/browser-macros/),
    // so resolve to the same 'local' namespace the RunUserScript tool uses in
    // no-auth (ctx.userId='local'). In auth mode item.ownerId is always set, so
    // scriptOwner === item.ownerId and behaviour is unchanged.
    const scriptOwner = item.ownerId ?? 'local';

    // Same security gates as the LLM-facing RunUserScript tool: global config
    // toggle + optional per-user allowlist. A scheduled run is automated, so a
    // mis-enabled user could quietly exfiltrate via cron without the gate.
    const gate = this.getUserScriptGate();
    if (!gate.enabled) {
      throw new Error(
        `scheduled_task=${item.id}: user scripts are disabled (tools.user_scripts_enabled=false)`,
      );
    }
    if (Array.isArray(gate.allowUserids) && gate.allowUserids.length > 0 && !gate.allowUserids.includes(scriptOwner)) {
      throw new Error(
        `scheduled_task=${item.id}: owner "${scriptOwner}" is not in tools.user_scripts_allow_userids`,
      );
    }

    // Decode params; tolerate null / empty
    let params: Record<string, unknown> = {};
    if (item.scriptParams) {
      try {
        const parsed = JSON.parse(item.scriptParams);
        if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
          params = parsed as Record<string, unknown>;
        }
      } catch (err) {
        throw new Error(`scheduled_task=${item.id}: malformed script_params JSON: ${(err as Error).message}`);
      }
    }

    const dateStr = new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
    const title = item.title ? `${item.title} (${dateStr})` : `スクリプト実行: ${item.scriptName} (${dateStr})`;

    const task = await this.repo.createLocalTask({
      title,
      body: `script: ${item.scriptName}\nparams: ${item.scriptParams ?? '{}'}`,
      pieceName: 'script',
      profile: item.profile as 'auto' | 'fast' | 'quality',
      outputFormat: item.outputFormat as 'text' | 'markdown' | 'json',
      ownerId: item.ownerId,
      visibility: item.visibility,
      visibilityScopeOrgId: item.visibilityScopeOrgId,
      browserSessionProfileId: item.browserSessionProfileId ?? null,
    });

    const workspacePath = join(this.worktreeDir, 'local', String(task.id));
    mkdirSync(join(workspacePath, 'input'), { recursive: true });
    mkdirSync(join(workspacePath, 'output'), { recursive: true });
    mkdirSync(join(workspacePath, 'logs'), { recursive: true });
    await this.repo.updateLocalTask(task.id, { workspacePath });

    // Create job in a pre-completed state — scheduler runs the script inline,
    // so the worker queue never sees it. We still create the job row so the UI
    // (which keys off jobs) shows the run in task history.
    const job = await this.repo.createJob({
      repo: `local/task-${task.id}`,
      issueNumber: task.id,
      instruction: `Scheduled script run: ${item.scriptName}`,
      pieceName: 'script',
      role: item.profile as any,
      ownerId: item.ownerId,
      visibility: item.visibility,
      visibilityScopeOrgId: item.visibilityScopeOrgId,
      browserSessionProfileId: item.browserSessionProfileId ?? null,
    });

    await this.repo.addAuditLog(job.id, 'job_queued_scheduled', 'scheduler', {
      scheduledTaskId: item.id,
      taskId: task.id,
      kind: 'script',
      scriptName: item.scriptName,
    });

    let runFailed = false;
    let errorMessage: string | null = null;
    try {
      const runResult = await resolveAndRunUserScript({
        rootDir: this.userFolderRoot,
        userId: scriptOwner,
        name: item.scriptName,
        params,
        sessRepo: this.sessRepo,
        masterKeyPath: this.masterKeyPath,
        timeoutMs: 60_000,
      });

      const summary = [
        `script: ${item.scriptName}`,
        `subdir: ${runResult.ok ? runResult.subdir : runResult.subdir ?? 'unresolved'}`,
        `ok: ${runResult.ok}`,
        runResult.ok ? `durationMs: ${runResult.durationMs}` : `error: ${runResult.error}`,
      ].join('\n');
      writeFileSync(join(workspacePath, 'logs', 'script-run.log'), summary + '\n', 'utf-8');

      if (runResult.ok) {
        const out = typeof runResult.result === 'string'
          ? runResult.result
          : JSON.stringify(runResult.result, null, 2);
        writeFileSync(join(workspacePath, 'output', 'script-output.txt'), out ?? '', 'utf-8');
        if (runResult.logs.length > 0) {
          writeFileSync(join(workspacePath, 'logs', 'script-stdout.log'), runResult.logs.join('\n') + '\n', 'utf-8');
        }
      } else {
        runFailed = true;
        errorMessage = runResult.error;
        writeFileSync(join(workspacePath, 'logs', 'script-error.log'), runResult.error + '\n', 'utf-8');
      }
    } catch (err) {
      runFailed = true;
      errorMessage = (err as Error).message;
      writeFileSync(join(workspacePath, 'logs', 'script-error.log'), `unexpected error: ${errorMessage}\n`, 'utf-8');
    }

    await this.repo.updateJob(job.id, {
      status: runFailed ? 'failed' : 'succeeded',
      ...(runFailed && errorMessage ? { errorSummary: errorMessage.slice(0, 1000) } : {}),
    });

    await this.repo.addAuditLog(job.id, 'user_script_run', 'scheduler', {
      scheduledTaskId: item.id,
      userId: scriptOwner,
      scriptName: item.scriptName,
      ok: !runFailed,
      ...(runFailed && errorMessage ? { error: errorMessage.slice(0, 500) } : {}),
    });

    const nextRun = calcNextRun(item.cronExpression);
    await this.repo.updateScheduledTask(item.id, {
      lastRunAt: now,
      lastJobId: job.id,
      nextRunAt: nextRun ?? item.nextRunAt,
      isActive: item.cronExpression === 'once' ? false : undefined,
    });

    if (runFailed) {
      logger.warn(`[scheduler] script scheduled_task=${item.id} (${item.scriptName}) failed: ${errorMessage}`);
    } else {
      logger.info(`[scheduler] executed script scheduled_task=${item.id} → task=${task.id} job=${job.id} script=${item.scriptName}`);
    }
  }

  /** 手動トリガー用: 指定IDのスケジュールを即時実行 */
  async executeById(id: number): Promise<void> {
    const item = await this.repo.getScheduledTask(id);
    if (!item) throw new Error(`Scheduled task ${id} not found`);
    await this.executeScheduledTask(item);
  }
}
