import { weightedJobProgress, validateStepWeights } from './progress';
import type {
  JobMetadataStore,
  JobExecutionResult,
  JobQueueListener,
  JobRecoveryProvider,
  MediaJob,
  MediaJobDefinition,
  MediaJobError,
  MediaJobExecutor,
  MediaJobStep,
  MediaJobStatus,
} from './job.types';

export interface MediaJobQueueOptions {
  readonly executor: MediaJobExecutor;
  readonly store: JobMetadataStore;
  readonly recoveryProvider?: JobRecoveryProvider;
  readonly now?: () => Date;
}

export class MediaJobQueue {
  readonly #executor: MediaJobExecutor;
  readonly #store: JobMetadataStore;
  readonly #recoveryProvider?: JobRecoveryProvider;
  readonly #now: () => Date;
  readonly #listeners = new Set<JobQueueListener>();
  readonly #definitions = new Map<string, MediaJobDefinition>();
  readonly #results = new Map<string, readonly JobExecutionResult[]>();
  readonly #cancellationTasks = new Map<string, Promise<void>>();
  #jobs = new Map<string, MediaJob>();
  #activeJobId?: string;
  #activeAbort?: AbortController;
  #scheduled = false;
  #initialized = false;
  #persistenceTail: Promise<void> = Promise.resolve();

  constructor(options: MediaJobQueueOptions) {
    this.#executor = options.executor;
    this.#store = options.store;
    this.#recoveryProvider = options.recoveryProvider;
    this.#now = options.now ?? (() => new Date());
  }

  async initialize(): Promise<void> {
    if (this.#initialized) {
      return;
    }
    const stored = await this.#store.list();
    this.#jobs = new Map(stored.map((job) => [job.id, freezeJob(job)]));
    for (const job of stored) {
      if (isInterruptedStatus(job.status)) {
        await this.#recoverPersistedJob(job);
      }
    }
    this.#initialized = true;
    this.#emit();
    this.#schedule();
  }

  list(): readonly MediaJob[] {
    return Object.freeze(
      [...this.#jobs.values()].sort((left, right) =>
        left.createdAt.localeCompare(right.createdAt)
      )
    );
  }

  get(id: string): MediaJob | undefined {
    return this.#jobs.get(id);
  }

  get activeJobId(): string | undefined {
    return this.#activeJobId;
  }

  getResults(id: string): readonly JobExecutionResult[] | undefined {
    return this.#results.get(id);
  }

  releaseResults(id: string): boolean {
    return this.#results.delete(id);
  }

