// 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 'package:meta/meta.dart';
import 'package:process/process.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

import '../base/common.dart';
import '../base/error_handling_io.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/process.dart';
import '../base/template.dart';
import '../convert.dart';
import '../macos/xcode.dart';
import '../template.dart';

/// A class to handle interacting with Xcode via OSA (Open Scripting Architecture)
/// Scripting to debug Flutter applications.
class XcodeDebug {
  XcodeDebug({
    required Logger logger,
    required ProcessManager processManager,
    required Xcode xcode,
    required FileSystem fileSystem,
  })  : _logger = logger,
        _processUtils = ProcessUtils(logger: logger, processManager: processManager),
        _xcode = xcode,
        _fileSystem = fileSystem;

  final ProcessUtils _processUtils;
  final Logger _logger;
  final Xcode _xcode;
  final FileSystem _fileSystem;

  /// Process to start Xcode's debug action.
  @visibleForTesting
  Process? startDebugActionProcess;

  /// Information about the project that is currently being debugged.
  @visibleForTesting
  XcodeDebugProject? currentDebuggingProject;

  /// Whether the debug action has been started.
  bool get debugStarted => currentDebuggingProject != null;

  /// Install, launch, and start a debug session for app through Xcode interface,
  /// automated by OSA scripting. First checks if the project is opened in
  /// Xcode. If it isn't, open it with the `open` command.
  ///
  /// The OSA script waits until the project is opened and the debug action
  /// has started. It does not wait for the app to install, launch, or start
  /// the debug session.
  Future<bool> debugApp({
    required XcodeDebugProject project,
    required String deviceId,
    required List<String> launchArguments,
  }) async {
    // If project is not already opened in Xcode, open it.
    if (!await _isProjectOpenInXcode(project: project)) {
      final bool openResult = await _openProjectInXcode(xcodeWorkspace: project.xcodeWorkspace);
      if (!openResult) {
        return openResult;
      }
    }

    currentDebuggingProject = project;
    StreamSubscription<String>? stdoutSubscription;
    StreamSubscription<String>? stderrSubscription;
    try {
      startDebugActionProcess = await _processUtils.start(
        <String>[
          ..._xcode.xcrunCommand(),
          'osascript',
          '-l',
          'JavaScript',
          _xcode.xcodeAutomationScriptPath,
          'debug',
          '--xcode-path',
          _xcode.xcodeAppPath,
          '--project-path',
          project.xcodeProject.path,
          '--workspace-path',
          project.xcodeWorkspace.path,
          '--project-name',
          project.hostAppProjectName,
          if (project.expectedConfigurationBuildDir != null)
            ...<String>[
              '--expected-configuration-build-dir',
              project.expectedConfigurationBuildDir!,
            ],
          '--device-id',
          deviceId,
          '--scheme',
          project.scheme,
          '--skip-building',
          '--launch-args',
          json.encode(launchArguments),
          if (project.verboseLogging) '--verbose',
        ],
      );

      final StringBuffer stdoutBuffer = StringBuffer();
      stdoutSubscription = startDebugActionProcess!.stdout
          .transform<String>(utf8.decoder)
          .transform<String>(const LineSplitter())
          .listen((String line) {
            _logger.printTrace(line);
            stdoutBuffer.write(line);
      });

      final StringBuffer stderrBuffer = StringBuffer();
      bool permissionWarningPrinted = false;
      // console.log from the script are found in the stderr
      stderrSubscription = startDebugActionProcess!.stderr
          .transform<String>(utf8.decoder)
          .transform<String>(const LineSplitter())
          .listen((String line) {
            _logger.printTrace('stderr: $line');
            stderrBuffer.write(line);

          // This error may occur if Xcode automation has not been allowed.
          // Example: Failed to get workspace: Error: An error occurred.
          if (!permissionWarningPrinted && line.contains('Failed to get workspace') && line.contains('An error occurred')) {
            _logger.printError(
              'There was an error finding the project in Xcode. Ensure permission '
              'has been given to control Xcode in Settings > Privacy & Security > Automation.',
            );
            permissionWarningPrinted = true;
          }
      });

      final int exitCode = await startDebugActionProcess!.exitCode.whenComplete(() async {
        await stdoutSubscription?.cancel();
        await stderrSubscription?.cancel();
        startDebugActionProcess = null;
      });

      if (exitCode != 0) {
        _logger.printError('Error executing osascript: $exitCode\n$stderrBuffer');
        return false;
      }

      final XcodeAutomationScriptResponse? response = parseScriptResponse(
        stdoutBuffer.toString(),
      );
      if (response == null) {
        return false;
      }
      if (response.status == false) {
        _logger.printError('Error starting debug session in Xcode: ${response.errorMessage}');
        return false;
      }
      if (response.debugResult == null) {
        _logger.printError('Unable to get debug results from response: $stdoutBuffer');
        return false;
      }
      if (response.debugResult?.status != 'running') {
        _logger.printError(
          'Unexpected debug results: \n'
          '  Status: ${response.debugResult?.status}\n'
          '  Completed: ${response.debugResult?.completed}\n'
          '  Error Message: ${response.debugResult?.errorMessage}\n'
        );
        return false;
      }
      return true;
    } on ProcessException catch (exception) {
      _logger.printError('Error executing osascript: $exitCode\n$exception');
      await stdoutSubscription?.cancel();
      await stderrSubscription?.cancel();
      startDebugActionProcess = null;

      return false;
    }
  }

