// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart' show immutable;
import 'package:path/path.dart' as path;

import 'common.dart';
import 'json_get.dart';
import 'layout_types.dart';

/// Signature for function that asynchonously returns a value.
typedef AsyncGetter<T> = Future<T> Function();

/// The filename of the local cache for the GraphQL response.
const String _githubCacheFileName = 'github-response.json';

/// The file of the remote repo to query.
const String _githubTargetFolder = 'src/vs/workbench/services/keybinding/browser/keyboardLayouts';

/// The full query string for GraphQL.
const String _githubQuery =
    '''
{
  repository(owner: "microsoft", name: "vscode") {
    defaultBranchRef {
       target {
        ... on Commit {
          history(first: 1) {
            nodes {
              oid
              file(path: "$_githubTargetFolder") {
                extension lineCount object {
                  ... on Tree {
                    entries {
                      name object {
                        ... on Blob {
                          text
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
''';

/// All goals in the form of KeyboardEvent.key.
final List<String> _kGoalKeys = kLayoutGoals.keys.toList();

/// A map from the key of `kLayoutGoals` (KeyboardEvent.key) to an
/// auto-incremental index.
final Map<String, int> _kGoalToIndex = Map<String, int>.fromEntries(
  _kGoalKeys.asMap().entries.map(
    (MapEntry<int, String> entry) => MapEntry<String, int>(entry.value, entry.key),
  ),
);

/// Retrieve a string using the procedure defined by `ifNotExist` based on the
/// cache file at `cachePath`.
///
/// If `forceRefresh` is false, this function tries to read the cache file, calls
/// `ifNotExist` when necessary, and writes the result to the cache.
///
/// If `forceRefresh` is true, this function never read the cache file, always
/// calls `ifNotExist` when necessary, and still writes the result to the cache.
///
/// Exceptions from `ifNotExist` will be thrown, while exceptions related to
/// caching are only printed.
Future<String> _tryCached(
  String cachePath,
  bool forceRefresh,
  AsyncGetter<String> ifNotExist,
) async {
  final File cacheFile = File(cachePath);
  if (!forceRefresh && cacheFile.existsSync()) {
    try {
      final String result = cacheFile.readAsStringSync();
      print('Using GitHub cache.');
      return result;
    } catch (exception) {
      print('Error reading GitHub cache, rebuilding. Details: $exception');
    }
  }
  final String result = await ifNotExist();
  IOSink? sink;
  try {
    print('Requesting from GitHub...');
    Directory(path.dirname(cachePath)).createSync(recursive: true);
    sink = cacheFile.openWrite();
    cacheFile.writeAsStringSync(result);
  } catch (exception) {
    print('Error writing GitHub cache. Details: $exception');
  } finally {
    sink?.close();
  }
  return result;
}

/// Make a GraphQL request, cache it, and return the result.
///
/// If `forceRefresh` is false, this function tries to read the cache file at
/// `cachePath`. Regardless of `forceRefresh`, the response is always recorded
/// in the cache file.
Future<Map<String, dynamic>> _fetchGithub(
  String githubToken,
  bool forceRefresh,
  String cachePath,
) async {
  final String response = await _tryCached(cachePath, forceRefresh, () async {
    final String condensedQuery = _githubQuery
        .replaceAll(RegExp(r'\{ +'), '{')
        .replaceAll(RegExp(r' +\}'), '}');
    final http.Response response = await http.post(
      Uri.parse('https://api.github.com/graphql'),
      headers: <String, String>{
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': 'bearer $githubToken',
      },
      body: jsonEncode(<String, String>{'query': condensedQuery}),
    );
    if (response.statusCode != 200) {
      throw Exception(
        'Request to GitHub failed with status code ${response.statusCode}: ${response.reasonPhrase}',
      );
    }
    return response.body;
  });
  return jsonDecode(response) as Map<String, dynamic>;
}

@immutable
class _GitHubFile {
  const _GitHubFile({required this.name, required this.content});

  final String name;
  final String content;
}

_GitHubFile _jsonGetGithubFile(JsonContext<JsonArray> files, int index) {
  final JsonContext<JsonObject> file = jsonGetIndex<JsonObject>(files, index);
  return _GitHubFile(
    name: jsonGetKey<String>(file, 'name').current,
    content: jsonGetPath<String>(file, 'object.text').current,
  );
}

/// Parses a literal JavaScript string that represents a character, which might
/// have been escaped or empty.
String _parsePrintable(String rawString, int isDeadKey) {
  // Parse a char represented in unicode hex, such as \u001b.
  final RegExp hexParser = RegExp(r'^\\u([0-9a-fA-F]+)$');

  if (isDeadKey != 0) {
    return LayoutEntry.kDeadKey;
  }
  if (rawString.isEmpty) {
    return '';
  }
  final RegExpMatch? hexMatch = hexParser.firstMatch(rawString);
  if (hexMatch != null) {
    final int codeUnit = int.parse(hexMatch.group(1)!, radix: 16);
    return String.fromCharCode(codeUnit);
  }
  return const <String, String>{
        r'\\': r'\',
        r'\r': '\r',
        r'\b': '\b',
        r'\t': '\t',
        r"\'": "'",
      }[rawString] ??
      rawString;
}

