// Legacy document migration helpers for the V1 persistence contract.
//
// The migration layer is intentionally plain Dart and side-effect free. Future
// adapters can use these results for dry-runs before deciding whether to write
// the migrated document back to storage.

import 'document_mapping.dart';
import 'persistence_contract.dart';
import 'time_contracts.dart';

/// Entity shape being migrated.
enum DocumentMigrationEntity {
  task,
  project,
  lockedBlock,
  lockedBlockOverride,
}

/// Result state for a migration attempt.
enum DocumentMigrationStatus {
  migrated,
  alreadyCurrent,
  failed,
}

/// Typed categories for migration failures.
enum DocumentMigrationFailureCode {
  invalidSchemaVersion,
  unsupportedSchemaVersion,
  legacyDocumentFailure,
  currentDocumentFailure,
}

/// Non-fatal migration notes surfaced to callers during dry-runs.
enum DocumentMigrationNoteCode {
  approximatedBacklogEnteredAt,
  synthesizedMetadataTimestamp,
}

/// Provenance labels written by migrations.
abstract final class DocumentMigrationProvenance {
  static const approximatedFromCreatedAt = 'approximated_from_created_at';
}

/// A non-fatal detail about a migrated document.
class DocumentMigrationNote {
  const DocumentMigrationNote({
    required this.code,
    this.fieldName,
    this.detailCode,
  });

  final DocumentMigrationNoteCode code;
  final String? fieldName;
  final String? detailCode;
}

/// Typed dry-run report for one document migration attempt.
class DocumentMigrationReport {
  const DocumentMigrationReport({
    required this.entity,
    required this.status,
    required this.documentId,
    required this.fromSchemaVersion,
    this.targetSchemaVersion = v1SchemaVersion,
    this.failureCode,
    this.mappingFailureCode,
    this.fieldName,
    this.detailCode,
    this.notes = const <DocumentMigrationNote>[],
  });

  final DocumentMigrationEntity entity;
  final DocumentMigrationStatus status;
  final String? documentId;
  final int? fromSchemaVersion;
  final int targetSchemaVersion;
  final DocumentMigrationFailureCode? failureCode;
  final DocumentMappingFailureCode? mappingFailureCode;
  final String? fieldName;
  final String? detailCode;
  final List<DocumentMigrationNote> notes;

  bool get canWrite => status != DocumentMigrationStatus.failed;
}

/// Migration output plus report.
class DocumentMigrationResult {
  const DocumentMigrationResult({
    required this.report,
    required this.document,
  });

  /// Migrated/current document. Null means the source must not be overwritten.
  final Map<String, Object?>? document;

  final DocumentMigrationReport report;

  bool get isSuccess => report.status != DocumentMigrationStatus.failed;

  /// True only when the caller should persist a transformed replacement.
  bool get shouldWrite => report.status == DocumentMigrationStatus.migrated;
}

/// Pure V0-to-V1 migration service.
///
/// Version 0 is the pre-Block-15 document shape: no `schemaVersion`, no owner
/// metadata, enum values stored as Dart `.name`, and no exact backlog-entry
/// timestamp. Missing `schemaVersion` is treated as V0; unsupported future
/// versions fail closed.
class V1DocumentMigrationService {
  const V1DocumentMigrationService({
    required this.ownerId,
    required this.migratedAt,
  });

  final String ownerId;
  final DateTime migratedAt;

  DocumentMigrationResult migrateTask(Map<String, Object?> document) {
    return _migrate(
      entity: DocumentMigrationEntity.task,
      document: document,
      migrateV0: _taskV0ToV1,
      validateCurrent: TaskDocumentMapping.fromDocument,
    );
  }

  DocumentMigrationResult migrateProject(Map<String, Object?> document) {
    return _migrate(
      entity: DocumentMigrationEntity.project,
      document: document,
      migrateV0: _projectV0ToV1,
      validateCurrent: ProjectDocumentMapping.fromDocument,
    );
  }