  /// Kills [startDebugActionProcess] if it's still running. If [force] is true, it
  /// will kill all Xcode app processes. Otherwise, it will stop the debug
  /// session in Xcode. If the project is temporary, it will close the Xcode
  /// window of the project and then delete the project.
  Future<bool> exit({
    bool force = false,
    @visibleForTesting
    bool skipDelay = false,
  }) async {
    final bool success = (startDebugActionProcess == null) || startDebugActionProcess!.kill();

    if (force) {
      await _forceExitXcode();
      if (currentDebuggingProject != null) {
        final XcodeDebugProject project = currentDebuggingProject!;
        if (project.isTemporaryProject) {
          // Only delete if it exists. This is to prevent crashes when racing
          // with shutdown hooks to delete temporary files.
          ErrorHandlingFileSystem.deleteIfExists(
            project.xcodeProject.parent,
            recursive: true,
          );
        }
        currentDebuggingProject = null;
      }
    }

    if (currentDebuggingProject != null) {
      final XcodeDebugProject project = currentDebuggingProject!;
      await stopDebuggingApp(
        project: project,
        closeXcode: project.isTemporaryProject,
      );

      if (project.isTemporaryProject) {
        // Wait a couple seconds before deleting the project. If project is
        // still opened in Xcode and it's deleted, it will prompt the user to
        // restore it.
        if (!skipDelay) {
          await Future<void>.delayed(const Duration(seconds: 2));
        }

        try {
          project.xcodeProject.parent.deleteSync(recursive: true);
        } on FileSystemException {
          _logger.printError('Failed to delete temporary Xcode project: ${project.xcodeProject.parent.path}');
        }
      }
      currentDebuggingProject = null;
    }

    return success;
  }

  /// Kill all opened Xcode applications.
  Future<bool> _forceExitXcode() async {
    final RunResult result = await _processUtils.run(
      <String>[
        'killall',
        '-9',
        'Xcode',
      ],
    );

    if (result.exitCode != 0) {
      _logger.printError('Error killing Xcode: ${result.exitCode}\n${result.stderr}');
      return false;
    }
    return true;
  }

  Future<bool> _isProjectOpenInXcode({
    required XcodeDebugProject project,
  }) async {

    final RunResult result = await _processUtils.run(
      <String>[
        ..._xcode.xcrunCommand(),
        'osascript',
        '-l',
        'JavaScript',
        _xcode.xcodeAutomationScriptPath,
        'check-workspace-opened',
        '--xcode-path',
        _xcode.xcodeAppPath,
        '--project-path',
        project.xcodeProject.path,
        '--workspace-path',
        project.xcodeWorkspace.path,
        if (project.verboseLogging) '--verbose',
      ],
    );

    if (result.exitCode != 0) {
      _logger.printError('Error executing osascript: ${result.exitCode}\n${result.stderr}');
      return false;
    }

    final XcodeAutomationScriptResponse? response = parseScriptResponse(result.stdout);
    if (response == null) {
      return false;
    }
    if (response.status == false) {
      _logger.printTrace('Error checking if project opened in Xcode: ${response.errorMessage}');
      return false;
    }
    return true;
  }

