import {
  assertMuxerMatchesExtension,
  createPlannedInput,
  createPlannedOutput,
  diagnostic,
  escapeMetadataValue,
  streamMapArguments,
  type SourceReference,
  type StreamSelection,
} from './command-utils';
import { freezeCommandPlan, type FFmpegCommandPlan } from './command-plan';

export type MetadataValue = string | number;

export const EDITABLE_STREAM_DISPOSITIONS = [
  'default',
  'dub',
  'original',
  'comment',
  'lyrics',
  'karaoke',
  'forced',
  'hearing_impaired',
  'visual_impaired',
  'clean_effects',
  'attached_pic',
  'timed_thumbnails',
  'captions',
  'descriptions',
  'metadata',
  'dependent',
  'still_image',
] as const;

export type StreamDisposition = (typeof EDITABLE_STREAM_DISPOSITIONS)[number];

export interface MetadataEdits {
  readonly format?: Readonly<Record<string, MetadataValue | null>>;
  readonly streams?: Readonly<
    Record<number, Readonly<Record<string, MetadataValue | null>>>
  >;
  readonly dispositions?: Readonly<
    Record<number, readonly StreamDisposition[]>
  >;
}

export interface MetadataEditPlanOptions {
  readonly jobId: string;
  readonly source: SourceReference;
  readonly edits: MetadataEdits;
  readonly targetExtension: string;
  readonly targetMuxer: string;
  readonly streamSelection?: StreamSelection;
  readonly sourceMetadataPolicy?: 'copy' | 'remove';
  readonly chapterPolicy?: 'keep' | 'remove';
  readonly expectedDurationSeconds?: number;
  readonly outputFileName?: string;
}

export function metadataArguments(edits: MetadataEdits): readonly string[] {
  const args: string[] = [];
  for (const [key, value] of sortedEntries(edits.format ?? {})) {
    validateMetadataKey(key);
    args.push(
      '-metadata',
      `${key}=${value === null ? '' : escapeMetadataValue(String(value))}`
    );
  }
  const streamEntries = Object.entries(edits.streams ?? {}).sort(
    ([left], [right]) => Number(left) - Number(right)
  );
  for (const [indexText, tags] of streamEntries) {
    const index = parseStreamIndex(indexText);
    for (const [key, value] of sortedEntries(tags)) {
      validateMetadataKey(key);
      args.push(
        `-metadata:s:${index}`,
        `${key}=${value === null ? '' : escapeMetadataValue(String(value))}`
      );
    }
  }
  const dispositionEntries = Object.entries(edits.dispositions ?? {}).sort(
    ([left], [right]) => Number(left) - Number(right)
  );
  for (const [indexText, dispositions] of dispositionEntries) {
    const index = parseStreamIndex(indexText);
    const unique = [...new Set(dispositions)];
    if (unique.length !== dispositions.length) {
      throw new TypeError(
        `Stream ${index} dispositions must not contain duplicates`
      );
    }
    for (const disposition of unique) {
      if (
        !EDITABLE_STREAM_DISPOSITIONS.includes(disposition as StreamDisposition)
      ) {
        throw new TypeError(
          `Unsupported stream disposition: ${String(disposition)}`
        );
      }
    }
    const ordered = EDITABLE_STREAM_DISPOSITIONS.filter((disposition) =>
      unique.includes(disposition)
    );
    args.push(
      `-disposition:${index}`,
      ordered.length > 0 ? ordered.join('+') : '0'
    );
  }
  return Object.freeze(args);
}

export function buildMetadataEditPlan(
  options: MetadataEditPlanOptions
): FFmpegCommandPlan {
  assertMuxerMatchesExtension(options.targetMuxer, options.targetExtension);
  const edits = metadataArguments(options.edits);
  const input = createPlannedInput(options.jobId, options.source);
  const output = createPlannedOutput(options.jobId, {
    baseName: options.source.fileName,
    operation: 'metadata',
    extension: options.targetExtension,
    ...(options.outputFileName ? { fileName: options.outputFileName } : {}),
  });
  const args: string[] = ['-i', input.path];
  if (options.streamSelection) {
    const maps = streamMapArguments(options.streamSelection);
    if (maps.length === 0) {
      throw new RangeError('At least one output stream must remain');
    }
    args.push(...maps);
  } else {
    args.push('-map', '0');
  }
  args.push(
    '-c',
    'copy',
    '-map_metadata',
    options.sourceMetadataPolicy === 'remove' ? '-1' : '0',
    '-map_chapters',
    options.chapterPolicy === 'remove' ? '-1' : '0',
    ...edits,
    output.path
  );
  return freezeCommandPlan({
    id: `${options.jobId}:metadata`,
    operation: 'metadata',
    inputs: [input],
    temporaryFiles: [],
    args,
    outputs: [output],
    ...(options.expectedDurationSeconds !== undefined
      ? { expectedDurationSeconds: options.expectedDurationSeconds }
      : {}),
    requiredCapabilities: { muxers: [options.targetMuxer] },
    diagnostics: [
      diagnostic(
        'metadata-round-trip',
        'info',
        'Re-probe the result to report which container and stream tags survived muxing.'
      ),
      ...(options.sourceMetadataPolicy === 'remove'
        ? [
            diagnostic(
              'metadata-removal-scope',
              'warning' as const,
              'Common removable tags are not copied, but container-specific structural metadata may remain.'
            ),
          ]
        : []),
    ],
  });
}