  DocumentMigrationResult migrateLockedBlock(Map<String, Object?> document) {
    return _migrate(
      entity: DocumentMigrationEntity.lockedBlock,
      document: document,
      migrateV0: _lockedBlockV0ToV1,
      validateCurrent: LockedBlockDocumentMapping.fromDocument,
    );
  }

  DocumentMigrationResult migrateLockedBlockOverride(
    Map<String, Object?> document,
  ) {
    return _migrate(
      entity: DocumentMigrationEntity.lockedBlockOverride,
      document: document,
      migrateV0: _lockedBlockOverrideV0ToV1,
      validateCurrent: LockedBlockOverrideDocumentMapping.fromDocument,
    );
  }

  DocumentMigrationResult _migrate({
    required DocumentMigrationEntity entity,
    required Map<String, Object?> document,
    required _V0Migrator migrateV0,
    required void Function(Map<String, Object?> document) validateCurrent,
  }) {
    final documentId = _documentId(document);
    final schemaRead = _readSchemaVersion(document);
    if (schemaRead.failure != null) {
      return _failed(
        entity: entity,
        documentId: documentId,
        fromSchemaVersion: null,
        failureCode: DocumentMigrationFailureCode.invalidSchemaVersion,
        fieldName: DocumentFields.schemaVersion,
        detailCode: schemaRead.failure,
      );
    }

    final fromVersion = schemaRead.version;
    if (fromVersion == v1SchemaVersion) {
      try {
        final copy = _deepCopyDocument(document);
        validateCurrent(copy);
        return DocumentMigrationResult(
          document: copy,
          report: DocumentMigrationReport(
            entity: entity,
            status: DocumentMigrationStatus.alreadyCurrent,
            documentId: documentId,
            fromSchemaVersion: fromVersion,
          ),
        );
      } on DocumentMappingException catch (error) {
        return _failedFromMapping(
          entity: entity,
          documentId: documentId,
          fromSchemaVersion: fromVersion,
          failureCode: DocumentMigrationFailureCode.currentDocumentFailure,
          error: error,
        );
      }
    }

    if (fromVersion > v1SchemaVersion || fromVersion < 0) {
      return _failed(
        entity: entity,
        documentId: documentId,
        fromSchemaVersion: fromVersion,
        failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion,
        fieldName: DocumentFields.schemaVersion,
        detailCode: fromVersion.toString(),
      );
    }

    if (fromVersion != 0) {
      return _failed(
        entity: entity,
        documentId: documentId,
        fromSchemaVersion: fromVersion,
        failureCode: DocumentMigrationFailureCode.unsupportedSchemaVersion,
        fieldName: DocumentFields.schemaVersion,
        detailCode: fromVersion.toString(),
      );
    }

    final notes = <DocumentMigrationNote>[];
    late final Map<String, Object?> migrated;
    try {
      migrated = migrateV0(document, notes);
    } on _LegacyDocumentException catch (error) {
      return _failed(
        entity: entity,
        documentId: documentId,
        fromSchemaVersion: fromVersion,
        failureCode: DocumentMigrationFailureCode.legacyDocumentFailure,
        fieldName: error.fieldName,
        detailCode: error.detailCode,
      );
    }

    try {
      validateCurrent(migrated);
    } on DocumentMappingException catch (error) {
      return _failedFromMapping(
        entity: entity,
        documentId: documentId,
        fromSchemaVersion: fromVersion,
        failureCode: DocumentMigrationFailureCode.currentDocumentFailure,
        error: error,
      );
    }

    return DocumentMigrationResult(
      document: migrated,
      report: DocumentMigrationReport(
        entity: entity,
        status: DocumentMigrationStatus.migrated,
        documentId: documentId,
        fromSchemaVersion: fromVersion,
        notes: List<DocumentMigrationNote>.unmodifiable(notes),
      ),
    );
  }