LayoutPlatform _platformFromGithubString(String origin) {
  switch (origin) {
    case 'win':
      return LayoutPlatform.win;
    case 'linux':
      return LayoutPlatform.linux;
    case 'darwin':
      return LayoutPlatform.darwin;
    default:
      throw ArgumentError('Unexpected platform "$origin".');
  }
}

/// Parses a single layout file.
Layout _parseLayoutFromGithubFile(_GitHubFile file) {
  final Map<String, LayoutEntry> entries = <String, LayoutEntry>{};

  // Parse a line that looks like the following, and get its key as well as
  // the content within the square bracket.
  //
  //    F19: [],
  //    KeyZ: ['y', 'Y', '', '', 0, 'VK_Y'],
  final RegExp lineParser = RegExp(r'^[ \t]*(.+?): \[(.*)\],$');
  // Parse each child of the content within the square bracket.
  final RegExp listParser = RegExp(r"^'(.*?)', '(.*?)', '(.*?)', '(.*?)', (\d)(?:, '(.+)')?$");
  file.content.split('\n').forEach((String line) {
    final RegExpMatch? lineMatch = lineParser.firstMatch(line);
    if (lineMatch == null) {
      return;
    }
    // KeyboardKey.code, such as "KeyZ".
    final String eventCode = lineMatch.group(1)!;
    // Only record goals.
    if (!_kGoalToIndex.containsKey(eventCode)) {
      return;
    }

    // Comma-separated definition as a string, such as "'y', 'Y', '', '', 0, 'VK_Y'".
    final String definition = lineMatch.group(2)!;
    if (definition.isEmpty) {
      return;
    }
    // Group 1-4 are single strings for an entry, such as "y", "", "\u001b".
    // Group 5 is the dead mask.
    final RegExpMatch? listMatch = listParser.firstMatch(definition);
    assert(listMatch != null, 'Unable to match $definition');
    final int deadMask = int.parse(listMatch!.group(5)!, radix: 10);

    entries[eventCode] = LayoutEntry(<String>[
      _parsePrintable(listMatch.group(1)!, deadMask & 0x1),
      _parsePrintable(listMatch.group(2)!, deadMask & 0x2),
      _parsePrintable(listMatch.group(3)!, deadMask & 0x4),
      _parsePrintable(listMatch.group(4)!, deadMask & 0x8),
    ]);
  });

  for (final String goalKey in _kGoalKeys) {
    entries.putIfAbsent(goalKey, () => LayoutEntry.empty);
  }

  // Parse the file name, which looks like "en-belgian.win.ts".
  final RegExp fileNameParser = RegExp(r'^([^.]+)\.([^.]+)\.ts$');
  late final Layout layout;
  try {
    final RegExpMatch? match = fileNameParser.firstMatch(file.name);
    final String layoutName = match!.group(1)!;
    final LayoutPlatform platform = _platformFromGithubString(match.group(2)!);
    layout = Layout(layoutName, platform, entries);
  } catch (exception) {
    throw ArgumentError('Unrecognizable file name ${file.name}.');
  }
  return layout;
}

/// Sort layouts by language first, then by platform.
int _sortLayout(Layout a, Layout b) {
  int result = a.language.compareTo(b.language);
  if (result == 0) {
    result = a.platform.index.compareTo(b.platform.index);
  }
  return result;
}

/// The overall results returned from the GitHub request.
class GithubResult {
  /// Create a [GithubResult].
  const GithubResult(this.layouts, this.url);

  /// All layouts, sorted.
  final List<Layout> layouts;

  /// The URL that points to the source folder of the VSCode GitHub repo,
  /// containing the correct commit hash.
  final String url;
}

/// Fetch necessary information from the VSCode GitHub repo.
///
/// The GraphQL request is made using the token `githubToken` (which requires
/// no extra access). The response is cached in files under directory
/// `cacheRoot`.
///
/// If `force` is false, this function tries to read the cache. Regardless of
/// `force`, the response is always recorded in the cache.
Future<GithubResult> fetchFromGithub({
  required String githubToken,
  required bool force,
  required String cacheRoot,
}) async {
  // Fetch files from GitHub.
  final Map<String, dynamic> githubBody = await _fetchGithub(
    githubToken,
    force,
    path.join(cacheRoot, _githubCacheFileName),
  );

  // Parse the result from GitHub.
  final JsonContext<JsonObject> commitJson = jsonGetPath<JsonObject>(
    JsonContext.root(githubBody),
    'data.repository.defaultBranchRef.target.history.nodes.0',
  );
  final String commitId = jsonGetKey<String>(commitJson, 'oid').current;
  final JsonContext<JsonArray> fileListJson = jsonGetPath<JsonArray>(
    commitJson,
    'file.object.entries',
  );
  final Iterable<_GitHubFile> files =
      Iterable<_GitHubFile>.generate(
        fileListJson.current.length,
        (int index) => _jsonGetGithubFile(fileListJson, index),
      ).where(
        // Exclude controlling files, which contain no layout information.
        (_GitHubFile file) =>
            !file.name.startsWith('layout.contribution.') &&
            !file.name.startsWith('_.contribution'),
      );

  // Layouts must be sorted to ensure that the output file has a fixed order.
  final List<Layout> layouts = files.map(_parseLayoutFromGithubFile).toList()..sort(_sortLayout);

  final String url = 'https://github.com/microsoft/vscode/tree/$commitId/$_githubTargetFolder';
  return GithubResult(layouts, url);

  //
}
