// 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:async';
import 'dart:typed_data';

import 'package:meta/meta.dart';
import 'package:native_stack_traces/native_stack_traces.dart';

import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../convert.dart';
import '../runner/flutter_command.dart';

const rootLoadingUnitId = 1;

/// Support for symbolizing a Dart stack trace.
///
/// This command accepts either paths to an input file containing the
/// stack trace and an output file for the symbolizing trace to be
/// written, or it accepts a stack trace over stdin and outputs it
/// over stdout.
class SymbolizeCommand extends FlutterCommand {
  SymbolizeCommand({
    required Stdio stdio,
    required FileSystem fileSystem,
    DwarfSymbolizationService dwarfSymbolizationService = const DwarfSymbolizationService(),
  }) : _stdio = stdio,
       _fileSystem = fileSystem,
       _dwarfSymbolizationService = dwarfSymbolizationService {
    argParser.addOption(
      'debug-info',
      abbr: 'd',
      valueHelp: '/out/android/app.arm64.symbols',
      help: 'A path to the symbols file generated with "--split-debug-info".',
    );
    argParser.addMultiOption(
      'unit-id-debug-info',
      abbr: 'u',
      valueHelp: '2:/out/android/app.arm64.symbols-2.part.so',
      help:
          'A loading unit id and the path to the symbols file for that'
          ' unit generated with "--split-debug-info".',
    );
    argParser.addOption(
      'input',
      abbr: 'i',
      valueHelp: '/crashes/stack_trace.err',
      help: 'A file path containing a Dart stack trace.',
    );
    argParser.addOption(
      'output',
      abbr: 'o',
      help: 'A file path for a symbolized stack trace to be written to.',
    );
  }

  final Stdio _stdio;
  final FileSystem _fileSystem;
  final DwarfSymbolizationService _dwarfSymbolizationService;

  @override
  String get description => 'Symbolize a stack trace from an AOT-compiled Flutter app.';

  @override
  String get name => 'symbolize';

  @override
  final String category = FlutterCommandCategory.tools;

  @override
  bool get shouldUpdateCache => false;

  File _handleDSYM(String fileName) {
    final FileSystemEntityType type = _fileSystem.typeSync(fileName);
    final bool isDSYM = fileName.endsWith('.dSYM');
    if (type == FileSystemEntityType.notFound) {
      throw FileNotFoundException(fileName);
    }
    if (type == FileSystemEntityType.directory) {
      if (!isDSYM) {
        throw StateError('$fileName is a directory, not a file');
      }
      final Directory dwarfDir = _fileSystem
          .directory(fileName)
          .childDirectory('Contents')
          .childDirectory('Resources')
          .childDirectory('DWARF');
      // The DWARF directory inside the .dSYM contains a single MachO file.
      return dwarfDir.listSync().single as File;
    }
    if (isDSYM) {
      throw StateError('$fileName is not a dSYM package directory');
    }
    return _fileSystem.file(fileName);
  }

  Map<int, File> _unitDebugInfoPathMap() {
    final map = <int, File>{};
    final String? rootInfo = stringArg('debug-info');
    if (rootInfo != null) {
      map[rootLoadingUnitId] = _handleDSYM(rootInfo);
    }
    for (final String arg in stringsArg('unit-id-debug-info')) {
      final int separatorIndex = arg.indexOf(':');
      final String unitIdString = arg.substring(0, separatorIndex);
      final int unitId = int.parse(unitIdString);
      final String unitDebugPath = arg.substring(separatorIndex + 1);
      if (map.containsKey(unitId) && map[unitId]!.path != unitDebugPath) {
        throw StateError(
          'Different paths were given for the same loading unit'
          ' $unitId: "${map[unitId]!.path}" and "$unitDebugPath".',
        );
      }
      map[unitId] = _handleDSYM(unitDebugPath);
    }
    return map;
  }

  @override
  Future<void> validateCommand() async {
    if (argResults?.wasParsed('debug-info') != true &&
        argResults?.wasParsed('unit-id-debug-info') != true) {
      throwToolExit(
        'Either "--debug-info" or "--unit-id-debug-info" is required to symbolize stack traces.',
      );
    }
    for (final String arg in stringsArg('unit-id-debug-info')) {
      final int separatorIndex = arg.indexOf(':');
      if (separatorIndex == -1) {
        throwToolExit(
          'The argument to "--unit-id-debug-info" must contain a unit ID and path,'
          ' separated by ":": "$arg".',
        );
      }
      final String unitIdString = arg.substring(0, separatorIndex);
      final int? unitId = int.tryParse(unitIdString);
      if (unitId == null) {
        throwToolExit(
          'The argument to "--unit-id-debug-info" must begin with'
          ' a unit ID: "$unitIdString" is not an integer.',
        );
      }
    }
    late final Map<int, File> map;
    try {
      map = _unitDebugInfoPathMap();
    } on Object catch (e) {
      throwToolExit(e.toString());
    }
    if (!map.containsKey(rootLoadingUnitId)) {
      throwToolExit(
        'Missing debug info for the root loading unit'
        ' (id $rootLoadingUnitId).',
      );
    }
    if ((argResults?.wasParsed('input') ?? false) &&
        !await _fileSystem.isFile(stringArg('input')!)) {
      throwToolExit('${stringArg('input')} does not exist.');
    }
    return super.validateCommand();
  }

