import { addDurations, validateSourceRange } from '../media/duration';
import {
  chapterSecondsToTicks,
  parseChapterTimeBase,
} from '../media/chapter-time-base';
import type { AvProjectV1 } from './project.types';

export type ProjectValidationIssueCode =
  | 'invalid-type'
  | 'missing-field'
  | 'invalid-value'
  | 'duplicate-id'
  | 'missing-reference'
  | 'invalid-range'
  | 'out-of-bounds'
  | 'unsupported-version';

export interface ProjectValidationIssue {
  code: ProjectValidationIssueCode;
  path: string;
  message: string;
}

export type ProjectValidationResult =
  | {
      valid: true;
      project: AvProjectV1;
      issues: [];
    }
  | {
      valid: false;
      issues: ProjectValidationIssue[];
    };

export class ProjectValidationError extends Error {
  readonly issues: ProjectValidationIssue[];

  constructor(issues: ProjectValidationIssue[]) {
    super(
      issues.length === 1
        ? `Invalid project: ${issues[0]?.message ?? 'unknown error'}`
        : `Invalid project: ${issues.length} validation errors.`
    );
    this.name = 'ProjectValidationError';
    this.issues = issues;
  }
}

export function validateProject(value: unknown): ProjectValidationResult {
  const issues: ProjectValidationIssue[] = [];
  if (!isRecord(value)) {
    issue(issues, 'invalid-type', '$', 'The project must be a JSON object.');
    return { valid: false, issues };
  }

  if (value.schemaVersion !== 1) {
    issue(
      issues,
      'unsupported-version',
      'schemaVersion',
      'Expected project schema version 1.'
    );
  }

  requireNonEmptyString(value.id, 'id', issues);
  requireNonEmptyString(value.name, 'name', issues);
  const createdAt = requireIsoDate(value.createdAt, 'createdAt', issues);
  const updatedAt = requireIsoDate(value.updatedAt, 'updatedAt', issues);
  if (
    createdAt !== undefined &&
    updatedAt !== undefined &&
    updatedAt < createdAt
  ) {
    issue(
      issues,
      'invalid-value',
      'updatedAt',
      'The update time cannot be before the creation time.'
    );
  }

  const { assetIds, sourceDurations } = validateAssets(value.assets, issues);
  const { clipIds, durationSeconds } = validateTimeline(
    value.timeline,
    assetIds,
    sourceDurations,
    issues
  );
  validateOutput(value.output, issues);
  validateMetadata(value.metadata, issues);
  validateChapters(value.chapters, durationSeconds, issues);
  validateSubtitles(value.subtitles, assetIds, issues);
  validateUi(value.ui, assetIds, clipIds, issues);

  if (issues.length > 0) {
    return { valid: false, issues };
  }

  let project: AvProjectV1;
  try {
    project = structuredClone(value) as unknown as AvProjectV1;
  } catch {
    issue(
      issues,
      'invalid-type',
      '$',
      'The project contains a value that cannot be stored in a project document.'
    );
    return { valid: false, issues };
  }

  return {
    valid: true,
    project,
    issues: [],
  };
}

export function assertValidProject(value: unknown): AvProjectV1 {
  const result = validateProject(value);
  if (!result.valid) {
    throw new ProjectValidationError(result.issues);
  }
  return result.project;
}

function validateAssets(
  value: unknown,
  issues: ProjectValidationIssue[]
): {
  assetIds: Set<string>;
  sourceDurations: Map<string, number>;
} {
  const assetIds = new Set<string>();
  const sourceDurations = new Map<string, number>();
  if (!Array.isArray(value)) {
    issue(issues, 'invalid-type', 'assets', 'Assets must be an array.');
    return { assetIds, sourceDurations };
  }

  value.forEach((asset, index) => {
    const path = `assets[${index}]`;
    if (!isRecord(asset)) {
      issue(issues, 'invalid-type', path, 'Each asset must be an object.');
      return;
    }
    const id = requireNonEmptyString(asset.id, `${path}.id`, issues);
    if (id !== undefined) {
      addUniqueId(id, `${path}.id`, assetIds, issues);
    }
    requireNonEmptyString(asset.originalName, `${path}.originalName`, issues);
    requireNumber(asset.size, `${path}.size`, issues, {
      integer: true,
      minimum: 0,
    });
    requireNumber(asset.lastModified, `${path}.lastModified`, issues, {
      integer: true,
      minimum: 0,
    });
    requireString(asset.mimeType, `${path}.mimeType`, issues);
    requireEnum(
      asset.sourceStatus,
      ['attached', 'missing'],
      `${path}.sourceStatus`,
      issues
    );
    if (asset.hash !== undefined) {
      validateSourceHash(asset.hash, `${path}.hash`, issues);
    }
    if (asset.probe !== undefined) {
      validateProbe(asset.probe, `${path}.probe`, issues);
      if (
        id !== undefined &&
        isRecord(asset.probe) &&
        typeof asset.probe.durationSeconds === 'number' &&
        Number.isFinite(asset.probe.durationSeconds) &&
        asset.probe.durationSeconds >= 0
      ) {
        sourceDurations.set(id, asset.probe.durationSeconds);
      }
    }
  });

  return { assetIds, sourceDurations };
}