  Map<String, Object?> _taskV0ToV1(
    Map<String, Object?> document,
    List<DocumentMigrationNote> notes,
  ) {
    final createdAt = _requiredInstant(document, TaskDocumentFields.createdAt);
    final status = _requiredLegacyCode(
      document,
      TaskDocumentFields.status,
      _taskStatusCodes,
    );
    final isBacklog = status == 'backlog';
    if (isBacklog) {
      notes.add(
        const DocumentMigrationNote(
          code: DocumentMigrationNoteCode.approximatedBacklogEnteredAt,
          fieldName: TaskDocumentFields.backlogEnteredAt,
          detailCode: DocumentMigrationProvenance.approximatedFromCreatedAt,
        ),
      );
    }

    return {
      ..._commonV1Fields(
        id: _requiredString(document, TaskDocumentFields.id),
        createdAt: createdAt,
        updatedAt: _requiredInstant(document, TaskDocumentFields.updatedAt),
      ),
      TaskDocumentFields.title:
          _requiredString(document, TaskDocumentFields.title),
      TaskDocumentFields.projectId:
          _requiredString(document, TaskDocumentFields.projectId),
      TaskDocumentFields.type: _requiredLegacyCode(
        document,
        TaskDocumentFields.type,
        _taskTypeCodes,
      ),
      TaskDocumentFields.status: status,
      TaskDocumentFields.priority: _nullableLegacyCode(
        document,
        TaskDocumentFields.priority,
        _priorityCodes,
      ),
      TaskDocumentFields.reward: _requiredLegacyCode(
        document,
        TaskDocumentFields.reward,
        _rewardCodes,
      ),
      TaskDocumentFields.difficulty: _requiredLegacyCode(
        document,
        TaskDocumentFields.difficulty,
        _difficultyCodes,
      ),
      TaskDocumentFields.durationMinutes:
          _requiredNullableInt(document, TaskDocumentFields.durationMinutes),
      TaskDocumentFields.scheduledStart: _nullableInstant(
        document,
        TaskDocumentFields.scheduledStart,
      ),
      TaskDocumentFields.scheduledEnd: _nullableInstant(
        document,
        TaskDocumentFields.scheduledEnd,
      ),
      TaskDocumentFields.actualStart:
          _nullableInstant(document, TaskDocumentFields.actualStart),
      TaskDocumentFields.actualEnd:
          _nullableInstant(document, TaskDocumentFields.actualEnd),
      TaskDocumentFields.completedAt:
          _nullableInstant(document, TaskDocumentFields.completedAt),
      TaskDocumentFields.parentTaskId:
          _requiredNullableString(document, TaskDocumentFields.parentTaskId),
      TaskDocumentFields.backlogTags: _requiredLegacyCodeList(
        document,
        TaskDocumentFields.backlogTags,
        _backlogTagCodes,
      ),
      TaskDocumentFields.reminderOverride: _nullableLegacyCode(
        document,
        TaskDocumentFields.reminderOverride,
        _reminderProfileCodes,
      ),
      TaskDocumentFields.stats:
          _requiredMap(document, TaskDocumentFields.stats),
      TaskDocumentFields.backlogEnteredAt: isBacklog ? createdAt : null,
      TaskDocumentFields.backlogEnteredAtProvenance: isBacklog
          ? DocumentMigrationProvenance.approximatedFromCreatedAt
          : null,
    };
  }

