// 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:collection';

import 'package:meta/meta.dart';
import 'package:process/process.dart';
import 'package:xml/xml.dart';

import '../application_package.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/process.dart';
import '../base/user_messages.dart';
import '../build_info.dart';
import '../project.dart';
import 'android_sdk.dart';
import 'gradle.dart';

/// An application package created from an already built Android APK.
class AndroidApk extends ApplicationPackage implements PrebuiltApplicationPackage {
  AndroidApk({
    required super.id,
    required this.applicationPackage,
    required this.versionCode,
    required this.launchActivity,
  }) : assert(applicationPackage != null),
       assert(launchActivity != null);

  /// Creates a new AndroidApk from an existing APK.
  ///
  /// Returns `null` if the APK was invalid or any required tooling was missing.
  static AndroidApk? fromApk(
    File apk, {
    required AndroidSdk androidSdk,
    required ProcessManager processManager,
    required UserMessages userMessages,
    required Logger logger,
    required ProcessUtils processUtils,
  }) {
    final String? aaptPath = androidSdk.latestVersion?.aaptPath;
    if (aaptPath == null || !processManager.canRun(aaptPath)) {
      logger.printError(userMessages.aaptNotFound);
      return null;
    }

    String apptStdout;
    try {
      apptStdout = processUtils.runSync(
        <String>[
          aaptPath,
          'dump',
          'xmltree',
          apk.path,
          'AndroidManifest.xml',
        ],
        throwOnError: true,
      ).stdout.trim();
    } on ProcessException catch (error) {
      logger.printError('Failed to extract manifest from APK: $error.');
      return null;
    }

    final ApkManifestData? data = ApkManifestData.parseFromXmlDump(apptStdout, logger);

    if (data == null) {
      logger.printError('Unable to read manifest info from ${apk.path}.');
      return null;
    }

    final String? packageName = data.packageName;
    if (packageName == null || data.launchableActivityName == null) {
      logger.printError('Unable to read manifest info from ${apk.path}.');
      return null;
    }

    return AndroidApk(
      id: packageName,
      applicationPackage: apk,
      versionCode: data.versionCode == null ? null : int.tryParse(data.versionCode!),
      launchActivity: '${data.packageName}/${data.launchableActivityName}',
    );
  }

  @override
  final FileSystemEntity applicationPackage;

  /// The path to the activity that should be launched.
  final String launchActivity;

  /// The version code of the APK.
  final int? versionCode;