function validateSourceHash(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): void {
  if (!isRecord(value)) {
    issue(issues, 'invalid-type', path, 'A source hash must be an object.');
    return;
  }
  requireEnum(value.algorithm, ['sha-256'], `${path}.algorithm`, issues);
  const hash = requireNonEmptyString(value.value, `${path}.value`, issues);
  if (hash !== undefined && !/^[a-fA-F0-9]{64}$/.test(hash)) {
    issue(
      issues,
      'invalid-value',
      `${path}.value`,
      'A SHA-256 value must contain 64 hexadecimal characters.'
    );
  }
  requireEnum(value.scope, ['full', 'partial'], `${path}.scope`, issues);
  if (value.bytesHashed !== undefined) {
    requireNumber(value.bytesHashed, `${path}.bytesHashed`, issues, {
      integer: true,
      minimum: 1,
    });
  }
  if (value.scope === 'partial' && value.bytesHashed === undefined) {
    issue(
      issues,
      'missing-field',
      `${path}.bytesHashed`,
      'A partial hash must state how many bytes were hashed.'
    );
  }
}

function validateProbe(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): void {
  if (!isRecord(value)) {
    issue(
      issues,
      'invalid-type',
      path,
      'A normalized probe report must be an object.'
    );
    return;
  }
  for (const field of ['durationSeconds', 'startTimeSeconds', 'bitRate']) {
    if (value[field] !== undefined) {
      requireNumber(value[field], `${path}.${field}`, issues, {
        minimum: field === 'startTimeSeconds' ? undefined : 0,
        integer: field === 'bitRate',
      });
    }
  }
  validateStringArray(value.formatNames, `${path}.formatNames`, issues);
  if (value.formatLongName !== undefined) {
    requireString(value.formatLongName, `${path}.formatLongName`, issues);
  }
  validateStringRecord(value.tags, `${path}.tags`, issues);
  if (!Array.isArray(value.streams)) {
    issue(
      issues,
      'invalid-type',
      `${path}.streams`,
      'Probe streams must be an array.'
    );
  } else {
    const streamIndexes = new Set<number>();
    value.streams.forEach((stream, index) => {
      const streamPath = `${path}.streams[${index}]`;
      if (!isRecord(stream)) {
        issue(
          issues,
          'invalid-type',
          streamPath,
          'Each probe stream must be an object.'
        );
        return;
      }
      const streamIndex = requireNumber(
        stream.index,
        `${streamPath}.index`,
        issues,
        { integer: true, minimum: 0 }
      );
      if (streamIndex !== undefined) {
        if (streamIndexes.has(streamIndex)) {
          issue(
            issues,
            'duplicate-id',
            `${streamPath}.index`,
            `Probe stream index ${streamIndex} is duplicated.`
          );
        }
        streamIndexes.add(streamIndex);
      }
      requireEnum(
        stream.type,
        ['video', 'audio', 'subtitle', 'attachment', 'data', 'unknown'],
        `${streamPath}.type`,
        issues
      );
      validateStringRecord(stream.tags, `${streamPath}.tags`, issues);
      validateBooleanRecord(
        stream.disposition,
        `${streamPath}.disposition`,
        issues
      );
      for (const field of [
        'codecName',
        'codecLongName',
        'profile',
        'language',
        'title',
        'pixelFormat',
        'timeBase',
        'sampleAspectRatio',
        'displayAspectRatio',
        'channelLayout',
        'sampleFormat',
      ]) {
        if (stream[field] !== undefined) {
          requireString(stream[field], `${streamPath}.${field}`, issues);
        }
      }
      for (const field of ['durationSeconds', 'bitRate']) {
        if (stream[field] !== undefined) {
          requireNumber(stream[field], `${streamPath}.${field}`, issues, {
            minimum: 0,
            integer: field === 'bitRate',
          });
        }
      }
      for (const field of [
        'width',
        'height',
        'codedWidth',
        'codedHeight',
        'sampleRate',
        'channels',
      ]) {
        if (stream[field] !== undefined) {
          requireNumber(stream[field], `${streamPath}.${field}`, issues, {
            integer: true,
            minimum: 1,
          });
        }
      }
      if (stream.frameRate !== undefined) {
        requireNumber(stream.frameRate, `${streamPath}.frameRate`, issues, {
          exclusiveMinimum: 0,
          maximum: 1_000,
        });
      }
      if (stream.rotationDegrees !== undefined) {
        requireNumber(
          stream.rotationDegrees,
          `${streamPath}.rotationDegrees`,
          issues,
          {
            exclusiveMinimum: -180,
            maximum: 180,
          }
        );
      }
    });
  }
  if (!Array.isArray(value.chapters)) {
    issue(
      issues,
      'invalid-type',
      `${path}.chapters`,
      'Probe chapters must be an array.'
    );
  } else {
    value.chapters.forEach((chapter, index) => {
      const chapterPath = `${path}.chapters[${index}]`;
      if (!isRecord(chapter)) {
        issue(
          issues,
          'invalid-type',
          chapterPath,
          'Each probe chapter must be an object.'
        );
        return;
      }
      requireNumber(chapter.id, `${chapterPath}.id`, issues, {
        integer: true,
        minimum: 0,
      });
      const start = requireNumber(
        chapter.startSeconds,
        `${chapterPath}.startSeconds`,
        issues,
        { minimum: 0 }
      );
      const end = requireNumber(
        chapter.endSeconds,
        `${chapterPath}.endSeconds`,
        issues,
        { minimum: 0 }
      );
      if (start !== undefined && end !== undefined && end < start) {
        issue(
          issues,
          'invalid-range',
          chapterPath,
          'A probe chapter end cannot be before its start.'
        );
      }
      if (chapter.timeBase !== undefined) {
        requireString(chapter.timeBase, `${chapterPath}.timeBase`, issues);
      }
      if (chapter.title !== undefined) {
        requireString(chapter.title, `${chapterPath}.title`, issues);
      }
      validateStringRecord(chapter.tags, `${chapterPath}.tags`, issues);
    });
  }
  if (!Array.isArray(value.warnings)) {
    issue(
      issues,
      'invalid-type',
      `${path}.warnings`,
      'Probe warnings must be an array.'
    );
  } else {
    value.warnings.forEach((warning, index) => {
      const warningPath = `${path}.warnings[${index}]`;
      if (!isRecord(warning)) {
        issue(
          issues,
          'invalid-type',
          warningPath,
          'Each probe warning must be an object.'
        );
        return;
      }
      requireEnum(
        warning.code,
        [
          'invalid-field',
          'invalid-fraction',
          'missing-stream-index',
          'duplicate-stream-index',
          'unknown-stream-type',
          'invalid-stream',
          'invalid-chapter',
        ],
        `${warningPath}.code`,
        issues
      );
      requireNonEmptyString(warning.path, `${warningPath}.path`, issues);
      requireNonEmptyString(warning.message, `${warningPath}.message`, issues);
      if (warning.received !== undefined) {
        requireString(warning.received, `${warningPath}.received`, issues);
      }
    });
  }
}