  Map<String, Object?> _projectV0ToV1(
    Map<String, Object?> document,
    List<DocumentMigrationNote> notes,
  ) {
    final metadata = _metadataInstants(document, notes);
    return {
      ..._commonV1Fields(
        id: _requiredString(document, ProjectDocumentFields.id),
        createdAt: metadata.createdAt,
        updatedAt: metadata.updatedAt,
      ),
      ProjectDocumentFields.name:
          _requiredString(document, ProjectDocumentFields.name),
      ProjectDocumentFields.colorKey:
          _requiredString(document, ProjectDocumentFields.colorKey),
      ProjectDocumentFields.defaultPriority: _requiredLegacyCode(
        document,
        ProjectDocumentFields.defaultPriority,
        _priorityCodes,
      ),
      ProjectDocumentFields.defaultReward: _requiredLegacyCode(
        document,
        ProjectDocumentFields.defaultReward,
        _rewardCodes,
      ),
      ProjectDocumentFields.defaultDifficulty: _requiredLegacyCode(
        document,
        ProjectDocumentFields.defaultDifficulty,
        _difficultyCodes,
      ),
      ProjectDocumentFields.defaultReminderProfile: _requiredLegacyCode(
        document,
        ProjectDocumentFields.defaultReminderProfile,
        _reminderProfileCodes,
      ),
      ProjectDocumentFields.defaultDurationMinutes: _requiredNullableInt(
        document,
        ProjectDocumentFields.defaultDurationMinutes,
      ),
      ProjectDocumentFields.archivedAt:
          _optionalNullableInstant(document, ProjectDocumentFields.archivedAt),
    };
  }

  Map<String, Object?> _lockedBlockV0ToV1(
    Map<String, Object?> document,
    List<DocumentMigrationNote> notes,
  ) {
    final metadata = _metadataInstants(document, notes);
    return {
      ..._commonV1Fields(
        id: _requiredString(document, LockedBlockDocumentFields.id),
        createdAt: metadata.createdAt,
        updatedAt: metadata.updatedAt,
      ),
      LockedBlockDocumentFields.name:
          _requiredString(document, LockedBlockDocumentFields.name),
      LockedBlockDocumentFields.startTime:
          _requiredWallTime(document, LockedBlockDocumentFields.startTime),
      LockedBlockDocumentFields.endTime:
          _requiredWallTime(document, LockedBlockDocumentFields.endTime),
      LockedBlockDocumentFields.date:
          _requiredNullableCivilDate(document, LockedBlockDocumentFields.date),
      LockedBlockDocumentFields.recurrence:
          _nullableRecurrence(document, LockedBlockDocumentFields.recurrence),
      LockedBlockDocumentFields.hiddenByDefault:
          _requiredBool(document, LockedBlockDocumentFields.hiddenByDefault),
      LockedBlockDocumentFields.projectId: _requiredNullableString(
        document,
        LockedBlockDocumentFields.projectId,
      ),
      LockedBlockDocumentFields.archivedAt: _optionalNullableInstant(
        document,
        LockedBlockDocumentFields.archivedAt,
      ),
    };
  }

  Map<String, Object?> _lockedBlockOverrideV0ToV1(
    Map<String, Object?> document,
    List<DocumentMigrationNote> notes,
  ) {
    final metadata = _metadataInstants(document, notes);
    return {
      ..._commonV1Fields(
        id: _requiredString(document, LockedBlockOverrideDocumentFields.id),
        createdAt: metadata.createdAt,
        updatedAt: metadata.updatedAt,
      ),
      LockedBlockOverrideDocumentFields.lockedBlockId: _requiredNullableString(
        document,
        LockedBlockOverrideDocumentFields.lockedBlockId,
      ),
      LockedBlockOverrideDocumentFields.date: _requiredCivilDate(
        document,
        LockedBlockOverrideDocumentFields.date,
      ),
      LockedBlockOverrideDocumentFields.type: _requiredLegacyCode(
        document,
        LockedBlockOverrideDocumentFields.type,
        _lockedOverrideTypeCodes,
      ),
      LockedBlockOverrideDocumentFields.name: _requiredNullableString(
        document,
        LockedBlockOverrideDocumentFields.name,
      ),
      LockedBlockOverrideDocumentFields.startTime: _requiredNullableWallTime(
        document,
        LockedBlockOverrideDocumentFields.startTime,
      ),
      LockedBlockOverrideDocumentFields.endTime: _requiredNullableWallTime(
        document,
        LockedBlockOverrideDocumentFields.endTime,
      ),
      LockedBlockOverrideDocumentFields.hiddenByDefault: _requiredBool(
        document,
        LockedBlockOverrideDocumentFields.hiddenByDefault,
      ),
      LockedBlockOverrideDocumentFields.projectId: _requiredNullableString(
        document,
        LockedBlockOverrideDocumentFields.projectId,
      ),
    };
  }

