/**
 * Schedule handler for task scheduling management
 * ARCHITECTURE: Event-driven schedule lifecycle management
 * Pattern: Factory pattern for async initialization (matches DependencyHandler)
 * Rationale: Manages schedule creation, triggering, pausing, and execution tracking
 */

import type { LoopCreateRequest, Schedule, ScheduleId, Task } from '../../core/domain.js';
import {
  createLoop,
  createTask,
  isTerminalState,
  LoopStatus,
  ScheduleStatus,
  ScheduleType,
  TaskId,
  TaskStatus,
  updateLoop,
  updateSchedule,
} from '../../core/domain.js';
import { AutobeatError, ErrorCode } from '../../core/errors.js';
import { EventBus } from '../../core/events/event-bus.js';
import {
  ScheduleCancelledEvent,
  ScheduleCreatedEvent,
  SchedulePausedEvent,
  ScheduleResumedEvent,
  ScheduleTriggeredEvent,
  ScheduleUpdatedEvent,
} from '../../core/events/events.js';
import { BaseEventHandler } from '../../core/events/handlers.js';
import {
  Logger,
  LoopRepository,
  LoopService,
  ScheduleRepository,
  SyncScheduleOperations,
  SyncTaskOperations,
  TaskRepository,
  TransactionRunner,
} from '../../core/interfaces.js';
import { err, ok, Result } from '../../core/result.js';
import { getNextRunTime, isValidTimezone, validateCronExpression } from '../../utils/cron.js';
import { captureLoopGitContext } from '../../utils/git-state.js';

/**
 * Options for ScheduleHandler configuration
 */
export interface ScheduleHandlerOptions {
  /** Default timezone for schedules without explicit timezone. Default: 'UTC' */
  readonly defaultTimezone?: string;
}

export interface ScheduleHandlerDeps {
  readonly scheduleRepo: ScheduleRepository & SyncScheduleOperations;
  readonly taskRepo: TaskRepository & SyncTaskOperations;
  readonly eventBus: EventBus;
  readonly database: TransactionRunner;
  readonly loopRepo: LoopRepository;
  readonly loopService: LoopService | undefined;
  readonly logger: Logger;
  readonly options?: ScheduleHandlerOptions;
}

export class ScheduleHandler extends BaseEventHandler {
  private readonly defaultTimezone: string;

  private readonly scheduleRepo: ScheduleRepository & SyncScheduleOperations;
  private readonly taskRepo: TaskRepository & SyncTaskOperations;
  private readonly eventBus: EventBus;
  private readonly database: TransactionRunner;
  private readonly loopRepo: LoopRepository;
  private readonly loopService: LoopService | undefined;

  /**
   * Private constructor - use ScheduleHandler.create() instead
   * ARCHITECTURE: Factory pattern ensures handler is fully initialized before use
   */
  private constructor(deps: ScheduleHandlerDeps) {
    super(deps.logger, 'ScheduleHandler');
    this.scheduleRepo = deps.scheduleRepo;
    this.taskRepo = deps.taskRepo;
    this.eventBus = deps.eventBus;
    this.database = deps.database;
    this.loopRepo = deps.loopRepo;
    this.loopService = deps.loopService;
    this.defaultTimezone = deps.options?.defaultTimezone ?? 'UTC';
  }

  /**
   * Factory method to create a fully initialized ScheduleHandler
   * ARCHITECTURE: Guarantees handler is ready to use - no uninitialized state possible
   */
  static async create(deps: ScheduleHandlerDeps): Promise<Result<ScheduleHandler, AutobeatError>> {
    const handlerLogger = deps.logger.child ? deps.logger.child({ module: 'ScheduleHandler' }) : deps.logger;

    // Create handler
    const handler = new ScheduleHandler({ ...deps, logger: handlerLogger });

    // Subscribe to events
    const subscribeResult = handler.subscribeToEvents();
    if (!subscribeResult.ok) {
      return subscribeResult;
    }

    handlerLogger.info('ScheduleHandler initialized', {
      defaultTimezone: handler.defaultTimezone,
    });

    return ok(handler);
  }

