import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:crypto/crypto.dart' as crypto;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

import '../../application/application.dart';
import '../../domain/domain.dart';

final class PathProviderLocalMediaStorage
    implements LocalMediaStorage, LocalBackupMediaStore {
  const PathProviderLocalMediaStorage();

  static const _mediaDirectoryName = 'gametime_media';

  @override
  Future<StoredMediaFile> importFile({
    required String sourcePath,
    required MediaKind kind,
    required String stableFileName,
  }) async {
    final source = File(sourcePath);
    if (!await source.exists()) {
      throw DomainException('Media source file not found: $sourcePath');
    }

    final extension = p.extension(source.path).toLowerCase();
    final targetDirectory = await _kindDirectory(kind);
    await targetDirectory.create(recursive: true);

    final target = File(
      p.join(targetDirectory.path, '$stableFileName$extension'),
    );
    await source.copy(target.path);

    final bytes = await target.length();
    final dimensions = kind == MediaKind.image
        ? await _tryReadImageDimensions(target)
        : null;

    return StoredMediaFile(
      localUri: target.uri.toString(),
      mimeType: _mimeTypeForExtension(extension, kind),
      sizeBytes: bytes,
      width: dimensions?.width,
      height: dimensions?.height,
      durationMs: null,
    );
  }

  @override
  Future<Set<String>> listManagedLocalUris() async {
    final root = await _rootDirectory();
    if (!await root.exists()) {
      return const {};
    }
    final files = await root
        .list(recursive: true)
        .where((entity) => entity is File)
        .cast<File>()
        .toList();
    return files.map((file) => file.uri.toString()).toSet();
  }

  @override
  Future<void> deleteByLocalUri(String localUri) async {
    final file = File.fromUri(Uri.parse(localUri));
    final root = await _rootDirectory();
    final filePath = p.normalize(file.absolute.path);
    final rootPath = p.normalize(root.absolute.path);
    if (!p.isWithin(rootPath, filePath) && filePath != rootPath) {
      throw DomainException(
        'Refusing to delete unmanaged media file: $localUri',
      );
    }
    if (await file.exists()) {
      await file.delete();
    }
  }

  @override
  Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
    List<LocalBackupResource> mediaAssets,
  ) async {
    final files = <EmbeddedBackupMediaFile>[];
    for (final resource in mediaAssets) {
      final localUri = resource.payload['localUri'];
      if (localUri is! String || localUri.isEmpty) {
        continue;
      }
      final file = _fileFromLocalUri(localUri);
      if (!await file.exists()) {
        continue;
      }
      final bytes = await file.readAsBytes();
      files.add(
        EmbeddedBackupMediaFile(
          mediaAssetId: resource.id,
          role: 'original',
          fileName: p.basename(file.path),
          mimeType: resource.payload['mimeType'] as String?,
          sizeBytes: bytes.length,
          sha256: crypto.sha256.convert(bytes).toString(),
          base64: base64Encode(bytes),
        ),
      );
    }
    return files;
  }

  @override
  Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
    List<EmbeddedBackupMediaFile> files,
  ) async {
    final restored = <String, RestoredMediaFile>{};
    final directory = await _restoredBackupDirectory();
    await directory.create(recursive: true);
    for (final file in files) {
      final bytes = switch (_decodeBase64OrNull(file.base64)) {
        final decoded? => decoded,
        null => null,
      };
      if (bytes == null) {
        continue;
      }
      if (crypto.sha256.convert(bytes).toString() != file.sha256) {
        continue;
      }
      final extension = p.extension(file.fileName).toLowerCase();
      final target = File(
        p.join(directory.path, '${file.mediaAssetId}$extension'),
      );
      await target.writeAsBytes(bytes, flush: true);
      restored[file.mediaAssetId] = RestoredMediaFile(
        mediaAssetId: file.mediaAssetId,
        localUri: target.uri.toString(),
        sizeBytes: bytes.length,
      );
    }
    return restored;
  }

  Future<Directory> _kindDirectory(MediaKind kind) async {
    final root = await _rootDirectory();
    final child = switch (kind) {
      MediaKind.image => 'images',
      MediaKind.video => 'videos',
    };
    return Directory(p.join(root.path, child));
  }

  Future<Directory> _rootDirectory() async {
    final documents = await getApplicationDocumentsDirectory();
    return Directory(p.join(documents.path, _mediaDirectoryName));
  }

  Future<Directory> _restoredBackupDirectory() async {
    final root = await _rootDirectory();
    return Directory(p.join(root.path, 'restored'));
  }
}

File _fileFromLocalUri(String localUri) {
  final uri = Uri.tryParse(localUri);
  if (uri != null && uri.scheme == 'file') {
    return File.fromUri(uri);
  }
  return File(localUri);
}

Uint8List? _decodeBase64OrNull(String value) {
  try {
    return base64Decode(value);
  } on FormatException {
    return null;
  }
}

final class _ImageDimensions {
  const _ImageDimensions(this.width, this.height);

  final int width;
  final int height;
}

Future<_ImageDimensions?> _tryReadImageDimensions(File file) async {
  final bytes = await file.openRead(0, 32).fold<BytesBuilder>(
    BytesBuilder(copy: false),
    (builder, chunk) {
      builder.add(chunk);
      return builder;
    },
  );
  final data = bytes.toBytes();
  return _readPngDimensions(data) ?? await _readJpegDimensions(file);
}

_ImageDimensions? _readPngDimensions(Uint8List data) {
  const signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
  if (data.length < 24) {
    return null;
  }
  for (var index = 0; index < signature.length; index++) {
    if (data[index] != signature[index]) {
      return null;
    }
  }
  final view = ByteData.sublistView(data);
  return _ImageDimensions(view.getUint32(16), view.getUint32(20));
}

Future<_ImageDimensions?> _readJpegDimensions(File file) async {
  final data = await file.readAsBytes();
  if (data.length < 4 || data[0] != 0xFF || data[1] != 0xD8) {
    return null;
  }
  var offset = 2;
  while (offset + 9 < data.length) {
    if (data[offset] != 0xFF) {
      return null;
    }
    final marker = data[offset + 1];
    final length = (data[offset + 2] << 8) + data[offset + 3];
    if (length < 2 || offset + 2 + length > data.length) {
      return null;
    }
    if ((marker >= 0xC0 && marker <= 0xC3) ||
        (marker >= 0xC5 && marker <= 0xC7) ||
        (marker >= 0xC9 && marker <= 0xCB) ||
        (marker >= 0xCD && marker <= 0xCF)) {
      final height = (data[offset + 5] << 8) + data[offset + 6];
      final width = (data[offset + 7] << 8) + data[offset + 8];
      return _ImageDimensions(width, height);
    }
    offset += 2 + length;
  }
  return null;
}

String? _mimeTypeForExtension(String extension, MediaKind kind) {
  return switch (extension) {
    '.jpg' || '.jpeg' => 'image/jpeg',
    '.png' => 'image/png',
    '.gif' => 'image/gif',
    '.webp' => 'image/webp',
    '.mp4' => 'video/mp4',
    '.mov' => 'video/quicktime',
    '.webm' => 'video/webm',
    _ => kind == MediaKind.image ? 'image/*' : 'video/*',
  };
}
