import type {
  FFmpegCommandPlan,
  PlannedOutput,
} from '../commands/command-plan';
import { MAX_GENERATED_OUTPUT_FILES } from '../limits';
import { blobWithOutputMimeType, normalizeOutputMimeType } from './mime';
import { safeExportFileName, uniqueExportFileNames } from './output-name';

export const DEFAULT_RESULT_COLLECTION_LIMITS = Object.freeze({
  maxResults: MAX_GENERATED_OUTPUT_FILES,
  maxSingleResultBytes: 1024 * 1024 * 1024,
  maxTotalBytes: 1024 * 1024 * 1024,
});

export interface ResultCollectionLimits {
  readonly maxResults: number;
  readonly maxSingleResultBytes: number;
  readonly maxTotalBytes: number;
}

export interface ExportResultInput {
  readonly id: string;
  readonly planId: string;
  readonly outputId: string;
  readonly fileName: string;
  readonly blob: Blob;
  readonly mimeType?: string;
  readonly role?: PlannedOutput['role'];
  readonly timeRange?: PlannedOutput['timeRange'];
  readonly createdAt?: string;
}

export interface ExportResult {
  readonly id: string;
  readonly planId: string;
  readonly outputId: string;
  readonly fileName: string;
  readonly blob: Blob;
  readonly mimeType: string;
  readonly role: PlannedOutput['role'];
  readonly timeRange?: PlannedOutput['timeRange'];
  readonly size: number;
  readonly createdAt: string;
}

export interface ObjectUrlApi {
  createObjectURL(blob: Blob): string;
  revokeObjectURL(url: string): void;
}

export interface ResultCollectionOptions {
  readonly limits?: Partial<ResultCollectionLimits>;
  readonly objectUrlApi?: ObjectUrlApi;
  readonly now?: () => Date;
}

export type CommandOutputData = Blob | Uint8Array;

/**
 * Owns browser-memory output Blobs and every object URL created for them.
 * Removing, replacing, or clearing a result always revokes its preview URL.
 */
export class ExportResultCollection {
  readonly #limits: ResultCollectionLimits;
  readonly #urlApi: ObjectUrlApi;
  readonly #now: () => Date;
  readonly #results = new Map<string, ExportResult>();
  readonly #objectUrls = new Map<string, string>();
  #totalBytes = 0;
  #disposed = false;

  constructor(options: ResultCollectionOptions = {}) {
    this.#limits = resolveLimits(options.limits);
    this.#urlApi = options.objectUrlApi ?? defaultObjectUrlApi();
    this.#now = options.now ?? (() => new Date());
  }

  get size(): number {
    return this.#results.size;
  }

  get totalBytes(): number {
    return this.#totalBytes;
  }

  get limits(): ResultCollectionLimits {
    return this.#limits;
  }