  Map<String, Object?> _commonV1Fields({
    required String id,
    required String createdAt,
    required String updatedAt,
  }) {
    return {
      DocumentFields.schemaVersion: v1SchemaVersion,
      DocumentFields.id: id,
      DocumentFields.ownerId: ownerId,
      DocumentFields.revision: 1,
      DocumentFields.createdAt: createdAt,
      DocumentFields.updatedAt: updatedAt,
    };
  }

  _LegacyMetadataInstants _metadataInstants(
    Map<String, Object?> document,
    List<DocumentMigrationNote> notes,
  ) {
    final createdAt =
        _optionalNullableInstant(document, DocumentFields.createdAt);
    final updatedAt =
        _optionalNullableInstant(document, DocumentFields.updatedAt);
    if (createdAt == null || updatedAt == null) {
      notes.add(
        const DocumentMigrationNote(
          code: DocumentMigrationNoteCode.synthesizedMetadataTimestamp,
          detailCode: 'migratedAt',
        ),
      );
    }
    final fallback = PersistenceDateTimeConvention.toStoredString(migratedAt);
    return _LegacyMetadataInstants(
      createdAt: createdAt ?? fallback,
      updatedAt: updatedAt ?? createdAt ?? fallback,
    );
  }
}

typedef _V0Migrator = Map<String, Object?> Function(
  Map<String, Object?> document,
  List<DocumentMigrationNote> notes,
);

class _SchemaVersionRead {
  const _SchemaVersionRead.version(this.version) : failure = null;
  const _SchemaVersionRead.failure(this.failure) : version = 0;

  final int version;
  final String? failure;
}

class _LegacyDocumentException implements Exception {
  const _LegacyDocumentException({
    required this.fieldName,
    required this.detailCode,
  });

  final String fieldName;
  final String detailCode;
}

class _LegacyMetadataInstants {
  const _LegacyMetadataInstants({
    required this.createdAt,
    required this.updatedAt,
  });

  final String createdAt;
  final String updatedAt;
}

const _taskTypeCodes = <String, String>{
  'flexible': 'flexible',
  'inflexible': 'inflexible',
  'critical': 'critical',
  'locked': 'locked',
  'surprise': 'surprise',
  'freeSlot': 'free_slot',
  'free_slot': 'free_slot',
};

const _taskStatusCodes = <String, String>{
  'planned': 'planned',
  'active': 'active',
  'completed': 'completed',
  'missed': 'missed',
  'cancelled': 'cancelled',
  'noLongerRelevant': 'no_longer_relevant',
  'no_longer_relevant': 'no_longer_relevant',
  'backlog': 'backlog',
};

const _priorityCodes = <String, String>{
  'veryLow': 'very_low',
  'very_low': 'very_low',
  'low': 'low',
  'medium': 'medium',
  'high': 'high',
  'veryHigh': 'very_high',
  'very_high': 'very_high',
};

const _rewardCodes = <String, String>{
  'notSet': 'not_set',
  'not_set': 'not_set',
  'veryLow': 'very_low',
  'very_low': 'very_low',
  'low': 'low',
  'medium': 'medium',
  'high': 'high',
  'veryHigh': 'very_high',
  'very_high': 'very_high',
};

const _difficultyCodes = <String, String>{
  'notSet': 'not_set',
  'not_set': 'not_set',
  'veryEasy': 'very_easy',
  'very_easy': 'very_easy',
  'easy': 'easy',
  'medium': 'medium',
  'hard': 'hard',
  'veryHard': 'very_hard',
  'very_hard': 'very_hard',
};