  @visibleForTesting
  XcodeAutomationScriptResponse? parseScriptResponse(String results) {
    try {
      final Object decodeResult = json.decode(results) as Object;
      if (decodeResult is Map<String, Object?>) {
        final XcodeAutomationScriptResponse response = XcodeAutomationScriptResponse.fromJson(decodeResult);
        // Status should always be found
        if (response.status != null) {
          return response;
        }
      }
      _logger.printError('osascript returned unexpected JSON response: $results');
      return null;
    } on FormatException {
      _logger.printError('osascript returned non-JSON response: $results');
      return null;
    }
  }

  Future<bool> _openProjectInXcode({
    required Directory xcodeWorkspace,
  }) async {
    try {
      await _processUtils.run(
        <String>[
          'open',
          '-a',
          _xcode.xcodeAppPath,
          '-g', // Do not bring the application to the foreground.
          '-j', // Launches the app hidden.
          '-F', // Open "fresh", without restoring windows.
          xcodeWorkspace.path
        ],
        throwOnError: true,
      );
      return true;
    } on ProcessException catch (error, stackTrace) {
      _logger.printError('$error', stackTrace: stackTrace);
    }
    return false;
  }

  /// Using OSA Scripting, stop the debug session in Xcode.
  ///
  /// If [closeXcode] is true, it will close the Xcode window that has the
  /// project opened. If [promptToSaveOnClose] is true, it will ask the user if
  /// they want to save any changes before it closes.
  Future<bool> stopDebuggingApp({
    required XcodeDebugProject project,
    bool closeXcode = false,
    bool promptToSaveOnClose = false,
  }) async {
    final RunResult result = await _processUtils.run(
      <String>[
        ..._xcode.xcrunCommand(),
        'osascript',
        '-l',
        'JavaScript',
        _xcode.xcodeAutomationScriptPath,
        'stop',
        '--xcode-path',
        _xcode.xcodeAppPath,
        '--project-path',
        project.xcodeProject.path,
        '--workspace-path',
        project.xcodeWorkspace.path,
        if (closeXcode) '--close-window',
        if (promptToSaveOnClose) '--prompt-to-save',
        if (project.verboseLogging) '--verbose',
      ],
    );

    if (result.exitCode != 0) {
      _logger.printError('Error executing osascript: ${result.exitCode}\n${result.stderr}');
      return false;
    }

    final XcodeAutomationScriptResponse? response = parseScriptResponse(result.stdout);
    if (response == null) {
      return false;
    }
    if (response.status == false) {
      _logger.printError('Error stopping app in Xcode: ${response.errorMessage}');
      return false;
    }
    return true;
  }

  /// Create a temporary empty Xcode project with the application bundle
  /// location explicitly set.
  Future<XcodeDebugProject> createXcodeProjectWithCustomBundle(
    String deviceBundlePath, {
    required TemplateRenderer templateRenderer,
    @visibleForTesting
    Directory? projectDestination,
    bool verboseLogging = false,
  }) async {
    final Directory tempXcodeProject = projectDestination ?? _fileSystem.systemTempDirectory.createTempSync('flutter_empty_xcode.');

    final Template template = await Template.fromName(
      _fileSystem.path.join('xcode', 'ios', 'custom_application_bundle'),
      fileSystem: _fileSystem,
      templateManifest: null,
      logger: _logger,
      templateRenderer: templateRenderer,
    );

    template.render(
      tempXcodeProject,
      <String, Object>{
        'applicationBundlePath': deviceBundlePath
      },
      printStatusWhenWriting: false,
    );

    return XcodeDebugProject(
      scheme: 'Runner',
      hostAppProjectName: 'Runner',
      xcodeProject: tempXcodeProject.childDirectory('Runner.xcodeproj'),
      xcodeWorkspace: tempXcodeProject.childDirectory('Runner.xcworkspace'),
      isTemporaryProject: true,
      verboseLogging: verboseLogging,
    );
  }