  list(): readonly ExportResult[] {
    this.#assertActive();
    return Object.freeze([...this.#results.values()]);
  }

  get(id: string): ExportResult | undefined {
    this.#assertActive();
    return this.#results.get(id);
  }

  add(input: ExportResultInput): ExportResult {
    const result = this.addMany([input])[0];
    if (!result) {
      throw new Error('The result could not be added.');
    }
    return result;
  }

  /**
   * Adds a batch atomically: every ID, timestamp and limit is validated before
   * the collection retains the first Blob.
   */
  addMany(inputs: readonly ExportResultInput[]): readonly ExportResult[] {
    this.#assertActive();
    if (inputs.length === 0) {
      return Object.freeze([]);
    }
    const inputIds = new Set<string>();
    let addedBytes = 0;
    for (const input of inputs) {
      validateIdentifier(input.id, 'Result ID');
      validateTimeRange(input.timeRange);
      if (this.#results.has(input.id) || inputIds.has(input.id)) {
        throw new RangeError(`A result with ID "${input.id}" already exists.`);
      }
      inputIds.add(input.id);
      this.#assertSingleResultSize(input.blob.size);
      addedBytes += input.blob.size;
      if (!Number.isSafeInteger(addedBytes)) {
        throw new RangeError(
          'Combined result size exceeds safe integer range.'
        );
      }
    }
    this.#assertCountAndTotalCapacity(inputs.length, addedBytes, 0);

    const existingNames = [...this.#results.values()].map(
      (result) => result.fileName
    );
    const uniqueNames = uniqueExportFileNames([
      ...existingNames,
      ...inputs.map((input) =>
        safeExportFileName(input.fileName, { fallback: 'output' })
      ),
    ]).slice(existingNames.length);
    const results = inputs.map((input, index) =>
      this.#createResult(input, undefined, uniqueNames[index])
    );
    for (const result of results) {
      this.#results.set(result.id, result);
    }
    this.#totalBytes += addedBytes;
    return Object.freeze(results);
  }

  replace(input: ExportResultInput): ExportResult {
    this.#assertActive();
    validateIdentifier(input.id, 'Result ID');
    validateTimeRange(input.timeRange);
    const existing = this.#results.get(input.id);
    this.#assertSingleResultSize(input.blob.size);
    this.#assertCountAndTotalCapacity(
      existing ? 0 : 1,
      input.blob.size,
      existing?.size ?? 0
    );
    const result = this.#createResult(input, input.id);
    this.#revokeObjectUrl(input.id);
    this.#results.set(input.id, result);
    this.#totalBytes = this.#totalBytes - (existing?.size ?? 0) + result.size;
    return result;
  }

  objectUrl(id: string): string {
    this.#assertActive();
    const result = this.#results.get(id);
    if (!result) {
      throw new RangeError(`Unknown result ID "${id}".`);
    }
    const existing = this.#objectUrls.get(id);
    if (existing) {
      return existing;
    }
    const url = this.#urlApi.createObjectURL(result.blob);
    this.#objectUrls.set(id, url);
    return url;
  }

  revokeObjectUrl(id: string): boolean {
    this.#assertActive();
    return this.#revokeObjectUrl(id);
  }

  remove(id: string): boolean {
    this.#assertActive();
    const result = this.#results.get(id);
    if (!result) {
      return false;
    }
    this.#revokeObjectUrl(id);
    this.#results.delete(id);
    this.#totalBytes -= result.size;
    return true;
  }

  clear(): void {
    this.#assertActive();
    this.#clear();
  }

  /**
   * Permanently releases the collection. Calling read or mutation methods
   * afterwards throws, making use-after-cleanup visible during development.
   */
  dispose(): void {
    if (this.#disposed) {
      return;
    }
    this.#clear();
    this.#disposed = true;
  }

  #createResult(
    input: ExportResultInput,
    replacedId?: string,
    assignedFileName?: string
  ): ExportResult {
    validateIdentifier(input.planId, 'Plan ID');
    validateIdentifier(input.outputId, 'Output ID');
    const existingNames = [...this.#results.values()]
      .filter((result) => result.id !== replacedId)
      .map((result) => result.fileName);
    const [generatedFileName] = uniqueExportFileNames([
      ...existingNames,
      safeExportFileName(input.fileName, { fallback: 'output' }),
    ]).slice(-1);
    const fileName = assignedFileName ?? generatedFileName;
    const createdAt = normalizeTimestamp(
      input.createdAt ?? this.#now().toISOString()
    );
    const mimeType = normalizeOutputMimeType(
      input.mimeType ?? input.blob.type,
      fileName
    );

    return Object.freeze({
      id: input.id,
      planId: input.planId,
      outputId: input.outputId,
      fileName: fileName ?? 'output',
      blob:
        input.blob.type === mimeType
          ? input.blob
          : blobWithOutputMimeType(input.blob, mimeType, fileName),
      mimeType,
      role: input.role ?? 'media',
      ...(input.timeRange
        ? { timeRange: Object.freeze({ ...input.timeRange }) }
        : {}),
      size: input.blob.size,
      createdAt,
    });
  }

  #assertSingleResultSize(addedBytes: number): void {
    if (!Number.isSafeInteger(addedBytes) || addedBytes < 0) {
      throw new RangeError('Result size must be a non-negative safe integer.');
    }
    if (addedBytes > this.#limits.maxSingleResultBytes) {
      throw new ExportResultLimitError(
        'single-result-bytes',
        addedBytes,
        this.#limits.maxSingleResultBytes
      );
    }
  }

  #assertCountAndTotalCapacity(
    addedCount: number,
    addedBytes: number,
    replacedBytes: number
  ): void {
    if (this.#results.size + addedCount > this.#limits.maxResults) {
      throw new ExportResultLimitError(
        'result-count',
        this.#results.size + addedCount,
        this.#limits.maxResults
      );
    }
    const nextTotal = this.#totalBytes - replacedBytes + addedBytes;
    if (nextTotal > this.#limits.maxTotalBytes) {
      throw new ExportResultLimitError(
        'total-result-bytes',
        nextTotal,
        this.#limits.maxTotalBytes
      );
    }
  }

  #revokeObjectUrl(id: string): boolean {
    const url = this.#objectUrls.get(id);
    if (!url) {
      return false;
    }
    this.#urlApi.revokeObjectURL(url);
    this.#objectUrls.delete(id);
    return true;
  }

  #clear(): void {
    for (const url of this.#objectUrls.values()) {
      this.#urlApi.revokeObjectURL(url);
    }
    this.#objectUrls.clear();
    this.#results.clear();
    this.#totalBytes = 0;
  }

  #assertActive(): void {
    if (this.#disposed) {
      throw new Error('This export result collection has been disposed.');
    }
  }
}

