/// Generates lib/data/hid_key_labels.dart from the curated offline HID catalog.
///
/// Usage:
///   dart run scripts/generate_hid_key_labels.dart [input.json] [output.dart]
library;

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

const defaultHidKeyLabelsInput = 'scripts/data/hid_key_labels.json';
const defaultHidKeyLabelsOutput = 'lib/data/hid_key_labels.dart';

final class HidKeyLabel {
  const HidKeyLabel({required this.id, required this.label});

  final String id;
  final String label;

  int get numericId => int.parse(id, radix: 16);
}

final class HidKeyGroup {
  const HidKeyGroup({required this.name, required this.keys});

  final String name;
  final List<HidKeyLabel> keys;
}

final class HidKeyCatalog {
  const HidKeyCatalog({required this.groups});

  final List<HidKeyGroup> groups;
}

typedef AtomicFileWriter = Future<void> Function(String path, String contents);

HidKeyCatalog parseHidKeyLabelsCatalog(String source) {
  final Object? decoded;
  try {
    decoded = jsonDecode(source);
  } on FormatException catch (error) {
    throw FormatException('Invalid HID catalog JSON: ${error.message}');
  }

  final root = _expectMap(decoded, 'catalog');
  if (root['schemaVersion'] != 1) {
    throw const FormatException('HID catalog schemaVersion must be 1');
  }

  final rawGroups = _expectList(root['groups'], 'groups');
  if (rawGroups.isEmpty) {
    throw const FormatException('HID catalog groups must not be empty');
  }

  final groups = <HidKeyGroup>[];
  final groupNames = <String>{};
  final ids = <String>{};
  var previousId = -1;

  for (var groupIndex = 0; groupIndex < rawGroups.length; groupIndex++) {
    final rawGroup = _expectMap(rawGroups[groupIndex], 'groups[$groupIndex]');
    final name = _expectNonemptyString(rawGroup['name'], 'groups[$groupIndex].name');
    if (!groupNames.add(name)) {
      throw FormatException('Duplicate HID group name: $name');
    }

    final rawKeys = _expectList(rawGroup['keys'], 'groups[$groupIndex].keys');
    if (rawKeys.isEmpty) {
      throw FormatException('HID group "$name" must contain at least one key');
    }

    final keys = <HidKeyLabel>[];
    for (var keyIndex = 0; keyIndex < rawKeys.length; keyIndex++) {
      final path = 'groups[$groupIndex].keys[$keyIndex]';
      final rawKey = _expectMap(rawKeys[keyIndex], path);
      final id = _expectString(rawKey['id'], '$path.id');
      if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(id)) {
        throw FormatException('$path.id must be exactly eight lowercase hexadecimal digits');
      }
      if (!ids.add(id)) {
        throw FormatException('Duplicate HID key ID: $id');
      }

      final numericId = int.parse(id, radix: 16);
      if (numericId <= previousId) {
        throw FormatException('HID key IDs must be in strictly increasing numeric order: $id');
      }
      previousId = numericId;

      final label = _expectNonemptyString(rawKey['label'], '$path.label');
      keys.add(HidKeyLabel(id: id, label: label));
    }
    groups.add(HidKeyGroup(name: name, keys: List.unmodifiable(keys)));
  }

  return HidKeyCatalog(groups: List.unmodifiable(groups));
}

String renderHidKeyLabels(HidKeyCatalog catalog) {
  final output = StringBuffer()
    ..writeln(
      '// Generated by dart run scripts/generate_hid_key_labels.dart from '
      'scripts/data/hid_key_labels.json; do not edit by hand.',
    )
    ..writeln()
    ..writeln('/// Human-readable labels for physical keyboard keys, keyed by USB HID usage code.')
    ..writeln('const hidKeyLabels = <int, String>{');

  for (final group in catalog.groups) {
    output.writeln('  // ${group.name}');
    for (final key in group.keys) {
      output.writeln('  0x${key.id}: ${_dartString(key.label)},');
    }
  }

  output.writeln('};');
  return output.toString();
}

Future<void> writeFileAtomically(String path, String contents) async {
  final output = File(path);
  await output.parent.create(recursive: true);
  final temporary = File('$path.tmp.$pid.${DateTime.now().microsecondsSinceEpoch}');
  try {
    await temporary.writeAsString(contents, flush: true);
    await temporary.rename(path);
  } finally {
    if (await temporary.exists()) {
      await temporary.delete();
    }
  }
}

Future<void> generateHidKeyLabels(
  String inputPath,
  String outputPath, {
  AtomicFileWriter writer = writeFileAtomically,
}) async {
  final source = await File(inputPath).readAsString();
  final catalog = parseHidKeyLabelsCatalog(source);
  final rendered = renderHidKeyLabels(catalog);
  await writer(outputPath, rendered);
}

Future<void> main(List<String> arguments) async {
  if (arguments.length > 2) {
    stderr.writeln('Usage: dart run scripts/generate_hid_key_labels.dart [input.json] [output.dart]');
    exitCode = 64;
    return;
  }

  final inputPath = arguments.isEmpty ? defaultHidKeyLabelsInput : arguments[0];
  final outputPath = arguments.length < 2 ? defaultHidKeyLabelsOutput : arguments[1];
  await generateHidKeyLabels(inputPath, outputPath);
}

Map<String, Object?> _expectMap(Object? value, String path) {
  if (value is! Map<String, Object?>) {
    throw FormatException('$path must be a JSON object');
  }
  return value;
}

List<Object?> _expectList(Object? value, String path) {
  if (value is! List<Object?>) {
    throw FormatException('$path must be a JSON array');
  }
  return value;
}

String _expectString(Object? value, String path) {
  if (value is! String) {
    throw FormatException('$path must be a string');
  }
  return value;
}

String _expectNonemptyString(Object? value, String path) {
  final string = _expectString(value, path);
  if (string.trim().isEmpty) {
    throw FormatException('$path must not be empty');
  }
  return string;
}

String _dartString(String value) {
  if (value == r'\') return r"r'\'";

  if (value.contains("'")) {
    final escaped = value
        .replaceAll(r'\', r'\\')
        .replaceAll('"', r'\"')
        .replaceAll(r'$', r'\$')
        .replaceAll('\r', r'\r')
        .replaceAll('\n', r'\n')
        .replaceAll('\t', r'\t');
    return '"$escaped"';
  }

  final escaped = value
      .replaceAll(r'\', r'\\')
      .replaceAll("'", r"\'")
      .replaceAll(r'$', r'\$')
      .replaceAll('\r', r'\r')
      .replaceAll('\n', r'\n')
      .replaceAll('\t', r'\t');
  return "'$escaped'";
}
