import { validateSourceRange } from '../media/duration';
import type {
  AvProjectV1,
  MediaAssetReference,
  OutputSettings,
  ProjectChapter,
  ProjectMetadataSettings,
  ProjectSubtitleTrack,
  ProjectUiState,
  TimelineClip,
} from './project.types';
import { assertValidProject } from './project.validation';

export type ProjectAction =
  | { type: 'project/replace'; project: AvProjectV1 }
  | { type: 'project/rename'; name: string; updatedAt: string }
  | {
      type: 'asset/add';
      asset: MediaAssetReference;
      updatedAt: string;
    }
  | { type: 'asset/remove'; assetId: string; updatedAt: string }
  | {
      type: 'asset/set-source-status';
      assetId: string;
      sourceStatus: 'attached' | 'missing';
      updatedAt: string;
    }
  | {
      type: 'asset/update';
      assetId: string;
      changes: Partial<
        Pick<MediaAssetReference, 'probe' | 'mimeType' | 'hash'>
      >;
      updatedAt: string;
    }
  | { type: 'clip/add'; clip: TimelineClip; index?: number; updatedAt: string }
  | {
      type: 'clip/update';
      clipId: string;
      changes: Partial<Omit<TimelineClip, 'id'>>;
      updatedAt: string;
    }
  | { type: 'clip/remove'; clipId: string; updatedAt: string }
  | {
      type: 'clip/move';
      clipId: string;
      toIndex: number;
      updatedAt: string;
    }
  | {
      type: 'output/set';
      output: OutputSettings;
      updatedAt: string;
    }
  | {
      type: 'metadata/set';
      metadata: ProjectMetadataSettings;
      updatedAt: string;
    }
  | {
      type: 'chapters/set';
      chapters: ProjectChapter[];
      updatedAt: string;
    }
  | {
      type: 'subtitles/set';
      subtitles: ProjectSubtitleTrack[];
      updatedAt: string;
    }
  | { type: 'ui/set'; ui: ProjectUiState | undefined };

export function projectReducer(
  project: AvProjectV1,
  action: ProjectAction
): AvProjectV1 {
  switch (action.type) {
    case 'project/replace':
      return assertValidProject(action.project);
    case 'project/rename': {
      const name = action.name.trim();
      if (!name) {
        throw new ProjectReducerError('A project name cannot be empty.');
      }
      return touch(project, action.updatedAt, { name });
    }
    case 'asset/add': {
      assertUniqueId(project.assets, action.asset.id, 'asset');
      return touch(project, action.updatedAt, {
        assets: [...project.assets, action.asset],
      });
    }
    case 'asset/remove': {
      assertExists(project.assets, action.assetId, 'asset');
      const removedClipIds = new Set(
        project.timeline
          .filter((clip) => clip.assetId === action.assetId)
          .map((clip) => clip.id)
      );
      return touch(project, action.updatedAt, {
        assets: project.assets.filter((asset) => asset.id !== action.assetId),
        timeline: project.timeline.filter(
          (clip) => clip.assetId !== action.assetId
        ),
        subtitles: project.subtitles.filter(
          (track) => track.assetId !== action.assetId
        ),
        ui: cleanUiAfterRemoval(project.ui, action.assetId, removedClipIds),
      });
    }
    case 'asset/set-source-status':
      assertExists(project.assets, action.assetId, 'asset');
      return touch(project, action.updatedAt, {
        assets: project.assets.map((asset) =>
          asset.id === action.assetId
            ? { ...asset, sourceStatus: action.sourceStatus }
            : asset
        ),
      });
    case 'asset/update':
      assertExists(project.assets, action.assetId, 'asset');
      return touch(project, action.updatedAt, {
        assets: project.assets.map((asset) =>
          asset.id === action.assetId ? { ...asset, ...action.changes } : asset
        ),
      });
    case 'clip/add': {
      assertUniqueId(project.timeline, action.clip.id, 'clip');
      assertAssetReference(project, action.clip.assetId);
      assertClipRange(action.clip);
      const index = normalizeInsertionIndex(
        action.index,
        project.timeline.length
      );
      const timeline = [...project.timeline];
      timeline.splice(index, 0, action.clip);
      return touch(project, action.updatedAt, { timeline });
    }
    case 'clip/update': {
      assertExists(project.timeline, action.clipId, 'clip');
      const timeline = project.timeline.map((clip) => {
        if (clip.id !== action.clipId) {
          return clip;
        }
        const updated = { ...clip, ...action.changes, id: clip.id };
        assertAssetReference(project, updated.assetId);
        assertClipRange(updated);
        return updated;
      });
      return touch(project, action.updatedAt, { timeline });
    }
    case 'clip/remove':
      assertExists(project.timeline, action.clipId, 'clip');
      return touch(project, action.updatedAt, {
        timeline: project.timeline.filter((clip) => clip.id !== action.clipId),
        ui:
          project.ui?.selectedClipId === action.clipId
            ? { ...project.ui, selectedClipId: undefined }
            : project.ui,
      });
    case 'clip/move': {
      const fromIndex = project.timeline.findIndex(
        (clip) => clip.id === action.clipId
      );
      if (fromIndex < 0) {
        throw new ProjectReducerError(
          `Cannot move unknown clip "${action.clipId}".`
        );
      }
      if (
        !Number.isSafeInteger(action.toIndex) ||
        action.toIndex < 0 ||
        action.toIndex >= project.timeline.length
      ) {
        throw new ProjectReducerError(
          'Clip destination must be a valid timeline index.'
        );
      }
      if (fromIndex === action.toIndex) {
        return project;
      }
      const timeline = [...project.timeline];
      const [clip] = timeline.splice(fromIndex, 1);
      if (clip === undefined) {
        return project;
      }
      timeline.splice(action.toIndex, 0, clip);
      return touch(project, action.updatedAt, { timeline });
    }
    case 'output/set':
      return touch(project, action.updatedAt, { output: action.output });
    case 'metadata/set':
      return touch(project, action.updatedAt, {
        metadata: action.metadata,
      });
    case 'chapters/set':
      return touch(project, action.updatedAt, {
        chapters: action.chapters,
      });
    case 'subtitles/set':
      return touch(project, action.updatedAt, {
        subtitles: action.subtitles,
      });
    case 'ui/set':
      return { ...project, ui: action.ui };
  }
}