  /**
   * Subscribe to all relevant events
   * ARCHITECTURE: Called by factory after initialization
   */
  private subscribeToEvents(): Result<void, AutobeatError> {
    const subscriptions = [
      // Schedule lifecycle events
      this.eventBus.subscribe('ScheduleCreated', this.handleScheduleCreated.bind(this)),
      this.eventBus.subscribe('ScheduleTriggered', this.handleScheduleTriggered.bind(this)),
      this.eventBus.subscribe('ScheduleCancelled', this.handleScheduleCancelled.bind(this)),
      this.eventBus.subscribe('SchedulePaused', this.handleSchedulePaused.bind(this)),
      this.eventBus.subscribe('ScheduleResumed', this.handleScheduleResumed.bind(this)),
      this.eventBus.subscribe('ScheduleUpdated', this.handleScheduleUpdated.bind(this)),
    ];

    // Check if any subscription failed
    for (const result of subscriptions) {
      if (!result.ok) {
        return err(
          new AutobeatError(ErrorCode.SYSTEM_ERROR, `Failed to subscribe to events: ${result.error.message}`, {
            error: result.error,
          }),
        );
      }
    }

    return ok(undefined);
  }

  // ============================================================================
  // SCHEDULE LIFECYCLE EVENT HANDLERS
  // ============================================================================