  @override
  Future<FlutterCommandResult> runCommand() async {
    // Configure output to either specified file or stdout.
    late final IOSink output;
    if (argResults?.wasParsed('output') ?? false) {
      final File outputFile = _fileSystem.file(stringArg('output'));
      if (!outputFile.parent.existsSync()) {
        outputFile.parent.createSync(recursive: true);
      }
      output = outputFile.openWrite();
    } else {
      final outputController = StreamController<List<int>>();
      outputController.stream.transform(utf8.decoder).listen(_stdio.stdoutWrite);
      output = IOSink(outputController);
    }

    // Configure input from either specified file or stdin.
    final Stream<List<int>> input = (argResults?.wasParsed('input') ?? false)
        ? _fileSystem.file(stringArg('input')).openRead()
        : _stdio.stdin;

    final unitSymbols = <int, Uint8List>{
      for (final MapEntry<int, File> entry in _unitDebugInfoPathMap().entries)
        entry.key: entry.value.readAsBytesSync(),
    };

    await _dwarfSymbolizationService.decodeWithUnits(
      input: input,
      output: output,
      unitSymbols: unitSymbols,
    );

    return FlutterCommandResult.success();
  }
}

typedef SymbolsTransformer = StreamTransformer<String, String> Function(Uint8List);
typedef UnitSymbolsTransformer = StreamTransformer<String, String> Function(Map<int, Uint8List>);

StreamTransformer<String, String> _defaultTransformer(Uint8List symbols) {
  return _defaultUnitsTransformer(<int, Uint8List>{rootLoadingUnitId: symbols});
}

StreamTransformer<String, String> _defaultUnitsTransformer(Map<int, Uint8List> unitSymbols) {
  final map = <int, Dwarf>{};
  for (final int unitId in unitSymbols.keys) {
    final Uint8List symbols = unitSymbols[unitId]!;
    final Dwarf? dwarf = Dwarf.fromBytes(symbols);
    if (dwarf == null) {
      throwToolExit('Failed to decode symbols file for loading unit $unitId');
    }
    map[unitId] = dwarf;
  }
  if (!map.containsKey(rootLoadingUnitId)) {
    throwToolExit('Missing symbols file for root loading unit (id $rootLoadingUnitId)');
  }
  return DwarfStackTraceDecoder(
    map[rootLoadingUnitId]!,
    includeInternalFrames: true,
    dwarfByUnitId: map,
  );
}

// A no-op transformer for `DwarfSymbolizationService.test`
StreamTransformer<String, String> _testUnitsTransformer(Map<int, Uint8List> buffer) {
  return StreamTransformer<String, String>.fromHandlers(
    handleData: (String data, EventSink<String> sink) {
      sink.add(data);
    },
    handleDone: (EventSink<String> sink) {
      sink.close();
    },
    handleError: (Object error, StackTrace stackTrace, EventSink<String> sink) {
      sink.addError(error, stackTrace);
    },
  );
}

/// A service which decodes stack traces from Dart applications.
class DwarfSymbolizationService {
  const DwarfSymbolizationService({SymbolsTransformer symbolsTransformer = _defaultTransformer})
    : _transformer = symbolsTransformer,
      _unitsTransformer = _defaultUnitsTransformer;

  const DwarfSymbolizationService.withUnits({
    UnitSymbolsTransformer unitSymbolsTransformer = _defaultUnitsTransformer,
  }) : _transformer = null,
       _unitsTransformer = unitSymbolsTransformer;

  /// Create a DwarfSymbolizationService with a no-op transformer for testing.
  @visibleForTesting
  factory DwarfSymbolizationService.test() {
    return const DwarfSymbolizationService.withUnits(unitSymbolsTransformer: _testUnitsTransformer);
  }

  final SymbolsTransformer? _transformer;
  final UnitSymbolsTransformer _unitsTransformer;

  /// Decode a stack trace from [input] and place the results in [output].
  ///
  /// Requires [symbols] to be a buffer created from the `--split-debug-info`
  /// command line flag.
  ///
  /// Throws a [ToolExit] if the symbols cannot be parsed or the stack trace
  /// cannot be decoded.
  Future<void> decode({
    required Stream<List<int>> input,
    required IOSink output,
    required Uint8List symbols,
  }) async {
    await decodeWithUnits(
      input: input,
      output: output,
      unitSymbols: <int, Uint8List>{rootLoadingUnitId: symbols},
    );
  }

  /// Decode a stack trace from [input] and place the results in [output].
  ///
  /// Requires [unitSymbols] to map integer unit IDs to buffers created from
  /// the `--split-debug-info` command line flag.
  ///
  /// Throws a [ToolExit] if the symbols cannot be parsed or the stack trace
  /// cannot be decoded.
  Future<void> decodeWithUnits({
    required Stream<List<int>> input,
    required IOSink output,
    required Map<int, Uint8List> unitSymbols,
  }) async {
    final UnitSymbolsTransformer unitSymbolsTransformer = _transformer != null
        ? ((Map<int, Uint8List> m) => _transformer(m[rootLoadingUnitId]!))
        : _unitsTransformer;
    final onDone = Completer<void>();
    StreamSubscription<void>? subscription;
    subscription = input
        .cast<List<int>>()
        .transform(const Utf8Decoder())
        .transform(const LineSplitter())
        .transform(unitSymbolsTransformer(unitSymbols))
        .listen(
          (String line) {
            try {
              output.writeln(line);
            } on Exception catch (e, s) {
              subscription?.cancel().whenComplete(() {
                if (!onDone.isCompleted) {
                  onDone.completeError(e, s);
                }
              });
            }
          },
          onDone: onDone.complete,
          onError: onDone.completeError,
        );

    try {
      await onDone.future;
      await output.close();
    } on Exception catch (err) {
      throwToolExit('Failed to symbolize stack trace:\n $err');
    }
  }
}