export class ProjectReducerError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ProjectReducerError';
  }
}

function touch(
  project: AvProjectV1,
  updatedAt: string,
  changes: Partial<AvProjectV1>
): AvProjectV1 {
  const timestamp = Date.parse(updatedAt);
  if (!/^\d{4}-\d{2}-\d{2}T/.test(updatedAt) || !Number.isFinite(timestamp)) {
    throw new ProjectReducerError('updatedAt must be an ISO-8601 date.');
  }
  if (timestamp < Date.parse(project.createdAt)) {
    throw new ProjectReducerError(
      'updatedAt cannot be before the project creation time.'
    );
  }
  if (timestamp < Date.parse(project.updatedAt)) {
    throw new ProjectReducerError(
      'updatedAt cannot be before the previous project update time.'
    );
  }
  return { ...project, ...changes, updatedAt };
}

function assertClipRange(clip: TimelineClip): void {
  const range = validateSourceRange(
    clip.sourceInSeconds,
    clip.sourceOutSeconds
  );
  if (!range.valid) {
    throw new ProjectReducerError(range.issues[0]?.message ?? 'Invalid clip.');
  }
}

function assertAssetReference(project: AvProjectV1, assetId: string): void {
  if (!project.assets.some((asset) => asset.id === assetId)) {
    throw new ProjectReducerError(
      `Clip references unknown asset "${assetId}".`
    );
  }
}

function assertExists(
  entries: readonly { id: string }[],
  id: string,
  kind: string
): void {
  if (!entries.some((entry) => entry.id === id)) {
    throw new ProjectReducerError(`Unknown ${kind} "${id}".`);
  }
}

function assertUniqueId(
  entries: readonly { id: string }[],
  id: string,
  kind: string
): void {
  if (entries.some((entry) => entry.id === id)) {
    throw new ProjectReducerError(`Duplicate ${kind} ID "${id}".`);
  }
}

function normalizeInsertionIndex(
  index: number | undefined,
  length: number
): number {
  if (index === undefined) {
    return length;
  }
  if (!Number.isSafeInteger(index) || index < 0 || index > length) {
    throw new ProjectReducerError(
      'Clip insertion index must be within the timeline.'
    );
  }
  return index;
}

function cleanUiAfterRemoval(
  ui: ProjectUiState | undefined,
  assetId: string,
  removedClipIds: ReadonlySet<string>
): ProjectUiState | undefined {
  if (ui === undefined) {
    return undefined;
  }
  return {
    ...ui,
    ...(ui.selectedAssetId === assetId ? { selectedAssetId: undefined } : {}),
    ...(ui.selectedClipId !== undefined && removedClipIds.has(ui.selectedClipId)
      ? { selectedClipId: undefined }
      : {}),
  };
}