const _reminderProfileCodes = <String, String>{
  'silent': 'silent',
  'gentle': 'gentle',
  'persistent': 'persistent',
  'strict': 'strict',
};

const _backlogTagCodes = <String, String>{
  'wishlist': 'wishlist',
};

const _lockedWeekdayCodes = <String, String>{
  'monday': 'monday',
  'tuesday': 'tuesday',
  'wednesday': 'wednesday',
  'thursday': 'thursday',
  'friday': 'friday',
  'saturday': 'saturday',
  'sunday': 'sunday',
};

const _lockedOverrideTypeCodes = <String, String>{
  'remove': 'remove',
  'replace': 'replace',
  'add': 'add',
};

final _explicitOffsetPattern = RegExp(r'(Z|[+-]\d{2}:\d{2})$');

_SchemaVersionRead _readSchemaVersion(Map<String, Object?> document) {
  if (!document.containsKey(DocumentFields.schemaVersion)) {
    return const _SchemaVersionRead.version(0);
  }
  final value = document[DocumentFields.schemaVersion];
  if (value is int) {
    return _SchemaVersionRead.version(value);
  }
  return const _SchemaVersionRead.failure('wrongType');
}

String? _documentId(Map<String, Object?> document) {
  final value = document[DocumentFields.id];
  return value is String ? value : null;
}

DocumentMigrationResult _failed({
  required DocumentMigrationEntity entity,
  required String? documentId,
  required int? fromSchemaVersion,
  required DocumentMigrationFailureCode failureCode,
  String? fieldName,
  String? detailCode,
}) {
  return DocumentMigrationResult(
    document: null,
    report: DocumentMigrationReport(
      entity: entity,
      status: DocumentMigrationStatus.failed,
      documentId: documentId,
      fromSchemaVersion: fromSchemaVersion,
      failureCode: failureCode,
      fieldName: fieldName,
      detailCode: detailCode,
    ),
  );
}

DocumentMigrationResult _failedFromMapping({
  required DocumentMigrationEntity entity,
  required String? documentId,
  required int fromSchemaVersion,
  required DocumentMigrationFailureCode failureCode,
  required DocumentMappingException error,
}) {
  return DocumentMigrationResult(
    document: null,
    report: DocumentMigrationReport(
      entity: entity,
      status: DocumentMigrationStatus.failed,
      documentId: documentId,
      fromSchemaVersion: fromSchemaVersion,
      failureCode: failureCode,
      mappingFailureCode: error.code,
      fieldName: error.fieldName,
      detailCode: error.detailCode,
    ),
  );
}

String _requiredString(Map<String, Object?> document, String fieldName) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  final value = document[fieldName];
  if (value is String) {
    return value;
  }
  throw _LegacyDocumentException(
    fieldName: fieldName,
    detailCode: 'wrongType',
  );
}

String? _requiredNullableString(
  Map<String, Object?> document,
  String fieldName,
) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  final value = document[fieldName];
  if (value == null || value is String) {
    return value as String?;
  }
  throw _LegacyDocumentException(
    fieldName: fieldName,
    detailCode: 'wrongType',
  );
}

int? _requiredNullableInt(Map<String, Object?> document, String fieldName) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  final value = document[fieldName];
  if (value == null || value is int) {
    return value as int?;
  }
  throw _LegacyDocumentException(
    fieldName: fieldName,
    detailCode: 'wrongType',
  );
}

bool _requiredBool(Map<String, Object?> document, String fieldName) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  final value = document[fieldName];
  if (value is bool) {
    return value;
  }
  throw _LegacyDocumentException(
    fieldName: fieldName,
    detailCode: 'wrongType',
  );
}