  /// Ensure the Xcode project is set up to launch an LLDB debugger. If these
  /// settings are not set, the launch will fail with a "Cannot create a
  /// FlutterEngine instance in debug mode without Flutter tooling or Xcode."
  /// error message. These settings should be correct by default, but some users
  /// reported them not being so after upgrading to Xcode 15.
  void ensureXcodeDebuggerLaunchAction(File schemeFile) {
    if (!schemeFile.existsSync()) {
      _logger.printError('Failed to find ${schemeFile.path}');
      return;
    }

    final String schemeXml = schemeFile.readAsStringSync();
    try {
      final XmlDocument document = XmlDocument.parse(schemeXml);
      final Iterable<XmlNode> nodes = document.xpath('/Scheme/LaunchAction');
      if (nodes.isEmpty) {
        _logger.printError('Failed to find LaunchAction for the Scheme in ${schemeFile.path}.');
        return;
      }
      final XmlNode launchAction = nodes.first;
      final XmlAttribute? debuggerIdentifer = launchAction.attributes
          .where((XmlAttribute attribute) =>
              attribute.localName == 'selectedDebuggerIdentifier')
          .firstOrNull;
      final XmlAttribute? launcherIdentifer = launchAction.attributes
          .where((XmlAttribute attribute) =>
              attribute.localName == 'selectedLauncherIdentifier')
          .firstOrNull;
      if (debuggerIdentifer == null ||
          launcherIdentifer == null ||
          !debuggerIdentifer.value.contains('LLDB') ||
          !launcherIdentifer.value.contains('LLDB')) {
        throwToolExit('''
Your Xcode project is not setup to start a debugger. To fix this, launch Xcode
and select "Product > Scheme > Edit Scheme", select "Run" in the sidebar,
and ensure "Debug executable" is checked in the "Info" tab.
''');
      }
    } on XmlException catch (exception) {
      _logger.printError('Failed to parse ${schemeFile.path}: $exception');
    }
  }
}

@visibleForTesting
class XcodeAutomationScriptResponse {
  XcodeAutomationScriptResponse._({
    this.status,
    this.errorMessage,
    this.debugResult,
  });

  factory XcodeAutomationScriptResponse.fromJson(Map<String, Object?> data) {
    XcodeAutomationScriptDebugResult? debugResult;
    if (data['debugResult'] != null && data['debugResult'] is Map<String, Object?>) {
      debugResult = XcodeAutomationScriptDebugResult.fromJson(
        data['debugResult']! as Map<String, Object?>,
      );
    }
    return XcodeAutomationScriptResponse._(
      status: data['status'] is bool? ? data['status'] as bool? : null,
      errorMessage: data['errorMessage']?.toString(),
      debugResult: debugResult,
    );
  }

  final bool? status;
  final String? errorMessage;
  final XcodeAutomationScriptDebugResult? debugResult;
}

@visibleForTesting
class XcodeAutomationScriptDebugResult {
  XcodeAutomationScriptDebugResult._({
    required this.completed,
    required this.status,
    required this.errorMessage,
  });

  factory XcodeAutomationScriptDebugResult.fromJson(Map<String, Object?> data) {
    return XcodeAutomationScriptDebugResult._(
      completed: data['completed'] is bool? ? data['completed'] as bool? : null,
      status: data['status']?.toString(),
      errorMessage: data['errorMessage']?.toString(),
    );
  }

  /// Whether this scheme action has completed (sucessfully or otherwise). Will
  /// be false if still running.
  final bool? completed;

  /// The status of the debug action. Potential statuses include:
  /// `not yet started`, `‌running`, `‌cancelled`, `‌failed`, `‌error occurred`,
  /// and `‌succeeded`.
  ///
  /// Only the status of `‌running` indicates the debug action has started successfully.
  /// For example, `‌succeeded` often does not indicate success as if the action fails,
  /// it will sometimes return `‌succeeded`.
  final String? status;

  /// When [status] is `‌error occurred`, an error message is provided.
  /// Otherwise, this will be null.
  final String? errorMessage;
}

class XcodeDebugProject {
  XcodeDebugProject({
    required this.scheme,
    required this.xcodeWorkspace,
    required this.xcodeProject,
    required this.hostAppProjectName,
    this.expectedConfigurationBuildDir,
    this.isTemporaryProject = false,
    this.verboseLogging = false,
  });

  final String scheme;
  final Directory xcodeWorkspace;
  final Directory xcodeProject;
  final String hostAppProjectName;
  final String? expectedConfigurationBuildDir;
  final bool isTemporaryProject;

  /// When [verboseLogging] is true, the xcode_debug.js script will log
  /// additional information via console.log, which is sent to stderr.
  final bool verboseLogging;
}