function validateTimeline(
  value: unknown,
  assetIds: Set<string>,
  sourceDurations: ReadonlyMap<string, number>,
  issues: ProjectValidationIssue[]
): { clipIds: Set<string>; durationSeconds: number } {
  const clipIds = new Set<string>();
  const durations: number[] = [];
  if (!Array.isArray(value)) {
    issue(issues, 'invalid-type', 'timeline', 'Timeline must be an array.');
    return { clipIds, durationSeconds: 0 };
  }

  value.forEach((clip, index) => {
    const path = `timeline[${index}]`;
    if (!isRecord(clip)) {
      issue(issues, 'invalid-type', path, 'Each clip must be an object.');
      return;
    }
    const id = requireNonEmptyString(clip.id, `${path}.id`, issues);
    if (id !== undefined) {
      addUniqueId(id, `${path}.id`, clipIds, issues);
    }
    const assetId = requireNonEmptyString(
      clip.assetId,
      `${path}.assetId`,
      issues
    );
    if (assetId !== undefined && !assetIds.has(assetId)) {
      issue(
        issues,
        'missing-reference',
        `${path}.assetId`,
        `Clip references missing asset "${assetId}".`
      );
    }
    const sourceIn = requireNumber(
      clip.sourceInSeconds,
      `${path}.sourceInSeconds`,
      issues,
      { minimum: 0 }
    );
    const sourceOut = requireNumber(
      clip.sourceOutSeconds,
      `${path}.sourceOutSeconds`,
      issues,
      { minimum: 0 }
    );
    if (sourceIn !== undefined && sourceOut !== undefined) {
      const range = validateSourceRange(
        sourceIn,
        sourceOut,
        assetId === undefined ? undefined : sourceDurations.get(assetId)
      );
      if (!range.valid) {
        for (const rangeIssue of range.issues) {
          issue(issues, 'invalid-range', path, rangeIssue.message);
        }
      } else if (range.durationSeconds !== undefined) {
        durations.push(range.durationSeconds);
      }
    }
    if (clip.crop !== undefined) {
      validateCrop(clip.crop, `${path}.crop`, issues);
    }
    if (clip.resize !== undefined) {
      validateResize(clip.resize, `${path}.resize`, issues);
    }
    if (clip.rotation !== undefined) {
      requireEnum(clip.rotation, [0, 90, 180, 270], `${path}.rotation`, issues);
    }
    if (clip.frameRate !== undefined) {
      requireNumber(clip.frameRate, `${path}.frameRate`, issues, {
        exclusiveMinimum: 0,
        maximum: 1_000,
      });
    }
    if (clip.audio !== undefined) {
      validateAudioSettings(
        clip.audio,
        `${path}.audio`,
        sourceIn,
        sourceOut,
        issues
      );
    }
    if (clip.video !== undefined) {
      validateVideoSettings(
        clip.video,
        `${path}.video`,
        sourceIn,
        sourceOut,
        issues
      );
    }
  });

  return { clipIds, durationSeconds: addDurations(durations) };
}