export type ExportResultLimit =
  'result-count' | 'single-result-bytes' | 'total-result-bytes';

export class ExportResultLimitError extends RangeError {
  readonly limit: ExportResultLimit;
  readonly actual: number;
  readonly maximum: number;

  constructor(limit: ExportResultLimit, actual: number, maximum: number) {
    super(
      `Export result limit exceeded: ${limit} is ${actual}, max ${maximum}.`
    );
    this.name = 'ExportResultLimitError';
    this.limit = limit;
    this.actual = actual;
    this.maximum = maximum;
  }
}

/**
 * Converts the output map returned by the job queue into managed browser
 * results. Keys may be the planned output ID or its virtual output path.
 */
export function collectCommandOutputs(
  plan: Pick<FFmpegCommandPlan, 'id' | 'outputs'>,
  outputs: ReadonlyMap<string, CommandOutputData>,
  collection: ExportResultCollection
): readonly ExportResult[] {
  const inputs: ExportResultInput[] = [];
  for (const planned of plan.outputs) {
    const data = outputs.get(planned.id) ?? outputs.get(planned.path);
    if (!data) {
      throw new MissingCommandOutputError(planned.id, planned.path);
    }
    const blob = blobWithOutputMimeType(
      data instanceof Blob ? data : Uint8Array.from(data),
      planned.mediaType,
      planned.fileName
    );
    inputs.push({
      id: `${plan.id}:${planned.id}`,
      planId: plan.id,
      outputId: planned.id,
      fileName: planned.fileName,
      blob,
      mimeType: planned.mediaType,
      role: planned.role,
      ...(planned.timeRange ? { timeRange: planned.timeRange } : {}),
    });
  }
  return collection.addMany(inputs);
}

export class MissingCommandOutputError extends Error {
  readonly outputId: string;
  readonly virtualPath: string;

  constructor(outputId: string, virtualPath: string) {
    super(`Expected output "${outputId}" was not returned by the media job.`);
    this.name = 'MissingCommandOutputError';
    this.outputId = outputId;
    this.virtualPath = virtualPath;
  }
}

function resolveLimits(
  overrides: Partial<ResultCollectionLimits> | undefined
): ResultCollectionLimits {
  const limits = {
    ...DEFAULT_RESULT_COLLECTION_LIMITS,
    ...overrides,
  };
  for (const [name, value] of Object.entries(limits)) {
    if (!Number.isSafeInteger(value) || value < 1) {
      throw new RangeError(`${name} must be a positive safe integer.`);
    }
  }
  if (limits.maxSingleResultBytes > limits.maxTotalBytes) {
    throw new RangeError('maxSingleResultBytes cannot exceed maxTotalBytes.');
  }
  return Object.freeze(limits);
}

function validateTimeRange(range: PlannedOutput['timeRange']): void {
  if (
    range &&
    (!Number.isFinite(range.startSeconds) ||
      range.startSeconds < 0 ||
      !Number.isFinite(range.endSeconds) ||
      range.endSeconds <= range.startSeconds)
  ) {
    throw new RangeError(
      'Result time range must have a finite start before end.'
    );
  }
}

function defaultObjectUrlApi(): ObjectUrlApi {
  if (
    typeof URL.createObjectURL !== 'function' ||
    typeof URL.revokeObjectURL !== 'function'
  ) {
    return {
      createObjectURL: () => {
        throw new Error('Object URLs are not supported in this environment.');
      },
      revokeObjectURL: () => undefined,
    };
  }
  return URL;
}

function validateIdentifier(value: string, label: string): void {
  if (value.length < 1 || value.length > 256 || hasControlCharacters(value)) {
    throw new RangeError(
      `${label} must contain 1 to 256 printable characters.`
    );
  }
}

function hasControlCharacters(value: string): boolean {
  return Array.from(value).some((character) => {
    const codePoint = character.codePointAt(0) ?? 0;
    return codePoint < 32 || codePoint === 127;
  });
}

function normalizeTimestamp(value: string): string {
  const timestamp = new Date(value);
  if (!Number.isFinite(timestamp.valueOf())) {
    throw new RangeError('Result creation time must be a valid timestamp.');
  }
  return timestamp.toISOString();
}
