import { projectReducer } from './project.reducer';
import type {
  AvProjectV1,
  MediaAssetReference,
  SourceHash,
} from './project.types';

export interface SourceFileDescriptor {
  name: string;
  size: number;
  lastModified: number;
  mimeType?: string;
  hash?: SourceHash;
}

export type ReattachmentConfidence = 'exact' | 'likely' | 'possible' | 'none';

export interface ReattachmentEvidence {
  field: 'size' | 'lastModified' | 'name' | 'mimeType' | 'hash';
  matches: boolean;
  message: string;
}

export interface ReattachmentMatch {
  candidate: SourceFileDescriptor;
  confidence: ReattachmentConfidence;
  score: number;
  evidence: ReattachmentEvidence[];
  requiresExplicitConfirmation: true;
}

export interface ReattachmentMatchOptions {
  lastModifiedToleranceMs?: number;
}

export function rankReattachmentCandidates(
  asset: MediaAssetReference,
  candidates: readonly SourceFileDescriptor[],
  options: ReattachmentMatchOptions = {}
): ReattachmentMatch[] {
  const tolerance = options.lastModifiedToleranceMs ?? 2_000;
  if (!Number.isFinite(tolerance) || tolerance < 0) {
    throw new RangeError(
      'Last-modified tolerance must be finite and non-negative.'
    );
  }
  for (const candidate of candidates) {
    assertValidDescriptor(candidate);
  }

  return candidates
    .map((candidate) => matchSource(asset, candidate, tolerance))
    .sort(
      (left, right) =>
        right.score - left.score ||
        left.candidate.name.localeCompare(right.candidate.name)
    );
}

export function bestReattachmentCandidate(
  asset: MediaAssetReference,
  candidates: readonly SourceFileDescriptor[],
  options?: ReattachmentMatchOptions
): ReattachmentMatch | undefined {
  const matches = rankReattachmentCandidates(asset, candidates, options);
  const best = matches[0];
  if (best === undefined || best.confidence === 'none') {
    return undefined;
  }
  const runnerUp = matches[1];
  if (runnerUp !== undefined && runnerUp.score === best.score) {
    return undefined;
  }
  return best;
}

export function confirmSourceReattachment(
  project: AvProjectV1,
  assetId: string,
  candidate: SourceFileDescriptor,
  updatedAt: string,
  options?: ReattachmentMatchOptions
): AvProjectV1 {
  const asset = project.assets.find((entry) => entry.id === assetId);
  if (asset === undefined) {
    throw new SourceReattachmentError(`Unknown asset "${assetId}".`);
  }
  const match = rankReattachmentCandidates(asset, [candidate], options)[0];
  if (match === undefined || match.confidence === 'none') {
    throw new SourceReattachmentError(
      'The selected source does not match the stored source fingerprint.'
    );
  }
  return projectReducer(project, {
    type: 'asset/set-source-status',
    assetId,
    sourceStatus: 'attached',
    updatedAt,
  });
}

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

function matchSource(
  asset: MediaAssetReference,
  candidate: SourceFileDescriptor,
  lastModifiedToleranceMs: number
): ReattachmentMatch {
  const evidence: ReattachmentEvidence[] = [];
  let score = 0;
  const sizeMatches = candidate.size === asset.size;
  evidence.push({
    field: 'size',
    matches: sizeMatches,
    message: sizeMatches ? 'File size matches.' : 'File size differs.',
  });
  if (sizeMatches) {
    score += 50;
  }

  const modifiedMatches =
    Math.abs(candidate.lastModified - asset.lastModified) <=
    lastModifiedToleranceMs;
  evidence.push({
    field: 'lastModified',
    matches: modifiedMatches,
    message: modifiedMatches
      ? 'Last-modified time matches within tolerance.'
      : 'Last-modified time differs.',
  });
  if (modifiedMatches) {
    score += 25;
  }

  const nameMatches = candidate.name === asset.originalName;
  evidence.push({
    field: 'name',
    matches: nameMatches,
    message: nameMatches
      ? 'Original filename matches.'
      : 'Original filename differs.',
  });
  if (nameMatches) {
    score += 10;
  }

  const mimeMatches =
    !asset.mimeType ||
    !candidate.mimeType ||
    candidate.mimeType === asset.mimeType;
  evidence.push({
    field: 'mimeType',
    matches: mimeMatches,
    message: mimeMatches ? 'MIME type is compatible.' : 'MIME type differs.',
  });
  if (mimeMatches && candidate.mimeType && asset.mimeType) {
    score += 5;
  }

  const hashComparison = compareHashes(asset.hash, candidate.hash);
  if (hashComparison !== undefined) {
    evidence.push({
      field: 'hash',
      matches: hashComparison,
      message: hashComparison
        ? 'Stored and candidate hashes match.'
        : 'Stored and candidate hashes differ.',
    });
    if (hashComparison) {
      score += 100;
    }
  }

  const incompatible = !sizeMatches || !mimeMatches || hashComparison === false;
  const confidence: ReattachmentConfidence = incompatible
    ? 'none'
    : hashComparison === true
      ? 'exact'
      : modifiedMatches
        ? 'likely'
        : nameMatches
          ? 'possible'
          : 'none';

  return {
    candidate,
    confidence,
    score: incompatible ? 0 : score,
    evidence,
    requiresExplicitConfirmation: true,
  };
}

function compareHashes(
  expected: SourceHash | undefined,
  actual: SourceHash | undefined
): boolean | undefined {
  if (expected === undefined || actual === undefined) {
    return undefined;
  }
  if (
    expected.algorithm !== actual.algorithm ||
    expected.scope !== actual.scope
  ) {
    return undefined;
  }
  if (
    expected.scope === 'partial' &&
    expected.bytesHashed !== actual.bytesHashed
  ) {
    return undefined;
  }
  return expected.value.toLowerCase() === actual.value.toLowerCase();
}

function assertValidDescriptor(candidate: SourceFileDescriptor): void {
  if (
    !candidate.name ||
    !Number.isSafeInteger(candidate.size) ||
    candidate.size < 0 ||
    !Number.isSafeInteger(candidate.lastModified) ||
    candidate.lastModified < 0
  ) {
    throw new SourceReattachmentError(
      'A source descriptor requires a name, non-negative size and non-negative last-modified time.'
    );
  }
  if (candidate.hash !== undefined) {
    const hash = candidate.hash;
    if (
      hash.algorithm !== 'sha-256' ||
      !/^[a-fA-F0-9]{64}$/.test(hash.value) ||
      (hash.scope !== 'full' && hash.scope !== 'partial') ||
      (hash.scope === 'partial' &&
        (!Number.isSafeInteger(hash.bytesHashed) ||
          (hash.bytesHashed ?? 0) <= 0))
    ) {
      throw new SourceReattachmentError(
        'A source hash must be a valid full or partial SHA-256 fingerprint.'
      );
    }
  }
}