  /// Creates a new AndroidApk based on the information in the Android manifest.
  static Future<AndroidApk?> fromAndroidProject(
    AndroidProject androidProject, {
    required AndroidSdk? androidSdk,
    required ProcessManager processManager,
    required UserMessages userMessages,
    required ProcessUtils processUtils,
    required Logger logger,
    required FileSystem fileSystem,
    BuildInfo? buildInfo,
  }) async {
    final File apkFile;
    final String filename;
    if (buildInfo == null) {
      filename = 'app.apk';
    } else if (buildInfo.flavor == null) {
      filename = 'app-${buildInfo.mode.name}.apk';
    } else {
      filename = 'app-${buildInfo.lowerCasedFlavor}-${buildInfo.mode.name}.apk';
    }

    if (androidProject.isUsingGradle && androidProject.isSupportedVersion) {
      apkFile = getApkDirectory(androidProject.parent).childFile(filename);
      if (apkFile.existsSync()) {
        // Grab information from the .apk. The gradle build script might alter
        // the application Id, so we need to look at what was actually built.
        return AndroidApk.fromApk(
          apkFile,
          androidSdk: androidSdk!,
          processManager: processManager,
          logger: logger,
          userMessages: userMessages,
          processUtils: processUtils,
        );
      }
      // The .apk hasn't been built yet, so we work with what we have. The run
      // command will grab a new AndroidApk after building, to get the updated
      // IDs.
    } else {
      apkFile = fileSystem.file(fileSystem.path.join(getAndroidBuildDirectory(), filename));
    }

    final File manifest = androidProject.appManifestFile;

    if (!manifest.existsSync()) {
      logger.printError('AndroidManifest.xml could not be found.');
      logger.printError('Please check ${manifest.path} for errors.');
      return null;
    }

    final String manifestString = manifest.readAsStringSync();
    XmlDocument document;
    try {
      document = XmlDocument.parse(manifestString);
    } on XmlException catch (exception) {
      String manifestLocation;
      if (androidProject.isUsingGradle) {
        manifestLocation = fileSystem.path.join(androidProject.hostAppGradleRoot.path, 'app', 'src', 'main', 'AndroidManifest.xml');
      } else {
        manifestLocation = fileSystem.path.join(androidProject.hostAppGradleRoot.path, 'AndroidManifest.xml');
      }
      logger.printError('AndroidManifest.xml is not a valid XML document.');
      logger.printError('Please check $manifestLocation for errors.');
      throwToolExit('XML Parser error message: $exception');
    }

    final Iterable<XmlElement> manifests = document.findElements('manifest');
    if (manifests.isEmpty) {
      logger.printError('AndroidManifest.xml has no manifest element.');
      logger.printError('Please check ${manifest.path} for errors.');
      return null;
    }
    final String? packageId = manifests.first.getAttribute('package');

    String? launchActivity;
    for (final XmlElement activity in document.findAllElements('activity')) {
      final String? enabled = activity.getAttribute('android:enabled');
      if (enabled != null && enabled == 'false') {
        continue;
      }

      for (final XmlElement element in activity.findElements('intent-filter')) {
        String? actionName = '';
        String? categoryName = '';
        for (final XmlNode node in element.children) {
          if (node is! XmlElement) {
            continue;
          }
          final String? name = node.getAttribute('android:name');
          if (name == 'android.intent.action.MAIN') {
            actionName = name;
          } else if (name == 'android.intent.category.LAUNCHER') {
            categoryName = name;
          }
        }
        if (actionName != null && categoryName != null && actionName.isNotEmpty && categoryName.isNotEmpty) {
          final String? activityName = activity.getAttribute('android:name');
          launchActivity = '$packageId/$activityName';
          break;
        }
      }
    }

    if (packageId == null || launchActivity == null) {
      logger.printError('package identifier or launch activity not found.');
      logger.printError('Please check ${manifest.path} for errors.');
      return null;
    }

    return AndroidApk(
      id: packageId,
      applicationPackage: apkFile,
      versionCode: null,
      launchActivity: launchActivity,
    );
  }

  @override
  String get name => applicationPackage.basename;
}

abstract class _Entry {
  const _Entry(this.parent, this.level);

  final _Element? parent;
  final int level;
}

class _Element extends _Entry {
  _Element._(this.name, _Element? parent, int level) : super(parent, level);

  factory _Element.fromLine(String line, _Element? parent) {
    //      E: application (line=29)
    final List<String> parts = line.trimLeft().split(' ');
    return _Element._(parts[1], parent, line.length - line.trimLeft().length);
  }

  final List<_Entry> children = <_Entry>[];
  final String? name;

  void addChild(_Entry child) {
    children.add(child);
  }

  _Attribute? firstAttribute(String name) {
    for (final _Attribute child in children.whereType<_Attribute>()) {
      if (child.key?.startsWith(name) ?? false) {
        return child;
      }
    }
    return null;
  }

  _Element? firstElement(String name) {
    for (final _Element child in children.whereType<_Element>()) {
      if (child.name?.startsWith(name) ?? false) {
        return child;
      }
    }
    return null;
  }

  Iterable<_Element> allElements(String name) {
    return children.whereType<_Element>().where((_Element e) => e.name?.startsWith(name) ?? false);
  }
}

class _Attribute extends _Entry {
  const _Attribute._(this.key, this.value, _Element? parent, int level) : super(parent, level);

  factory _Attribute.fromLine(String line, _Element parent) {
    //     A: android:label(0x01010001)="hello_world" (Raw: "hello_world")
    const String attributePrefix = 'A: ';
    final List<String> keyVal = line.substring(line.indexOf(attributePrefix) + attributePrefix.length).split('=');
    return _Attribute._(keyVal[0], keyVal[1], parent, line.length - line.trimLeft().length);
  }

  final String? key;
  final String? value;
}

class ApkManifestData {
  ApkManifestData._(this._data);

  static bool _isAttributeWithValuePresent(
      _Element baseElement, String childElement, String attributeName, String attributeValue) {
    final Iterable<_Element> allElements = baseElement.allElements(childElement);
    for (final _Element oneElement in allElements) {
      final String? elementAttributeValue = oneElement
          .firstAttribute(attributeName)
          ?.value;
      if (elementAttributeValue != null &&
          elementAttributeValue.startsWith(attributeValue)) {
        return true;
      }
    }
    return false;
  }