  subscribe(listener: JobQueueListener): () => void {
    this.#listeners.add(listener);
    listener(this.list(), this.#activeJobId);
    return () => this.#listeners.delete(listener);
  }

  async enqueue(definition: MediaJobDefinition): Promise<MediaJob> {
    this.#assertInitialized();
    validateDefinition(definition);
    if (this.#jobs.has(definition.id)) {
      throw new RangeError(`Job already exists: ${definition.id}`);
    }
    this.#definitions.set(definition.id, freezeDefinition(definition));
    const timestamp = this.#timestamp();
    const job = freezeJob({
      id: definition.id,
      operation: definition.operation,
      status: 'queued',
      steps: definition.steps.map((step) => ({
        id: step.id,
        name: step.name,
        weight: step.weight,
        status: 'queued',
        progress: 0,
        planId: step.plan.id,
      })),
      progress: 0,
      createdAt: timestamp,
      updatedAt: timestamp,
      retryCount: 0,
    });
    await this.#replace(job);
    this.#schedule();
    return job;
  }

  async cancel(id: string): Promise<boolean> {
    this.#assertInitialized();
    const job = this.#jobs.get(id);
    if (!job || isTerminalStatus(job.status)) {
      return false;
    }
    if (job.status === 'queued') {
      await this.#update(id, {
        status: 'cancelled',
        completedAt: this.#timestamp(),
        steps: job.steps.map((step) =>
          step.status === 'queued' ? { ...step, status: 'cancelled' } : step
        ),
      });
      return true;
    }
    if (id !== this.#activeJobId) {
      return false;
    }
    const existing = this.#cancellationTasks.get(id);
    if (existing) {
      await existing;
      return true;
    }
    const task = this.#cancelActive(id);
    this.#cancellationTasks.set(id, task);
    try {
      await task;
    } finally {
      this.#cancellationTasks.delete(id);
    }
    return true;
  }

  async retry(
    id: string,
    replacementDefinition?: MediaJobDefinition
  ): Promise<MediaJob> {
    this.#assertInitialized();
    const job = this.#jobs.get(id);
    if (!job || (job.status !== 'failed' && job.status !== 'cancelled')) {
      throw new RangeError('Only failed or cancelled jobs can be retried');
    }
    const definition = replacementDefinition ?? this.#definitions.get(id);
    if (!definition) {
      throw new Error('The command plans for this job are no longer available');
    }
    validateDefinition(definition);
    if (definition.id !== id) {
      throw new TypeError('Replacement definition must retain the job ID');
    }
    this.#definitions.set(id, freezeDefinition(definition));
    this.#results.delete(id);
    const retried = await this.#update(id, {
      status: 'queued',
      progress: 0,
      startedAt: undefined,
      completedAt: undefined,
      error: undefined,
      recoveryNote: undefined,
      retryCount: job.retryCount + 1,
      steps: definition.steps.map((step) => ({
        id: step.id,
        name: step.name,
        weight: step.weight,
        status: 'queued',
        progress: 0,
        planId: step.plan.id,
      })),
    });
    this.#schedule();
    return retried;
  }

  async remove(id: string): Promise<boolean> {
    this.#assertInitialized();
    const job = this.#jobs.get(id);
    if (!job) {
      return false;
    }
    if (!isTerminalStatus(job.status)) {
      throw new Error('Active or queued jobs cannot be removed');
    }
    this.#jobs.delete(id);
    this.#definitions.delete(id);
    this.#results.delete(id);
    await this.#deletePersisted(id);
    this.#emit();
    return true;
  }

  async clearCompleted(): Promise<void> {
    const removable = this.list().filter((job) => isTerminalStatus(job.status));
    for (const job of removable) {
      this.#jobs.delete(job.id);
      this.#definitions.delete(job.id);
      this.#results.delete(job.id);
      await this.#deletePersisted(job.id);
    }
    this.#emit();
  }

  async waitForIdle(): Promise<void> {
    while (
      this.#activeJobId !== undefined ||
      this.list().some((job) => job.status === 'queued')
    ) {
      await new Promise<void>((resolve) => {
        const unsubscribe = this.subscribe((jobs, activeJobId) => {
          if (
            activeJobId === undefined &&
            !jobs.some((job) => job.status === 'queued')
          ) {
            unsubscribe();
            resolve();
          }
        });
      });
    }
  }

  async #recoverPersistedJob(job: MediaJob): Promise<void> {
    const recovering = freezeJob({
      ...job,
      status: 'recovering',
      updatedAt: this.#timestamp(),
      recoveryNote:
        'The previous browser session ended before this job reached a terminal state.',
    });
    this.#jobs.set(job.id, recovering);
    await this.#persist(recovering);
    const definition = await this.#recoveryProvider?.restore(recovering);
    if (definition) {
      validateDefinition(definition);
      if (definition.id !== job.id) {
        throw new TypeError('Recovered job definition has a mismatched ID');
      }
      this.#definitions.set(job.id, freezeDefinition(definition));
      await this.#update(job.id, {
        status: 'queued',
        progress: 0,
        startedAt: undefined,
        completedAt: undefined,
        error: undefined,
        steps: definition.steps.map((step) => ({
          id: step.id,
          name: step.name,
          weight: step.weight,
          status: 'queued',
          progress: 0,
          planId: step.plan.id,
        })),
      });
      return;
    }
    await this.#update(job.id, {
      status: 'failed',
      completedAt: this.#timestamp(),
      error: {
        code: 'session-interrupted',
        message:
          'The job was interrupted and its source handles or command plans could not be restored.',
        recoverable: true,
      },
      steps: job.steps.map((step) =>
        step.status === 'completed'
          ? step
          : {
              ...step,
              status: 'failed',
              progress: Math.min(step.progress, 0.999),
            }
      ),
    });
  }

  async #run(definition: MediaJobDefinition): Promise<void> {
    const id = definition.id;
    this.#activeJobId = id;
    this.#activeAbort = new AbortController();
    const completedResults: JobExecutionResult[] = [];
    this.#emit();
    try {
      const existing = this.#requireJob(id);
      await this.#update(id, {
        status: 'preparing',
        startedAt: existing.startedAt ?? this.#timestamp(),
      });
      await this.#executor.prepare?.(
        this.#requireJob(id),
        this.#activeAbort.signal
      );
      for (let index = 0; index < definition.steps.length; index += 1) {
        this.#throwIfCancelling(id);
        const definitionStep = definition.steps[index];
        if (!definitionStep) {
          continue;
        }
        await this.#setStep(id, index, { status: 'running', progress: 0 });
        await this.#update(id, { status: 'running' });
        let result = await this.#executor.execute(definitionStep.plan, {
          signal: this.#activeAbort.signal,
          onProgress: (update) => {
            if (update.progress !== undefined) {
              this.#setStepProgress(id, index, update.progress);
            }
          },
          onLog: () => {
            // Engine-specific log buffers own full logs. The queue deliberately
            // persists only bounded job metadata.
          },
        });
        this.#throwIfCancelling(id);
        if (this.#executor.readOutputs) {
          await this.#update(id, { status: 'reading-output' });
          result = await this.#executor.readOutputs(
            definitionStep.plan,
            result,
            this.#activeAbort.signal
          );
        }
        completedResults.push(result);
        await this.#setStep(id, index, { status: 'completed', progress: 1 });
      }
      await this.#executor.cleanup(id);
      this.#results.set(id, Object.freeze(completedResults));
      await this.#update(id, {
        status: 'completed',
        progress: 1,
        completedAt: this.#timestamp(),
      });
    } catch (error) {
      const cancellation = this.#cancellationTasks.get(id);
      if (cancellation) {
        await cancellation;
      } else {
        await this.#handleFailure(id, error);
      }
    } finally {
      if (this.#activeJobId === id) {
        this.#activeJobId = undefined;
        this.#activeAbort = undefined;
      }
      this.#emit();
      this.#schedule();
    }
  }

  async #cancelActive(id: string): Promise<void> {
    this.#results.delete(id);
    await this.#update(id, {
      status: 'cancelling',
      steps: this.#requireJob(id).steps.map((step) =>
        step.status === 'queued' || step.status === 'running'
          ? {
              ...step,
              status: 'cancelled',
              progress: Math.min(step.progress, 0.999),
            }
          : step
      ),
    });
    this.#activeAbort?.abort(new DOMException('Job cancelled', 'AbortError'));
    try {
      await this.#executor.cancelCurrentJob();
      try {
        await this.#executor.cleanup(id);
      } catch {
        // Termination may already remove the virtual filesystem. Recovery below
        // is authoritative before another job may start.
      }
      await this.#update(id, { status: 'recovering' });
      await this.#executor.recoverAfterCancellation();
      await this.#update(id, {
        status: 'cancelled',
        completedAt: this.#timestamp(),
      });
      this.#results.delete(id);
    } catch (error) {
      await this.#update(id, {
        status: 'failed',
        completedAt: this.#timestamp(),
        error: toMediaJobError(error, 'cancellation-recovery-failed', false),
      });
    }
  }

  async #handleFailure(id: string, error: unknown): Promise<void> {
    this.#results.delete(id);
    try {
      await this.#executor.cleanup(id);
    } catch (cleanupError) {
      await this.#update(id, {
        status: 'recovering',
        recoveryNote:
          'Cleanup failed. The execution engine is being recreated before the queue continues.',
      });
      try {
        await this.#executor.cancelCurrentJob();
        await this.#executor.recoverAfterCancellation();
      } catch {
        error = new AggregateError(
          [error, cleanupError],
          'Job and engine cleanup both failed'
        );
      }
    }
    const job = this.#requireJob(id);
    await this.#update(id, {
      status: 'failed',
      completedAt: this.#timestamp(),
      error: toMediaJobError(error, 'execution-failed', true),
      steps: job.steps.map((step) =>
        step.status === 'running'
          ? {
              ...step,
              status: 'failed',
              progress: Math.min(step.progress, 0.999),
            }
          : step
      ),
    });
  }

  #setStepProgress(id: string, index: number, progress: number): void {
    const job = this.#jobs.get(id);
    if (!job || job.status !== 'running') {
      return;
    }
    const steps = job.steps.map((step, stepIndex) =>
      stepIndex === index
        ? { ...step, progress: Math.min(0.99, Math.max(0, progress)) }
        : step
    );
    const updated = freezeJob({
      ...job,
      steps,
      progress: weightedJobProgress(steps),
      updatedAt: this.#timestamp(),
    });
    this.#jobs.set(id, updated);
    void this.#persist(updated).catch(() => undefined);
    this.#emit();
  }

  async #setStep(
    id: string,
    index: number,
    patch: Pick<MediaJobStep, 'status' | 'progress'>
  ): Promise<void> {
    const job = this.#requireJob(id);
    const steps = job.steps.map((step, stepIndex) =>
      stepIndex === index ? { ...step, ...patch } : step
    );
    await this.#update(id, {
      steps,
      progress: weightedJobProgress(steps),
    });
  }

  #throwIfCancelling(id: string): void {
    const status = this.#requireJob(id).status;
    if (
      status === 'cancelling' ||
      status === 'recovering' ||
      status === 'cancelled'
    ) {
      throw new DOMException('Job cancelled', 'AbortError');
    }
    this.#activeAbort?.signal.throwIfAborted();
  }

  #schedule(): void {
    if (this.#scheduled || this.#activeJobId || !this.#initialized) {
      return;
    }
    this.#scheduled = true;
    queueMicrotask(() => {
      this.#scheduled = false;
      if (this.#activeJobId) {
        return;
      }
      const next = this.list().find(
        (job) => job.status === 'queued' && this.#definitions.has(job.id)
      );
      const definition = next ? this.#definitions.get(next.id) : undefined;
      if (definition) {
        void this.#run(definition);
      }
    });
  }

  async #update(
    id: string,
    patch: Partial<Omit<MediaJob, 'id' | 'createdAt' | 'updatedAt'>>
  ): Promise<MediaJob> {
    const current = this.#requireJob(id);
    const withOptionalFields = { ...current, ...patch };
    if (patch.startedAt === undefined && 'startedAt' in patch) {
      delete (withOptionalFields as { startedAt?: string }).startedAt;
    }
    if (patch.completedAt === undefined && 'completedAt' in patch) {
      delete (withOptionalFields as { completedAt?: string }).completedAt;
    }
    if (patch.error === undefined && 'error' in patch) {
      delete (withOptionalFields as { error?: MediaJobError }).error;
    }
    if (patch.recoveryNote === undefined && 'recoveryNote' in patch) {
      delete (withOptionalFields as { recoveryNote?: string }).recoveryNote;
    }
    return this.#replace(
      freezeJob({ ...withOptionalFields, updatedAt: this.#timestamp() })
    );
  }

  async #replace(job: MediaJob): Promise<MediaJob> {
    this.#jobs.set(job.id, job);
    await this.#persist(job);
    this.#emit();
    return job;
  }

  #requireJob(id: string): MediaJob {
    const job = this.#jobs.get(id);
    if (!job) {
      throw new RangeError(`Unknown job: ${id}`);
    }
    return job;
  }

  #timestamp(): string {
    return this.#now().toISOString();
  }

  #emit(): void {
    const jobs = this.list();
    for (const listener of this.#listeners) {
      try {
        listener(jobs, this.#activeJobId);
      } catch {
        // A view listener must not interrupt execution or durable state updates.
      }
    }
  }

  #persist(job: MediaJob): Promise<void> {
    const operation = this.#persistenceTail.then(() => this.#store.put(job));
    this.#persistenceTail = operation.catch(() => undefined);
    return operation;
  }

  #deletePersisted(id: string): Promise<void> {
    const operation = this.#persistenceTail.then(() => this.#store.delete(id));
    this.#persistenceTail = operation.catch(() => undefined);
    return operation;
  }

  #assertInitialized(): void {
    if (!this.#initialized) {
      throw new Error('MediaJobQueue.initialize() must be called first');
    }
  }
}