Map<String, Object?> _requiredMap(
  Map<String, Object?> document,
  String fieldName,
) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  final value = document[fieldName];
  if (value is Map) {
    return _deepCopyMap(value, fieldName);
  }
  throw _LegacyDocumentException(
    fieldName: fieldName,
    detailCode: 'wrongType',
  );
}

String _requiredLegacyCode(
  Map<String, Object?> document,
  String fieldName,
  Map<String, String> codes,
) {
  final raw = _requiredString(document, fieldName);
  final code = codes[raw];
  if (code == null) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'unknownCode:$raw',
    );
  }
  return code;
}

String? _nullableLegacyCode(
  Map<String, Object?> document,
  String fieldName,
  Map<String, String> codes,
) {
  final raw = _requiredNullableString(document, fieldName);
  if (raw == null) {
    return null;
  }
  final code = codes[raw];
  if (code == null) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'unknownCode:$raw',
    );
  }
  return code;
}

List<String> _requiredLegacyCodeList(
  Map<String, Object?> document,
  String fieldName,
  Map<String, String> codes,
) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  final value = document[fieldName];
  if (value is! List) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'wrongType',
    );
  }
  return value.map((entry) {
    if (entry is! String) {
      throw _LegacyDocumentException(
        fieldName: fieldName,
        detailCode: 'wrongType',
      );
    }
    final code = codes[entry];
    if (code == null) {
      throw _LegacyDocumentException(
        fieldName: fieldName,
        detailCode: 'unknownCode:$entry',
      );
    }
    return code;
  }).toList(growable: false);
}

String _requiredInstant(Map<String, Object?> document, String fieldName) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  final value = document[fieldName];
  final stored = _instantToStored(value, fieldName);
  if (stored == null) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'wrongType',
    );
  }
  return stored;
}

String? _nullableInstant(Map<String, Object?> document, String fieldName) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  return _instantToStored(document[fieldName], fieldName);
}

String? _optionalNullableInstant(
  Map<String, Object?> document,
  String fieldName,
) {
  if (!document.containsKey(fieldName)) {
    return null;
  }
  return _instantToStored(document[fieldName], fieldName);
}

String? _instantToStored(Object? value, String fieldName) {
  if (value == null) {
    return null;
  }
  if (value is DateTime) {
    if (!value.isUtc) {
      throw _LegacyDocumentException(
        fieldName: fieldName,
        detailCode: 'ambiguousDateTime',
      );
    }
    return PersistenceDateTimeConvention.toStoredString(value);
  }
  if (value is String) {
    if (!_explicitOffsetPattern.hasMatch(value)) {
      throw _LegacyDocumentException(
        fieldName: fieldName,
        detailCode: 'ambiguousDateTime',
      );
    }
    try {
      return PersistenceDateTimeConvention.toStoredString(
        DateTime.parse(value),
      );
    } on FormatException {
      throw _LegacyDocumentException(
        fieldName: fieldName,
        detailCode: 'invalidDateTime',
      );
    }
  }
  throw _LegacyDocumentException(
    fieldName: fieldName,
    detailCode: 'wrongType',
  );
}

String _requiredCivilDate(Map<String, Object?> document, String fieldName) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  final value = _civilDateToStored(document[fieldName], fieldName);
  if (value == null) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'wrongType',
    );
  }
  return value;
}

String? _requiredNullableCivilDate(
  Map<String, Object?> document,
  String fieldName,
) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  return _civilDateToStored(document[fieldName], fieldName);
}

String? _civilDateToStored(Object? value, String fieldName) {
  if (value == null) {
    return null;
  }
  if (value is String) {
    try {
      return PersistenceCivilDateConvention.toStoredString(
        PersistenceCivilDateConvention.fromStoredString(value),
      );
    } on Object {
      throw _LegacyDocumentException(
        fieldName: fieldName,
        detailCode: 'invalidCivilDate',
      );
    }
  }
  if (value is DateTime) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'ambiguousCivilDateTime',
    );
  }
  throw _LegacyDocumentException(
    fieldName: fieldName,
    detailCode: 'wrongType',
  );
}