  static ApkManifestData? parseFromXmlDump(String data, Logger logger) {
    if (data == null || data.trim().isEmpty) {
      return null;
    }

    final List<String> lines = data.split('\n');
    assert(lines.length > 3);

    final int manifestLine = lines.indexWhere((String line) => line.contains('E: manifest'));
    final _Element manifest = _Element.fromLine(lines[manifestLine], null);
    _Element currentElement = manifest;

    for (final String line in lines.skip(manifestLine)) {
      final String trimLine = line.trimLeft();
      final int level = line.length - trimLine.length;

      // Handle level out
      while (currentElement.parent != null && level <= currentElement.level) {
        currentElement = currentElement.parent!;
      }

      if (level > currentElement.level) {
        switch (trimLine[0]) {
          case 'A':
            currentElement
                .addChild(_Attribute.fromLine(line, currentElement));
            break;
          case 'E':
            final _Element element = _Element.fromLine(line, currentElement);
            currentElement.addChild(element);
            currentElement = element;
        }
      }
    }

    final _Element? application = manifest.firstElement('application');
    if (application == null) {
      return null;
    }

    final Iterable<_Element> activities = application.allElements('activity');

    _Element? launchActivity;
    for (final _Element activity in activities) {
      final _Attribute? enabled = activity.firstAttribute('android:enabled');
      final Iterable<_Element> intentFilters = activity.allElements('intent-filter');
      final bool isEnabledByDefault = enabled == null;
      final bool isExplicitlyEnabled = enabled != null && (enabled.value?.contains('0xffffffff') ?? false);
      if (!(isEnabledByDefault || isExplicitlyEnabled)) {
        continue;
      }

      for (final _Element element in intentFilters) {
        final bool isMainAction = _isAttributeWithValuePresent(
            element, 'action', 'android:name', '"android.intent.action.MAIN"');
        if (!isMainAction) {
          continue;
        }
        final bool isLauncherCategory = _isAttributeWithValuePresent(
            element, 'category', 'android:name',
            '"android.intent.category.LAUNCHER"');
        if (!isLauncherCategory) {
          continue;
        }
        launchActivity = activity;
        break;
      }
      if (launchActivity != null) {
        break;
      }
    }

    final _Attribute? package = manifest.firstAttribute('package');
    // "io.flutter.examples.hello_world" (Raw: "io.flutter.examples.hello_world")
    final String? packageName = package?.value?.substring(1, package.value?.indexOf('" '));

    if (launchActivity == null) {
      logger.printError('Error running $packageName. Default activity not found');
      return null;
    }

    final _Attribute? nameAttribute = launchActivity.firstAttribute('android:name');
    // "io.flutter.examples.hello_world.MainActivity" (Raw: "io.flutter.examples.hello_world.MainActivity")
    final String? activityName = nameAttribute?.value?.substring(1, nameAttribute.value?.indexOf('" '));

    // Example format: (type 0x10)0x1
    final _Attribute? versionCodeAttr = manifest.firstAttribute('android:versionCode');
    if (versionCodeAttr == null) {
      logger.printError('Error running $packageName. Manifest versionCode not found');
      return null;
    }
    if (versionCodeAttr.value?.startsWith('(type 0x10)') != true) {
      logger.printError('Error running $packageName. Manifest versionCode invalid');
      return null;
    }
    final int? versionCode = versionCodeAttr.value == null ? null : int.tryParse(versionCodeAttr.value!.substring(11));
    if (versionCode == null) {
      logger.printError('Error running $packageName. Manifest versionCode invalid');
      return null;
    }

    final Map<String, Map<String, String>> map = <String, Map<String, String>>{
      if (packageName != null)
        'package': <String, String>{'name': packageName},
      'version-code': <String, String>{'name': versionCode.toString()},
      if (activityName != null)
        'launchable-activity': <String, String>{'name': activityName},
    };

    return ApkManifestData._(map);
  }

  final Map<String, Map<String, String>> _data;

  @visibleForTesting
  Map<String, Map<String, String>> get data =>
      UnmodifiableMapView<String, Map<String, String>>(_data);

  String? get packageName => _data['package'] == null ? null : _data['package']?['name'];

  String? get versionCode => _data['version-code'] == null ? null : _data['version-code']?['name'];

  String? get launchableActivityName {
    return _data['launchable-activity'] == null ? null : _data['launchable-activity']?['name'];
  }

  @override
  String toString() => _data.toString();
}