export function serializeMetadataJson(edits: MetadataEdits): string {
  metadataArguments(edits);
  return `${JSON.stringify(edits, null, 2)}\n`;
}

export function parseMetadataJson(json: string): MetadataEdits {
  let value: unknown;
  try {
    value = JSON.parse(json) as unknown;
  } catch {
    throw new TypeError('Metadata JSON is malformed');
  }
  if (!isRecord(value)) {
    throw new TypeError('Metadata JSON must be an object');
  }
  const unknown = Object.keys(value).filter(
    (key) => key !== 'format' && key !== 'streams' && key !== 'dispositions'
  );
  if (unknown.length > 0) {
    throw new TypeError(`Unsupported metadata field: ${unknown[0]}`);
  }
  const format = parseTagRecord(value.format, 'format');
  const streams: Record<
    number,
    Readonly<Record<string, MetadataValue | null>>
  > = {};
  if (value.streams !== undefined) {
    if (!isRecord(value.streams)) {
      throw new TypeError('streams must be an object');
    }
    for (const [indexText, tags] of Object.entries(value.streams)) {
      if (!/^(?:0|[1-9][0-9]*)$/u.test(indexText)) {
        throw new TypeError(`Invalid stream index: ${indexText}`);
      }
      const parsedTags = parseTagRecord(tags, `streams.${indexText}`);
      if (!parsedTags) {
        throw new TypeError(`streams.${indexText} must be an object`);
      }
      streams[Number(indexText)] = parsedTags;
    }
  }
  const dispositions: Record<number, readonly StreamDisposition[]> = {};
  if (value.dispositions !== undefined) {
    if (!isRecord(value.dispositions)) {
      throw new TypeError('dispositions must be an object');
    }
    for (const [indexText, entry] of Object.entries(value.dispositions)) {
      const index = parseStreamIndex(indexText);
      if (
        !Array.isArray(entry) ||
        !entry.every((disposition) => typeof disposition === 'string')
      ) {
        throw new TypeError(
          `dispositions.${indexText} must be an array of disposition names`
        );
      }
      const parsed = entry as string[];
      if (
        parsed.some(
          (disposition) =>
            !EDITABLE_STREAM_DISPOSITIONS.includes(
              disposition as StreamDisposition
            )
        )
      ) {
        throw new TypeError(
          `dispositions.${indexText} contains an unsupported disposition`
        );
      }
      if (new Set(parsed).size !== parsed.length) {
        throw new TypeError(
          `dispositions.${indexText} must not contain duplicates`
        );
      }
      dispositions[index] = Object.freeze(
        EDITABLE_STREAM_DISPOSITIONS.filter((disposition) =>
          parsed.includes(disposition)
        )
      );
    }
  }
  return Object.freeze({
    ...(format ? { format: Object.freeze(format) } : {}),
    ...(Object.keys(streams).length > 0
      ? { streams: Object.freeze(streams) }
      : {}),
    ...(Object.keys(dispositions).length > 0
      ? { dispositions: Object.freeze(dispositions) }
      : {}),
  });
}

function parseStreamIndex(indexText: string): number {
  if (!/^(?:0|[1-9][0-9]*)$/u.test(indexText)) {
    throw new TypeError(`Invalid stream index: ${indexText}`);
  }
  const index = Number(indexText);
  if (!Number.isSafeInteger(index)) {
    throw new TypeError(`Invalid stream index: ${indexText}`);
  }
  return index;
}

function parseTagRecord(
  value: unknown,
  label: string
): Readonly<Record<string, MetadataValue | null>> | undefined {
  if (value === undefined) {
    return undefined;
  }
  if (!isRecord(value)) {
    throw new TypeError(`${label} must be an object`);
  }
  const result: Record<string, MetadataValue | null> = {};
  for (const [key, entry] of Object.entries(value)) {
    validateMetadataKey(key);
    if (
      entry !== null &&
      typeof entry !== 'string' &&
      (typeof entry !== 'number' || !Number.isFinite(entry))
    ) {
      throw new TypeError(
        `${label}.${key} must be a string, finite number or null`
      );
    }
    result[key] = entry as MetadataValue | null;
  }
  return result;
}

function validateMetadataKey(key: string): void {
  if (!/^[a-zA-Z0-9_.-]{1,64}$/u.test(key)) {
    throw new TypeError(`Invalid metadata key: ${key}`);
  }
}

function sortedEntries<T>(
  value: Readonly<Record<string, T>>
): readonly (readonly [string, T])[] {
  return Object.entries(value).sort(([left], [right]) =>
    left.localeCompare(right)
  );
}

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