function validateCrop(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): void {
  if (!isRecord(value)) {
    issue(issues, 'invalid-type', path, 'Crop settings must be an object.');
    return;
  }
  requireNumber(value.x, `${path}.x`, issues, { integer: true, minimum: 0 });
  requireNumber(value.y, `${path}.y`, issues, { integer: true, minimum: 0 });
  requireNumber(value.width, `${path}.width`, issues, {
    integer: true,
    minimum: 1,
  });
  requireNumber(value.height, `${path}.height`, issues, {
    integer: true,
    minimum: 1,
  });
}

function validateResize(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): void {
  if (!isRecord(value)) {
    issue(issues, 'invalid-type', path, 'Resize settings must be an object.');
    return;
  }
  if (value.width === undefined && value.height === undefined) {
    issue(
      issues,
      'missing-field',
      path,
      'Resize settings require a width, a height, or both.'
    );
  }
  if (value.width !== undefined) {
    requireNumber(value.width, `${path}.width`, issues, {
      integer: true,
      minimum: 1,
    });
  }
  if (value.height !== undefined) {
    requireNumber(value.height, `${path}.height`, issues, {
      integer: true,
      minimum: 1,
    });
  }
  requireEnum(value.mode, ['fit', 'fill', 'stretch'], `${path}.mode`, issues);
  if (value.allowUpscale !== undefined) {
    requireBoolean(value.allowUpscale, `${path}.allowUpscale`, issues);
  }
  if (value.algorithm !== undefined) {
    requireEnum(
      value.algorithm,
      ['fast_bilinear', 'bilinear', 'bicubic', 'lanczos'],
      `${path}.algorithm`,
      issues
    );
  }
  if (
    value.paddingColor !== undefined &&
    (typeof value.paddingColor !== 'string' ||
      !/^(?:#[0-9a-f]{6}|[a-z]{3,20})$/iu.test(value.paddingColor))
  ) {
    issue(
      issues,
      'invalid-value',
      `${path}.paddingColor`,
      'Padding colour must be a named colour or #RRGGBB.'
    );
  }
}

function validateAudioSettings(
  value: unknown,
  path: string,
  sourceIn: number | undefined,
  sourceOut: number | undefined,
  issues: ProjectValidationIssue[]
): void {
  if (!isRecord(value)) {
    issue(issues, 'invalid-type', path, 'Audio settings must be an object.');
    return;
  }
  requireBoolean(value.enabled, `${path}.enabled`, issues);
  if (value.gainDb !== undefined) {
    requireNumber(value.gainDb, `${path}.gainDb`, issues, {
      minimum: -120,
      maximum: 120,
    });
  }
  if (value.normalization !== undefined) {
    if (!isRecord(value.normalization)) {
      issue(
        issues,
        'invalid-type',
        `${path}.normalization`,
        'Normalization settings must be an object.'
      );
    } else {
      requireNumber(
        value.normalization.targetLufs,
        `${path}.normalization.targetLufs`,
        issues,
        { minimum: -70, maximum: 0 }
      );
      requireNumber(
        value.normalization.truePeakDb,
        `${path}.normalization.truePeakDb`,
        issues,
        { minimum: -20, maximum: 0 }
      );
      if (value.normalization.loudnessRangeLufs !== undefined) {
        requireNumber(
          value.normalization.loudnessRangeLufs,
          `${path}.normalization.loudnessRangeLufs`,
          issues,
          { minimum: 1, maximum: 50 }
        );
      }
    }
  }
  validateFades(value, path, sourceIn, sourceOut, issues);
}

function validateVideoSettings(
  value: unknown,
  path: string,
  sourceIn: number | undefined,
  sourceOut: number | undefined,
  issues: ProjectValidationIssue[]
): void {
  if (!isRecord(value)) {
    issue(issues, 'invalid-type', path, 'Video settings must be an object.');
    return;
  }
  requireBoolean(value.enabled, `${path}.enabled`, issues);
  validateFades(value, path, sourceIn, sourceOut, issues);
}

function validateFades(
  value: Record<string, unknown>,
  path: string,
  sourceIn: number | undefined,
  sourceOut: number | undefined,
  issues: ProjectValidationIssue[]
): void {
  const fadeIn =
    value.fadeInSeconds === undefined
      ? 0
      : requireNumber(value.fadeInSeconds, `${path}.fadeInSeconds`, issues, {
          minimum: 0,
        });
  const fadeOut =
    value.fadeOutSeconds === undefined
      ? 0
      : requireNumber(value.fadeOutSeconds, `${path}.fadeOutSeconds`, issues, {
          minimum: 0,
        });
  if (value.fadeCurve !== undefined) {
    requireEnum(
      value.fadeCurve,
      ['tri', 'qsin', 'exp'],
      `${path}.fadeCurve`,
      issues
    );
  }
  if (
    fadeIn !== undefined &&
    fadeOut !== undefined &&
    sourceIn !== undefined &&
    sourceOut !== undefined &&
    fadeIn + fadeOut > sourceOut - sourceIn + 1e-6
  ) {
    issue(
      issues,
      'out-of-bounds',
      path,
      'Combined fade duration exceeds the clip duration.'
    );
  }
}

function validateOutput(
  value: unknown,
  issues: ProjectValidationIssue[]
): void {
  if (!isRecord(value)) {
    issue(
      issues,
      'invalid-type',
      'output',
      'Output settings must be an object.'
    );
    return;
  }
  requireNonEmptyString(value.presetId, 'output.presetId', issues);
  requireNonEmptyString(value.fileName, 'output.fileName', issues);
  requireNonEmptyString(value.container, 'output.container', issues);
  requireBoolean(value.videoEnabled, 'output.videoEnabled', issues);
  requireBoolean(value.audioEnabled, 'output.audioEnabled', issues);
  for (const field of ['width', 'height']) {
    if (value[field] !== undefined) {
      requireNumber(value[field], `output.${field}`, issues, {
        integer: true,
        minimum: 2,
        maximum: 8192,
      });
      if (
        typeof value[field] === 'number' &&
        Number.isInteger(value[field]) &&
        value[field] % 2 !== 0
      ) {
        issue(
          issues,
          'invalid-value',
          `output.${field}`,
          'Timeline output dimensions must be even.'
        );
      }
    }
  }
  if (value.frameRate !== undefined) {
    requireNumber(value.frameRate, 'output.frameRate', issues, {
      exclusiveMinimum: 0,
      maximum: 240,
    });
  }
  if (value.sampleRate !== undefined) {
    requireNumber(value.sampleRate, 'output.sampleRate', issues, {
      integer: true,
      minimum: 8_000,
      maximum: 192_000,
    });
  }
  if (value.channelLayout !== undefined) {
    requireEnum(
      value.channelLayout,
      ['mono', 'stereo', '5.1'],
      'output.channelLayout',
      issues
    );
  }
  if (value.missingAudioPolicy !== undefined) {
    requireEnum(
      value.missingAudioPolicy,
      ['insert-silence', 'drop-all', 'reject'],
      'output.missingAudioPolicy',
      issues
    );
  }
  if (value.videoEnabled === false && value.audioEnabled === false) {
    issue(
      issues,
      'invalid-value',
      'output',
      'At least one output stream type must be enabled.'
    );
  }
}

function validateMetadata(
  value: unknown,
  issues: ProjectValidationIssue[]
): void {
  if (!isRecord(value)) {
    issue(
      issues,
      'invalid-type',
      'metadata',
      'Project metadata must be an object.'
    );
    return;
  }
  for (const field of ['title', 'artist', 'album', 'comment']) {
    if (value[field] !== undefined) {
      requireString(value[field], `metadata.${field}`, issues);
    }
  }
  validateStringRecord(value.custom, 'metadata.custom', issues);
}

function validateChapters(
  value: unknown,
  outputDurationSeconds: number,
  issues: ProjectValidationIssue[]
): void {
  const ids = new Set<string>();
  if (!Array.isArray(value)) {
    issue(issues, 'invalid-type', 'chapters', 'Chapters must be an array.');
    return;
  }
  let previousEnd = 0;
  value.forEach((chapter, index) => {
    const path = `chapters[${index}]`;
    if (!isRecord(chapter)) {
      issue(issues, 'invalid-type', path, 'Each chapter must be an object.');
      return;
    }
    const id = requireNonEmptyString(chapter.id, `${path}.id`, issues);
    if (id !== undefined) {
      addUniqueId(id, `${path}.id`, ids, issues);
    }
    const start = requireNumber(
      chapter.startSeconds,
      `${path}.startSeconds`,
      issues,
      { minimum: 0 }
    );
    const end = requireNumber(
      chapter.endSeconds,
      `${path}.endSeconds`,
      issues,
      { minimum: 0 }
    );
    requireNonEmptyString(chapter.title, `${path}.title`, issues);
    const timeBase =
      chapter.timeBase === undefined
        ? undefined
        : parseChapterTimeBase(chapter.timeBase);
    if (chapter.timeBase !== undefined && timeBase === undefined) {
      issue(
        issues,
        'invalid-value',
        `${path}.timeBase`,
        'Chapter time base must be a positive integer fraction such as 1/1000.'
      );
    }
    if (start !== undefined && end !== undefined) {
      if (end <= start) {
        issue(
          issues,
          'invalid-range',
          path,
          'A chapter end must be after its start.'
        );
      }
      if (start < previousEnd - 1e-6) {
        issue(
          issues,
          'invalid-range',
          `${path}.startSeconds`,
          'Chapters must be ordered and may not overlap.'
        );
      }
      if (end > outputDurationSeconds + 1e-6) {
        issue(
          issues,
          'out-of-bounds',
          `${path}.endSeconds`,
          'The chapter exceeds the timeline duration.'
        );
      }
      if (timeBase !== undefined) {
        const startTick = chapterSecondsToTicks(start, timeBase);
        const endTick = chapterSecondsToTicks(end, timeBase);
        if (
          startTick === undefined ||
          endTick === undefined ||
          endTick <= startTick
        ) {
          issue(
            issues,
            'invalid-value',
            `${path}.timeBase`,
            'Chapter time base is too coarse or large for this chapter range.'
          );
        }
      }
      previousEnd = Math.max(previousEnd, end);
    }
    if (chapter.metadata !== undefined) {
      validateStringRecord(chapter.metadata, `${path}.metadata`, issues);
    }
  });
}

function validateSubtitles(
  value: unknown,
  assetIds: Set<string>,
  issues: ProjectValidationIssue[]
): void {
  const ids = new Set<string>();
  if (!Array.isArray(value)) {
    issue(issues, 'invalid-type', 'subtitles', 'Subtitles must be an array.');
    return;
  }
  let burnInCount = 0;
  value.forEach((track, index) => {
    const path = `subtitles[${index}]`;
    if (!isRecord(track)) {
      issue(
        issues,
        'invalid-type',
        path,
        'Each subtitle track must be an object.'
      );
      return;
    }
    const id = requireNonEmptyString(track.id, `${path}.id`, issues);
    if (id !== undefined) {
      addUniqueId(id, `${path}.id`, ids, issues);
    }
    if (track.assetId !== undefined) {
      const assetId = requireNonEmptyString(
        track.assetId,
        `${path}.assetId`,
        issues
      );
      if (assetId !== undefined && !assetIds.has(assetId)) {
        issue(
          issues,
          'missing-reference',
          `${path}.assetId`,
          `Subtitle track references missing asset "${assetId}".`
        );
      }
    }
    for (const field of ['sourceName', 'language', 'title']) {
      if (track[field] !== undefined) {
        requireString(track[field], `${path}.${field}`, issues);
      }
    }
    if (track.sourceFormat !== undefined) {
      requireEnum(
        track.sourceFormat,
        ['srt', 'vtt', 'ass'],
        `${path}.sourceFormat`,
        issues
      );
    }
    if (
      track.offsetSeconds !== undefined &&
      (typeof track.offsetSeconds !== 'number' ||
        !Number.isFinite(track.offsetSeconds) ||
        Math.abs(track.offsetSeconds) > 86_400)
    ) {
      issue(
        issues,
        'invalid-value',
        `${path}.offsetSeconds`,
        'Subtitle time shift must be a finite number within 24 hours.'
      );
    }
    for (const field of ['default', 'forced']) {
      if (track[field] !== undefined && typeof track[field] !== 'boolean') {
        issue(
          issues,
          'invalid-type',
          `${path}.${field}`,
          `${field} must be a boolean.`
        );
      }
    }
    requireEnum(
      track.mode,
      ['exclude', 'mux', 'burn-in'],
      `${path}.mode`,
      issues
    );
    if (track.mode === 'burn-in') {
      burnInCount += 1;
    }
    if (track.sourceStatus !== undefined) {
      requireEnum(
        track.sourceStatus,
        ['attached', 'missing'],
        `${path}.sourceStatus`,
        issues
      );
    }
  });
  if (burnInCount > 1) {
    issue(
      issues,
      'invalid-value',
      'subtitles',
      'Only one subtitle track can be burned in at a time.'
    );
  }
}

function validateUi(
  value: unknown,
  assetIds: Set<string>,
  clipIds: Set<string>,
  issues: ProjectValidationIssue[]
): void {
  if (value === undefined) {
    return;
  }
  if (!isRecord(value)) {
    issue(issues, 'invalid-type', 'ui', 'UI state must be an object.');
    return;
  }
  if (value.selectedAssetId !== undefined) {
    const id = requireNonEmptyString(
      value.selectedAssetId,
      'ui.selectedAssetId',
      issues
    );
    if (id !== undefined && !assetIds.has(id)) {
      issue(
        issues,
        'missing-reference',
        'ui.selectedAssetId',
        'The selected asset does not exist.'
      );
    }
  }
  if (value.selectedClipId !== undefined) {
    const id = requireNonEmptyString(
      value.selectedClipId,
      'ui.selectedClipId',
      issues
    );
    if (id !== undefined && !clipIds.has(id)) {
      issue(
        issues,
        'missing-reference',
        'ui.selectedClipId',
        'The selected clip does not exist.'
      );
    }
  }
  if (value.inspectorPanel !== undefined) {
    requireEnum(
      value.inspectorPanel,
      ['media', 'clip', 'output'],
      'ui.inspectorPanel',
      issues
    );
  }
  if (value.timelineZoom !== undefined) {
    requireNumber(value.timelineZoom, 'ui.timelineZoom', issues, {
      exclusiveMinimum: 0,
      maximum: 100,
    });
  }
}

interface NumberRequirements {
  integer?: boolean;
  minimum?: number;
  exclusiveMinimum?: number;
  maximum?: number;
}

function requireNumber(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[],
  requirements: NumberRequirements = {}
): number | undefined {
  if (typeof value !== 'number' || !Number.isFinite(value)) {
    issue(issues, 'invalid-type', path, 'Expected a finite number.');
    return undefined;
  }
  if (requirements.integer === true && !Number.isSafeInteger(value)) {
    issue(issues, 'invalid-value', path, 'Expected a safe integer.');
  }
  if (requirements.minimum !== undefined && value < requirements.minimum) {
    issue(
      issues,
      'invalid-value',
      path,
      `Expected a value of at least ${requirements.minimum}.`
    );
  }
  if (
    requirements.exclusiveMinimum !== undefined &&
    value <= requirements.exclusiveMinimum
  ) {
    issue(
      issues,
      'invalid-value',
      path,
      `Expected a value greater than ${requirements.exclusiveMinimum}.`
    );
  }
  if (requirements.maximum !== undefined && value > requirements.maximum) {
    issue(
      issues,
      'invalid-value',
      path,
      `Expected a value no greater than ${requirements.maximum}.`
    );
  }
  return value;
}

function requireBoolean(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): boolean | undefined {
  if (typeof value !== 'boolean') {
    issue(issues, 'invalid-type', path, 'Expected a boolean.');
    return undefined;
  }
  return value;
}

function requireString(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): string | undefined {
  if (typeof value !== 'string') {
    issue(issues, 'invalid-type', path, 'Expected a string.');
    return undefined;
  }
  return value;
}

function requireNonEmptyString(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): string | undefined {
  const string = requireString(value, path, issues);
  if (string !== undefined && string.trim() === '') {
    issue(issues, 'invalid-value', path, 'Expected a non-empty string.');
    return undefined;
  }
  return string;
}

function requireIsoDate(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): number | undefined {
  const string = requireNonEmptyString(value, path, issues);
  if (
    string === undefined ||
    !/^\d{4}-\d{2}-\d{2}T/.test(string) ||
    !Number.isFinite(Date.parse(string))
  ) {
    if (string !== undefined) {
      issue(
        issues,
        'invalid-value',
        path,
        'Expected an ISO-8601 date and time.'
      );
    }
    return undefined;
  }
  return Date.parse(string);
}

function requireEnum<T>(
  value: unknown,
  allowed: readonly T[],
  path: string,
  issues: ProjectValidationIssue[]
): T | undefined {
  if (!allowed.includes(value as T)) {
    issue(
      issues,
      'invalid-value',
      path,
      `Expected one of: ${allowed.map(String).join(', ')}.`
    );
    return undefined;
  }
  return value as T;
}

function validateStringArray(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): void {
  if (!Array.isArray(value)) {
    issue(issues, 'invalid-type', path, 'Expected an array of strings.');
    return;
  }
  value.forEach((entry, index) =>
    requireString(entry, `${path}[${index}]`, issues)
  );
}

function validateStringRecord(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): void {
  if (!isRecord(value)) {
    issue(issues, 'invalid-type', path, 'Expected an object of string values.');
    return;
  }
  for (const [key, entry] of Object.entries(value)) {
    if (!isSafeRecordKey(key)) {
      issue(issues, 'invalid-value', `${path}.${key}`, 'Unsafe object key.');
    }
    requireString(entry, `${path}.${key}`, issues);
  }
}

function validateBooleanRecord(
  value: unknown,
  path: string,
  issues: ProjectValidationIssue[]
): void {
  if (!isRecord(value)) {
    issue(
      issues,
      'invalid-type',
      path,
      'Expected an object of boolean values.'
    );
    return;
  }
  for (const [key, entry] of Object.entries(value)) {
    if (!isSafeRecordKey(key)) {
      issue(issues, 'invalid-value', `${path}.${key}`, 'Unsafe object key.');
    }
    requireBoolean(entry, `${path}.${key}`, issues);
  }
}

function addUniqueId(
  id: string,
  path: string,
  ids: Set<string>,
  issues: ProjectValidationIssue[]
): void {
  if (ids.has(id)) {
    issue(issues, 'duplicate-id', path, `ID "${id}" is duplicated.`);
  }
  ids.add(id);
}

function issue(
  issues: ProjectValidationIssue[],
  code: ProjectValidationIssueCode,
  path: string,
  message: string
): void {
  issues.push({ code, path, message });
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isSafeRecordKey(key: string): boolean {
  return key !== '__proto__' && key !== 'prototype' && key !== 'constructor';
}