export function toMediaJobError(
  error: unknown,
  fallbackCode: string,
  recoverable: boolean
): MediaJobError {
  if (isMediaJobError(error)) {
    return Object.freeze({ ...error });
  }
  if (error instanceof Error) {
    return Object.freeze({
      code: fallbackCode,
      message: error.message || error.name,
      recoverable,
      ...(error.stack ? { details: error.stack.slice(0, 4_000) } : {}),
    });
  }
  return Object.freeze({
    code: fallbackCode,
    message: typeof error === 'string' ? error : 'Unknown media job error',
    recoverable,
  });
}

function validateDefinition(definition: MediaJobDefinition): void {
  if (!definition.id.trim() || !definition.operation.trim()) {
    throw new TypeError('Job id and operation are required');
  }
  validateStepWeights(definition.steps);
  if (
    definition.steps.some(
      (step) =>
        !step.name.trim() ||
        !step.plan.id.trim() ||
        step.plan.id !== step.plan.id.trim()
    )
  ) {
    throw new TypeError('Job step names and command plan IDs are required');
  }
}

function freezeDefinition(definition: MediaJobDefinition): MediaJobDefinition {
  return Object.freeze({
    ...definition,
    steps: Object.freeze(
      definition.steps.map((step) => Object.freeze({ ...step }))
    ),
  });
}

function freezeJob(job: MediaJob): MediaJob {
  return Object.freeze({
    ...job,
    steps: Object.freeze(job.steps.map((step) => Object.freeze({ ...step }))),
    ...(job.error ? { error: Object.freeze({ ...job.error }) } : {}),
  });
}

function isTerminalStatus(status: MediaJobStatus): boolean {
  return (
    status === 'completed' || status === 'failed' || status === 'cancelled'
  );
}

function isInterruptedStatus(status: MediaJobStatus): boolean {
  return (
    status === 'queued' ||
    status === 'preparing' ||
    status === 'running' ||
    status === 'reading-output' ||
    status === 'cancelling' ||
    status === 'recovering'
  );
}

function isMediaJobError(value: unknown): value is MediaJobError {
  if (typeof value !== 'object' || value === null) {
    return false;
  }
  const candidate = value as Partial<MediaJobError>;
  return (
    typeof candidate.code === 'string' &&
    typeof candidate.message === 'string' &&
    typeof candidate.recoverable === 'boolean'
  );
}