String _requiredWallTime(Map<String, Object?> document, String fieldName) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  final value = _wallTimeToStored(document[fieldName], fieldName);
  if (value == null) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'wrongType',
    );
  }
  return value;
}

String? _requiredNullableWallTime(
  Map<String, Object?> document,
  String fieldName,
) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  return _wallTimeToStored(document[fieldName], fieldName);
}

String? _wallTimeToStored(Object? value, String fieldName) {
  if (value == null) {
    return null;
  }
  if (value is String) {
    try {
      return PersistenceWallTimeConvention.toStoredString(
        PersistenceWallTimeConvention.fromStoredString(value),
      );
    } on Object {
      throw _LegacyDocumentException(
        fieldName: fieldName,
        detailCode: 'invalidWallTime',
      );
    }
  }
  if (value is Map) {
    final map = _deepCopyMap(value, fieldName);
    final legacyValue = map[ClockTimeDocumentFields.value];
    if (legacyValue is String) {
      return _wallTimeToStored(legacyValue, fieldName);
    }
    final hour = map['hour'];
    final minute = map['minute'];
    if (hour is int && minute is int) {
      try {
        return WallTime(hour: hour, minute: minute).toIsoString();
      } on Object {
        throw _LegacyDocumentException(
          fieldName: fieldName,
          detailCode: 'invalidWallTime',
        );
      }
    }
  }
  throw _LegacyDocumentException(
    fieldName: fieldName,
    detailCode: 'wrongType',
  );
}

Map<String, Object?>? _nullableRecurrence(
  Map<String, Object?> document,
  String fieldName,
) {
  if (!document.containsKey(fieldName)) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'missingField',
    );
  }
  final value = document[fieldName];
  if (value == null) {
    return null;
  }
  if (value is! Map) {
    throw _LegacyDocumentException(
      fieldName: fieldName,
      detailCode: 'wrongType',
    );
  }
  final recurrence = _deepCopyMap(value, fieldName);
  final weekdays = recurrence[LockedBlockRecurrenceDocumentFields.weekdays];
  if (weekdays is! List) {
    throw _LegacyDocumentException(
      fieldName: LockedBlockRecurrenceDocumentFields.weekdays,
      detailCode: 'wrongType',
    );
  }
  return {
    LockedBlockRecurrenceDocumentFields.type:
        recurrence[LockedBlockRecurrenceDocumentFields.type] ?? 'weekly',
    LockedBlockRecurrenceDocumentFields.weekdays: weekdays.map((entry) {
      if (entry is! String) {
        throw const _LegacyDocumentException(
          fieldName: LockedBlockRecurrenceDocumentFields.weekdays,
          detailCode: 'wrongType',
        );
      }
      final code = _lockedWeekdayCodes[entry];
      if (code == null) {
        throw _LegacyDocumentException(
          fieldName: LockedBlockRecurrenceDocumentFields.weekdays,
          detailCode: 'unknownCode:$entry',
        );
      }
      return code;
    }).toList(growable: false),
  };
}

Map<String, Object?> _deepCopyDocument(Map<String, Object?> document) {
  return _deepCopyMap(document, 'document');
}

Map<String, Object?> _deepCopyMap(Map<Object?, Object?> map, String fieldName) {
  return {
    for (final entry in map.entries)
      _stringKey(entry.key, fieldName): _deepCopyValue(entry.value, fieldName),
  };
}

String _stringKey(Object? key, String fieldName) {
  if (key is String) {
    return key;
  }
  throw _LegacyDocumentException(
    fieldName: fieldName,
    detailCode: 'nonStringMapKey',
  );
}

Object? _deepCopyValue(Object? value, String fieldName) {
  if (value is Map) {
    return _deepCopyMap(value, fieldName);
  }
  if (value is List) {
    return value.map((entry) => _deepCopyValue(entry, fieldName)).toList();
  }
  return value;
}