  /**
   * Handle schedule creation - validate, calculate nextRunAt, persist
   */
  private async handleScheduleCreated(event: ScheduleCreatedEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const schedule = e.schedule;

      this.logger.info('Processing new schedule', {
        scheduleId: schedule.id,
        type: schedule.scheduleType,
        timezone: schedule.timezone,
      });

      // Validate timezone
      if (!isValidTimezone(schedule.timezone)) {
        this.logger.error('Invalid timezone for schedule', undefined, {
          scheduleId: schedule.id,
          timezone: schedule.timezone,
        });
        return err(
          new AutobeatError(ErrorCode.INVALID_INPUT, `Invalid timezone: ${schedule.timezone}`, {
            scheduleId: schedule.id,
            timezone: schedule.timezone,
          }),
        );
      }

      // Validate and calculate nextRunAt based on schedule type
      let nextRunAt: number | undefined;

      if (schedule.scheduleType === ScheduleType.CRON) {
        // Validate cron expression
        if (!schedule.cronExpression) {
          return err(
            new AutobeatError(ErrorCode.INVALID_INPUT, 'CRON schedule requires cronExpression', {
              scheduleId: schedule.id,
            }),
          );
        }

        const cronValidation = validateCronExpression(schedule.cronExpression);
        if (!cronValidation.ok) {
          this.logger.error('Invalid cron expression', cronValidation.error, {
            scheduleId: schedule.id,
            cronExpression: schedule.cronExpression,
          });
          return cronValidation;
        }

        // Calculate first run time
        const nextResult = getNextRunTime(schedule.cronExpression, schedule.timezone);
        if (!nextResult.ok) {
          return nextResult;
        }
        nextRunAt = nextResult.value;
      } else if (schedule.scheduleType === ScheduleType.ONE_TIME) {
        // ONE_TIME uses scheduledAt directly
        if (!schedule.scheduledAt) {
          return err(
            new AutobeatError(ErrorCode.INVALID_INPUT, 'ONE_TIME schedule requires scheduledAt timestamp', {
              scheduleId: schedule.id,
            }),
          );
        }
        nextRunAt = schedule.scheduledAt;
      } else {
        const _exhaustive: never = schedule.scheduleType;
        return err(
          new AutobeatError(ErrorCode.INVALID_INPUT, `Unknown schedule type: ${schedule.scheduleType}`, {
            scheduleId: schedule.id,
          }),
        );
      }

      // Update schedule with calculated nextRunAt and save
      const updatedSchedule = updateSchedule(schedule, { nextRunAt });
      const saveResult = await this.scheduleRepo.save(updatedSchedule);
      if (!saveResult.ok) {
        this.logger.error('Failed to save schedule', saveResult.error, {
          scheduleId: schedule.id,
        });
        return saveResult;
      }

      this.logger.info('Schedule created and persisted', {
        scheduleId: schedule.id,
        nextRunAt,
        nextRunAtDate: nextRunAt ? new Date(nextRunAt).toISOString() : 'none',
      });

      return ok(undefined);
    });
  }

  /**
   * Handle schedule trigger - dispatch to single-task or pipeline path
   */
  private async handleScheduleTriggered(event: ScheduleTriggeredEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const { scheduleId, triggeredAt } = e;

      this.logger.info('Processing schedule trigger', {
        scheduleId,
        triggeredAt,
        triggeredAtDate: new Date(triggeredAt).toISOString(),
      });

      // Fetch schedule
      const scheduleResult = await this.scheduleRepo.findById(scheduleId);
      if (!scheduleResult.ok) {
        return scheduleResult;
      }

      const schedule = scheduleResult.value;
      if (!schedule) {
        return err(new AutobeatError(ErrorCode.TASK_NOT_FOUND, `Schedule ${scheduleId} not found`, { scheduleId }));
      }

      // Check if schedule is still active
      if (schedule.status !== ScheduleStatus.ACTIVE) {
        this.logger.warn('Schedule is not active, skipping trigger', {
          scheduleId,
          status: schedule.status,
        });
        return ok(undefined);
      }

      // Dispatch to appropriate trigger path
      if (schedule.loopConfig) {
        return this.handleLoopTrigger(schedule, schedule.loopConfig, triggeredAt);
      }
      if (schedule.pipelineSteps && schedule.pipelineSteps.length > 0) {
        return this.handlePipelineTrigger(schedule, schedule.pipelineSteps, triggeredAt);
      }
      return this.handleSingleTaskTrigger(schedule, triggeredAt);
    });
  }

  /**
   * Handle single-task trigger — atomic via Database.runInTransaction()
   */
  private async handleSingleTaskTrigger(schedule: Schedule, triggeredAt: number): Promise<Result<void>> {
    const scheduleId = schedule.id;

    // afterScheduleId enforcement: inject dependency on chained schedule's latest task
    // (reads only, needs async for history lookup — stays OUTSIDE transaction)
    const afterTaskId = await this.resolveAfterScheduleTaskId(schedule);
    const dependsOn = afterTaskId
      ? [...(schedule.taskTemplate.dependsOn ?? []), afterTaskId]
      : schedule.taskTemplate.dependsOn;

    // Create task domain object (pure computation — OUTSIDE transaction)
    const task = createTask({ ...schedule.taskTemplate, dependsOn });

    // Pure computation OUTSIDE transaction
    const scheduleUpdates = this.computeScheduleUpdates(schedule, triggeredAt);

    // Atomic: save task + record execution + update schedule
    const txResult = this.database.runInTransaction(() => {
      try {
        this.taskRepo.saveSync(task);
      } catch (error) {
        throw new AutobeatError(
          ErrorCode.SYSTEM_ERROR,
          `Schedule trigger failed: ${error instanceof Error ? error.message : String(error)}`,
        );
      }

      this.scheduleRepo.recordExecutionSync({
        scheduleId,
        taskId: task.id,
        scheduledFor: schedule.nextRunAt ?? triggeredAt,
        executedAt: triggeredAt,
        status: 'triggered',
        createdAt: Date.now(),
      });

      this.scheduleRepo.updateSync(schedule.id, scheduleUpdates, schedule);
    });

    if (!txResult.ok) {
      // Transaction rolled back — best-effort audit trail
      await this.recordFailedExecution(
        scheduleId,
        schedule.nextRunAt ?? triggeredAt,
        triggeredAt,
        txResult.error.message,
      );
      return txResult;
    }

    // Post-commit logging
    this.logScheduleTransition(schedule, scheduleUpdates);

    // Events emitted AFTER transaction commit (never emit for uncommitted data)
    await this.eventBus.emit('TaskDelegated', { task });

    await this.eventBus.emit('ScheduleExecuted', {
      scheduleId,
      taskId: task.id,
      executedAt: triggeredAt,
    });

    this.logger.info('Schedule triggered successfully', {
      scheduleId,
      taskId: task.id,
      runCount: schedule.runCount + 1,
    });

    return ok(undefined);
  }

  /**
   * Handle pipeline trigger — atomic via Database.runInTransaction().
   * If any task save throws → automatic rollback → zero tasks persisted → no cleanup needed.
   */
  private async handlePipelineTrigger(
    schedule: Schedule,
    steps: NonNullable<Schedule['pipelineSteps']>,
    triggeredAt: number,
  ): Promise<Result<void>> {
    const scheduleId = schedule.id;
    const defaults = schedule.taskTemplate;

    this.logger.info('Processing pipeline trigger', {
      scheduleId,
      stepCount: steps.length,
    });

    // afterScheduleId handling: resolve predecessor dependency for step 0
    // (reads only, needs async for history lookup — stays OUTSIDE transaction)
    const afterTaskId = await this.resolveAfterScheduleTaskId(schedule);
    const step0DependsOn: TaskId[] | undefined = afterTaskId ? [afterTaskId] : undefined;

    // Pre-create ALL task domain objects OUTSIDE transaction (pure computation)
    const tasks: Task[] = [];
    for (let i = 0; i < steps.length; i++) {
      const step = steps[i];
      const dependsOn: TaskId[] = [];

      if (i === 0 && step0DependsOn) {
        dependsOn.push(...step0DependsOn);
      }

      if (i > 0) {
        dependsOn.push(tasks[i - 1].id);
      }

      tasks.push(
        createTask({
          prompt: step.prompt,
          priority: step.priority ?? defaults.priority,
          workingDirectory: step.workingDirectory ?? defaults.workingDirectory,
          agent: step.agent ?? defaults.agent,
          model: step.model ?? defaults.model,
          systemPrompt: defaults.systemPrompt,
          dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
        }),
      );
    }

    const allTaskIds = tasks.map((t) => t.id);
    const firstTaskId = tasks[0].id;
    const lastTaskId = tasks[tasks.length - 1].id;

    // Pure computation OUTSIDE transaction
    const scheduleUpdates = this.computeScheduleUpdates(schedule, triggeredAt);

    // Atomic: save N tasks + record execution + update schedule
    const txResult = this.database.runInTransaction(() => {
      for (let i = 0; i < tasks.length; i++) {
        try {
          this.taskRepo.saveSync(tasks[i]);
        } catch (error) {
          throw new AutobeatError(
            ErrorCode.SYSTEM_ERROR,
            `Pipeline failed at step ${i + 1}: ${error instanceof Error ? error.message : String(error)}`,
          );
        }
      }

      // Record execution with lastTaskId for afterScheduleId chaining
      this.scheduleRepo.recordExecutionSync({
        scheduleId,
        taskId: lastTaskId,
        scheduledFor: schedule.nextRunAt ?? triggeredAt,
        executedAt: triggeredAt,
        status: 'triggered',
        pipelineTaskIds: allTaskIds,
        createdAt: Date.now(),
      });

      this.scheduleRepo.updateSync(schedule.id, scheduleUpdates, schedule);
    });

    if (!txResult.ok) {
      // Transaction rolled back — zero tasks exist, no cleanup needed.
      // Best-effort audit trail for the failure.
      await this.recordFailedExecution(
        scheduleId,
        schedule.nextRunAt ?? triggeredAt,
        triggeredAt,
        txResult.error.message,
      );
      return txResult;
    }

    // Post-commit logging
    this.logScheduleTransition(schedule, scheduleUpdates);

    // Events emitted AFTER transaction commit (never emit for uncommitted data)
    // Step 0 failure is fatal — it's the only task that becomes runnable.
    // Steps 1–N failures are best-effort.
    for (let ti = 0; ti < tasks.length; ti++) {
      const task = tasks[ti];
      const emitResult = await this.eventBus.emit('TaskDelegated', { task });
      if (!emitResult.ok) {
        if (ti === 0) {
          this.logger.error('Failed to emit TaskDelegated for pipeline step 0 — aborting pipeline', emitResult.error, {
            taskId: task.id,
            scheduleId,
          });
          // Post-commit cancellation — tasks are committed but event pipeline failed
          for (const savedTask of tasks) {
            await this.taskRepo.update(savedTask.id, { status: TaskStatus.CANCELLED });
          }
          return emitResult;
        }
        this.logger.error('Failed to emit TaskDelegated for pipeline task', emitResult.error, {
          taskId: task.id,
          scheduleId,
          step: ti,
        });
      }
    }

    // Emit ScheduleExecuted with lastTaskId (tail task for concurrency tracking)
    await this.eventBus.emit('ScheduleExecuted', {
      scheduleId,
      taskId: lastTaskId,
      executedAt: triggeredAt,
    });

    this.logger.info('Pipeline triggered successfully', {
      scheduleId,
      stepCount: steps.length,
      firstTaskId,
      lastTaskId,
      runCount: schedule.runCount + 1,
    });

    return ok(undefined);
  }

  /**
   * Handle loop trigger — create a new loop from schedule's loopConfig
   * ARCHITECTURE: Collision detection prevents concurrent loops from same schedule.
   * Uses LoopCreated event to hand off to LoopHandler for persistence and iteration start.
   */
  private async handleLoopTrigger(
    schedule: Schedule,
    loopConfig: LoopCreateRequest,
    triggeredAt: number,
  ): Promise<Result<void>> {
    const scheduleId = schedule.id;

    this.logger.info('Processing loop trigger', { scheduleId });

    // Collision detection: check for active (RUNNING or PAUSED) loops from this schedule
    const existingLoopsResult = await this.loopRepo.findByScheduleId(scheduleId);
    if (existingLoopsResult.ok) {
      const activeLoops = existingLoopsResult.value.filter(
        (l) => l.status === LoopStatus.RUNNING || l.status === LoopStatus.PAUSED,
      );
      if (activeLoops.length > 0) {
        this.logger.info('Skipping scheduled loop trigger — previous loop still active', {
          scheduleId,
          activeLoopId: activeLoops[0].id,
          activeLoopStatus: activeLoops[0].status,
        });

        // Still update schedule timing so it advances to the next run
        const scheduleUpdates = this.computeScheduleUpdates(schedule, triggeredAt);
        await this.scheduleRepo.update(scheduleId, scheduleUpdates);

        return ok(undefined);
      }
    }

    // Validate loopConfig via LoopService if available (prevents bypassing validations)
    const workingDirectory = loopConfig.workingDirectory ?? schedule.taskTemplate.workingDirectory ?? process.cwd();
    if (this.loopService) {
      // ARCHITECTURE: Omit workingDirectory from validation — it was already validated
      // at schedule creation time. Re-validating here would fail for paths like /tmp
      // that resolve outside CWD on some platforms (e.g., macOS /tmp -> /private/tmp).
      const validationResult = await this.loopService.validateCreateRequest({
        ...loopConfig,
        workingDirectory: undefined,
      });
      if (!validationResult.ok) {
        await this.recordFailedExecution(
          scheduleId,
          schedule.nextRunAt ?? triggeredAt,
          triggeredAt,
          validationResult.error.message,
        );
        return err(new AutobeatError(ErrorCode.INVALID_INPUT, validationResult.error.message, { scheduleId }));
      }
    }

    // Create loop from loopConfig via domain factory
    const loop = createLoop(loopConfig, workingDirectory, scheduleId);

    // Capture gitStartCommitSha (v0.8.1)
    // ARCHITECTURE: createLoop() sets these to undefined; override here because
    // git operations are async (not available in pure domain factory)
    // gitBaseBranch is a legacy field still populated for DB backward compatibility
    let loopWithGit = loop;
    const gitContextResult = await captureLoopGitContext(workingDirectory, loopConfig.gitBranch);
    if (gitContextResult.ok) {
      const { gitBaseBranch, gitStartCommitSha } = gitContextResult.value;
      if (gitBaseBranch || gitStartCommitSha) {
        loopWithGit = updateLoop(loop, {
          ...(gitBaseBranch ? { gitBaseBranch } : {}),
          ...(gitStartCommitSha ? { gitStartCommitSha } : {}),
        });
      }
    } else {
      this.logger.warn('Failed to capture git state for scheduled loop — proceeding without git context', {
        scheduleId,
        loopId: loop.id,
        error: gitContextResult.error.message,
      });
    }

    // Pure computation OUTSIDE transaction
    const scheduleUpdates = this.computeScheduleUpdates(schedule, triggeredAt);

    // Atomic: record execution + update schedule (matches handleSingleTaskTrigger pattern)
    const txResult = this.database.runInTransaction(() => {
      this.scheduleRepo.recordExecutionSync({
        scheduleId,
        loopId: loopWithGit.id,
        scheduledFor: schedule.nextRunAt ?? triggeredAt,
        executedAt: triggeredAt,
        status: 'triggered',
        createdAt: Date.now(),
      });

      this.scheduleRepo.updateSync(schedule.id, scheduleUpdates, schedule);
    });

    if (!txResult.ok) {
      // Transaction rolled back — best-effort audit trail
      await this.recordFailedExecution(
        scheduleId,
        schedule.nextRunAt ?? triggeredAt,
        triggeredAt,
        txResult.error.message,
      );
      return txResult;
    }

    // Post-commit logging
    this.logScheduleTransition(schedule, scheduleUpdates);

    // Emit LoopCreated — LoopHandler persists the loop and starts first iteration
    const emitResult = await this.eventBus.emit('LoopCreated', { loop: loopWithGit });
    if (!emitResult.ok) {
      this.logger.error('Failed to emit LoopCreated for scheduled loop trigger', emitResult.error, {
        scheduleId,
        loopId: loopWithGit.id,
      });
      return emitResult;
    }

    // Emit ScheduleExecuted with loopId for ScheduleExecutor concurrency tracking
    await this.eventBus.emit('ScheduleExecuted', {
      scheduleId,
      loopId: loopWithGit.id,
      executedAt: triggeredAt,
    });

    this.logger.info('Scheduled loop triggered successfully', {
      scheduleId,
      loopId: loopWithGit.id,
      runCount: schedule.runCount + 1,
    });

    return ok(undefined);
  }

  // ============================================================================
  // SHARED HELPERS (extracted from handleScheduleTriggered decomposition)
  // ============================================================================

  /**
   * Resolve afterScheduleId to the TaskId of the chained schedule's latest non-terminal task.
   * Returns undefined if no dependency is needed (no afterScheduleId, no active task, etc.)
   * Used by both single-task and pipeline trigger paths.
   */
  private async resolveAfterScheduleTaskId(schedule: Schedule): Promise<TaskId | undefined> {
    if (!schedule.afterScheduleId) return undefined;

    const historyResult = await this.scheduleRepo.getExecutionHistory(schedule.afterScheduleId, 1);
    if (!historyResult.ok || historyResult.value.length === 0) return undefined;

    const latestExecution = historyResult.value[0];
    if (!latestExecution.taskId) return undefined;

    const depTaskResult = await this.taskRepo.findById(latestExecution.taskId);
    if (!depTaskResult.ok || !depTaskResult.value || isTerminalState(depTaskResult.value.status)) {
      this.logger.info('afterSchedule dependency already resolved, skipping', {
        scheduleId: schedule.id,
        afterScheduleId: schedule.afterScheduleId,
        taskId: latestExecution.taskId,
        taskStatus: depTaskResult.ok ? (depTaskResult.value?.status ?? 'not-found') : 'lookup-failed',
      });
      return undefined;
    }

    this.logger.info('Resolved afterSchedule dependency', {
      scheduleId: schedule.id,
      afterScheduleId: schedule.afterScheduleId,
      dependsOnTaskId: latestExecution.taskId,
    });

    return latestExecution.taskId;
  }

  /**
   * Record a failed execution in the audit trail
   */
  private async recordFailedExecution(
    scheduleId: ScheduleId,
    scheduledFor: number,
    triggeredAt: number,
    errorMessage: string,
  ): Promise<void> {
    const result = await this.scheduleRepo.recordExecution({
      scheduleId,
      scheduledFor,
      executedAt: triggeredAt,
      status: 'failed',
      errorMessage,
      createdAt: Date.now(),
    });
    if (!result.ok) {
      this.logger.error('Failed to record failed execution', result.error, { scheduleId });
    }
  }

  /**
   * Compute schedule update fields after a trigger (runCount, lastRunAt, nextRunAt, status).
   * Pure computation — no side effects.
   */
  private computeScheduleUpdates(schedule: Schedule, triggeredAt: number): Partial<Schedule> {
    const newRunCount = schedule.runCount + 1;
    let newStatus: ScheduleStatus | undefined;
    let newNextRunAt: number | undefined;

    // Calculate next run time for CRON schedules
    if (schedule.scheduleType === ScheduleType.CRON && schedule.cronExpression) {
      const nextResult = getNextRunTime(schedule.cronExpression, schedule.timezone);
      if (nextResult.ok) {
        newNextRunAt = nextResult.value;
      } else {
        newStatus = ScheduleStatus.PAUSED;
      }
    } else if (schedule.scheduleType === ScheduleType.ONE_TIME) {
      newStatus = ScheduleStatus.COMPLETED;
      newNextRunAt = undefined;
    }

    // Check if maxRuns reached
    if (schedule.maxRuns && newRunCount >= schedule.maxRuns) {
      newStatus = ScheduleStatus.COMPLETED;
      newNextRunAt = undefined;
    }

    // Check expiration
    if (schedule.expiresAt && Date.now() >= schedule.expiresAt) {
      newStatus = ScheduleStatus.EXPIRED;
      newNextRunAt = undefined;
    }

    return {
      runCount: newRunCount,
      lastRunAt: triggeredAt,
      nextRunAt: newNextRunAt,
      ...(newStatus !== undefined && { status: newStatus }),
    };
  }

  /**
   * Log schedule state transitions after successful commit.
   * Post-commit only — never called inside a transaction.
   */
  private logScheduleTransition(schedule: Schedule, updates: Partial<Schedule>): void {
    if (updates.status === ScheduleStatus.PAUSED) {
      this.logger.warn('Failed to calculate next run, pausing schedule', {
        scheduleId: schedule.id,
        cronExpression: schedule.cronExpression,
      });
    }
    if (updates.status === ScheduleStatus.COMPLETED && schedule.maxRuns) {
      this.logger.info('Schedule reached maxRuns, marking completed', {
        scheduleId: schedule.id,
        runCount: updates.runCount,
        maxRuns: schedule.maxRuns,
      });
    }
    if (updates.status === ScheduleStatus.EXPIRED) {
      this.logger.info('Schedule expired', {
        scheduleId: schedule.id,
        expiresAt: schedule.expiresAt,
      });
    }
  }

  /**
   * Handle schedule cancellation - update status to CANCELLED, cascade to active loops
   */
  private async handleScheduleCancelled(event: ScheduleCancelledEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const { scheduleId, reason } = e;

      this.logger.info('Cancelling schedule', { scheduleId, reason });

      // Fetch schedule to check for loopConfig before updating status
      const scheduleResult = await this.scheduleRepo.findById(scheduleId);

      const updateResult = await this.scheduleRepo.update(scheduleId, {
        status: ScheduleStatus.CANCELLED,
        nextRunAt: undefined,
      });

      if (!updateResult.ok) {
        this.logger.error('Failed to cancel schedule', updateResult.error, { scheduleId });
        return updateResult;
      }

      // Cascade cancel to active loops if this schedule has loopConfig
      if (scheduleResult.ok && scheduleResult.value?.loopConfig) {
        const loopsResult = await this.loopRepo.findByScheduleId(scheduleId);
        if (loopsResult.ok) {
          const activeLoops = loopsResult.value.filter(
            (l) => l.status === LoopStatus.RUNNING || l.status === LoopStatus.PAUSED,
          );
          for (const loop of activeLoops) {
            const cancelResult = await this.eventBus.emit('LoopCancelled', {
              loopId: loop.id,
              reason: `Parent schedule ${scheduleId} cancelled`,
            });
            if (!cancelResult.ok) {
              this.logger.warn('Failed to cancel loop for cancelled schedule', {
                loopId: loop.id,
                scheduleId,
                error: cancelResult.error.message,
              });
            }
          }
          if (activeLoops.length > 0) {
            this.logger.info('Cascade-cancelled active loops for schedule', {
              scheduleId,
              loopCount: activeLoops.length,
            });
          }
        }
      }

      this.logger.info('Schedule cancelled', { scheduleId, reason });
      return ok(undefined);
    });
  }

  /**
   * Handle schedule pause - update status to PAUSED
   */
  private async handleSchedulePaused(event: SchedulePausedEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const { scheduleId } = e;

      this.logger.info('Pausing schedule', { scheduleId });

      const updateResult = await this.scheduleRepo.update(scheduleId, {
        status: ScheduleStatus.PAUSED,
      });

      if (!updateResult.ok) {
        this.logger.error('Failed to pause schedule', updateResult.error, { scheduleId });
        return updateResult;
      }

      this.logger.info('Schedule paused', { scheduleId });
      return ok(undefined);
    });
  }

  /**
   * Handle schedule resume - update status to ACTIVE, recalculate nextRunAt
   */
  private async handleScheduleResumed(event: ScheduleResumedEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const { scheduleId } = e;

      this.logger.info('Resuming schedule', { scheduleId });

      // Fetch current schedule to recalculate nextRunAt
      const scheduleResult = await this.scheduleRepo.findById(scheduleId);
      if (!scheduleResult.ok) {
        return scheduleResult;
      }

      const schedule = scheduleResult.value;
      if (!schedule) {
        return err(new AutobeatError(ErrorCode.TASK_NOT_FOUND, `Schedule ${scheduleId} not found`, { scheduleId }));
      }

      // Recalculate nextRunAt for CRON schedules
      let nextRunAt = schedule.nextRunAt;
      if (schedule.scheduleType === ScheduleType.CRON && schedule.cronExpression) {
        const nextResult = getNextRunTime(schedule.cronExpression, schedule.timezone);
        if (nextResult.ok) {
          nextRunAt = nextResult.value;
        }
      }

      const updateResult = await this.scheduleRepo.update(scheduleId, {
        status: ScheduleStatus.ACTIVE,
        nextRunAt,
      });

      if (!updateResult.ok) {
        this.logger.error('Failed to resume schedule', updateResult.error, { scheduleId });
        return updateResult;
      }

      this.logger.info('Schedule resumed', {
        scheduleId,
        nextRunAt,
        nextRunAtDate: nextRunAt ? new Date(nextRunAt).toISOString() : 'none',
      });
      return ok(undefined);
    });
  }

  /**
   * Handle schedule update - apply partial updates
   */
  private async handleScheduleUpdated(event: ScheduleUpdatedEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const { scheduleId, update } = e;

      this.logger.info('Updating schedule', { scheduleId, updateFields: Object.keys(update) });

      const updateResult = await this.scheduleRepo.update(scheduleId, update);

      if (!updateResult.ok) {
        this.logger.error('Failed to update schedule', updateResult.error, { scheduleId });
        return updateResult;
      }

      this.logger.info('Schedule updated', { scheduleId });
      return ok(undefined);
    });
  }
}
