// Copyright (c) 2025, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

// This test checks various command line options related to the Mach-O
// snapshot writer. Note that some of these options may make the written
// snapshot unrunnable, as they are meant to be used in a larger workflow
// (e.g., not emitting a code signature because it will be added later by
// XCode).

// OtherResources=use_save_debugging_info_flag_program.dart

import "dart:io";

import 'package:expect/expect.dart';
import 'package:native_stack_traces/src/macho.dart' as macho;
import 'package:path/path.dart' as path;

import 'use_flag_test_helper.dart';

Future<void> main(List<String> args) async {
  if (!isAOTRuntime) {
    return; // Running in JIT: AOT binaries not available.
  }

  if (Platform.isAndroid) {
    return; // SDK tree and dart_bootstrap not available on the test device.
  }

  // These are the tools we need to be available to run on a given platform:
  if (!await testExecutable(genSnapshot)) {
    throw "Cannot run test as $genSnapshot not available";
  }
  if (!await testExecutable(dartPrecompiledRuntime)) {
    throw "Cannot run test as $dartPrecompiledRuntime not available";
  }
  if (!File(platformDill).existsSync()) {
    throw "Cannot run test as $platformDill does not exist";
  }

  await withTempDir('use-macho-options-test', (String tempDir) async {
    final cwDir = path.dirname(Platform.script.toFilePath());
    final script = path.join(
      cwDir,
      'use_save_debugging_info_flag_program.dart',
    );
    final scriptDill = path.join(tempDir, 'flag_program.dill');

    // Compile script to Kernel IR.
    await run(genKernel, <String>[
      '--aot',
      '--platform=$platformDill',
      '-o',
      scriptDill,
      script,
    ]);

    final testCases = <TestCase>[
      for (final t in testsToRun) await compileSnapshot(tempDir, scriptDill, t),
    ];
    await checkCases(testCases);
  });
}

final canRetrieveDebugMap =
    // The identifier isn't provided on these platforms due to the lack
    // of a basename implementation, so no debug map can be extracted.
    !Platform.isWindows &&
    !Platform.isFuchsia &&
    // clangBuildToolsDir uses Abi.current(), so it returns the buildtools
    // dir for the architecture being simulated, not the host.
    !isSimulator;

Future<List<String>?> retrieveDebugMap(String snapshotPath) async {
  final dsymutil = llvmTool('dsymutil');
  if (dsymutil == null) {
    if (!canRetrieveDebugMap) return null;
    throw StateError('Expected dsymutil');
  }
  // Don't clutter the log with the output from dsymutil as it's large.
  return await runOutput(dsymutil, [
    '--dump-debug-map',
    snapshotPath,
  ], printStdout: false);
}

Future<TestCase> compileSnapshot(
  String tempDir,
  String scriptDill,
  TestType testType,
) async {
  final additionalOptions = [
    if (testType == TestType.AddRunPaths)
      '--macho-rpath=${machORunPaths.join(',')}',
    if (testType == TestType.MinOSVersion)
      '--macho-min-os-version=$expectedVersion',
    if (testType == TestType.NoLinkerSignature) '--no-macho-linker-signature',
    if (testType == TestType.ReplaceInstallName)
      '--macho-install-name=$machoInstallName',
  ];

  final scriptSnapshot = path.join(tempDir, 'output.so');
  await createSnapshot(
    scriptDill,
    SnapshotType.machoDylib,
    scriptSnapshot,
    additionalOptions,
  );
  return TestCase(
    testType,
    scriptSnapshot,
    macho.MachO.fromFile(scriptSnapshot)!,
    debugMap: await retrieveDebugMap(scriptSnapshot),
  );
}

@pragma('vm:platform-const')
final isApplePlatform = Platform.isMacOS || Platform.isIOS;
@pragma('vm:platform-const')
final expectedVersion = isApplePlatform ? macho.Version(1, 2, 3) : null;
const machoInstallName = '@rpath/App.framework/App';
const machORunPaths = [
  '@executable_path/Frameworks',
  '@loader_path/Frameworks',
];

enum TestType {
  AddRunPaths,
  MinOSVersion,
  NoLinkerSignature,
  ReplaceInstallName,
  Encryptable,
}

@pragma('vm:platform-const')
final testsToRun = [
  if (isApplePlatform) TestType.MinOSVersion,
  if (isApplePlatform) TestType.AddRunPaths,
  TestType.ReplaceInstallName,
  TestType.NoLinkerSignature,
];

class TestCase {
  final TestType type;
  final String snapshotPath;
  final macho.MachO snapshot;
  final List<String>? debugMap;

  TestCase(this.type, this.snapshotPath, this.snapshot, {this.debugMap});
}

Future<void> checkCases(List<TestCase> testCases) async {
  // We want to make sure the debug maps are consistent across cases.
  checkDebugMaps(testCases);
  for (final c in testCases) {
    checkInstallName(c);
    checkRunPaths(c);
    checkBuildVersion(c);
    checkCodeSignature(c);
    checkEncryptionInfo(c);
  }
  // Unsigned snapshots are not runnable.
  final runnableCases = testCases
      .where((c) => c.type != TestType.NoLinkerSignature)
      .toList();
  await checkRunnable(runnableCases);
}

Future<void> checkRunnable(List<TestCase> testCases) async {
  Expect.isNotEmpty(testCases);
  final traces = [
    for (final c in testCases)
      await runError(dartPrecompiledRuntime, <String>[
        c.snapshotPath,
      ], printStderr: false),
  ];

  // Use the first testcase's stack trace as the expected result.
  final expectedTrace = traces.first;
  print('');
  print("Stack trace 1:");
  expectedTrace.forEach(print);

  if (traces.length == 1) {
    // On non-Apple platforms, there's only one runnable case.
    print('');
    print('No other runnable test cases to compare.');
    return;
  }

  for (int i = 1; i < testCases.length; i++) {
    final gotTrace = traces[i];
    print('');
    print("Stack trace ${i + 1}:");
    print(gotTrace);

    Expect.deepEquals(expectedTrace, gotTrace);
  }
}

final _tripleLineRegExp = RegExp(r'triple:\s+(.*)');
final _timestampLineRegExp = RegExp(r'timestamp:\s+(.*)');
// We only check that the number of symbols were the same.
final _symbolLineRegExp = RegExp(r'{ sym: ');

void checkDebugMaps(List<TestCase> testCases) {
  // Not a platform where we can test debug maps.
  if (!canRetrieveDebugMap) return;

  for (final c in testCases) {
    Expect.isNotNull(c.debugMap, 'Debug map for test ${c.type} missing');
  }

  // Like with the runnable testcases, use the first one as the expected
  // result for the others.
  final expected = testCases.first.debugMap!;
  final got = testCases.skip(1).map((t) => t.debugMap!).toList();

  // The dump should look like the following YAML:
  // ---
  // triple:        '<arch>-<vendor>-<os>'
  // binary-path:   <filename>
  // objects:
  //   - filename:      <filename>
  //   - timestamp:     0
  //   - symbols:
  //     - { sym: <name>, ... }
  //     ...
  // ...
  //
  // The initial --- and ending ... are literal, as those are used to
  // separate multiple YAML documents in a single stream.
  //
  // For all test cases:
  // - The triple should be the same.
  // - The binary-path and filename lines should exist, though the filenames
  //   may be different.
  // - The timestamp should be 0.
  // - The number of symbols should be the same.
  Expect.isTrue(expected.length > 7);
  for (final c in got) {
    Expect.equals(expected.length, c.length);
  }
  for (int i = 0; i < expected.length; i++) {
    final expectedLine = expected[i];
    final isSymbol = _symbolLineRegExp.hasMatch(expectedLine);
    final expectedTriple = _tripleLineRegExp.firstMatch(expectedLine)?[1];

    final expectedTimestampMatch = _timestampLineRegExp.firstMatch(
      expectedLine,
    );
    if (expectedTimestampMatch != null) {
      final expectedTimestamp = int.tryParse(expectedTimestampMatch[1]!);
      // The timestamp (value of the N_OSO symbol) in our snapshots is always 0.
      Expect.equals(0, expectedTimestamp);
    }

    // Lines that are allowed to have varying field values.
    final prefixOnlyLinePrefixes = ['binary-path: ', '  - filename: '];
    var expectedPrefixEnd = -1;
    if (prefixOnlyLinePrefixes.any((s) => expectedLine.startsWith(s))) {
      expectedPrefixEnd = expectedLine.indexOf(':');
    }

    for (final c in got) {
      final gotLine = c[i];
      if (expectedTriple != null) {
        final gotTriple = _tripleLineRegExp.firstMatch(gotLine)?[1];
        Expect.equals(expectedTriple, gotTriple);
      } else if (isSymbol) {
        Expect.isTrue(_symbolLineRegExp.hasMatch(gotLine));
      } else if (expectedPrefixEnd > 0) {
        // If there's a unhandled field name, check that those match and don't
        // check the rest of the line (as, say, the filename will differ).
        Expect.stringEquals(
          expectedLine.substring(0, expectedLine.indexOf(':')),
          gotLine.substring(0, expectedLine.indexOf(':')),
        );
      } else {
        // Check line equality for anything not already covered.
        Expect.stringEquals(expectedLine, gotLine);
      }
    }
  }
}

void checkInstallName(TestCase testCase) {
  final dylibCommands = testCase.snapshot
      .commandsWhereType<macho.DylibCommand>();
  Expect.isNotEmpty(dylibCommands);
  final idDylib = dylibCommands
      .where((c) => c.cmd == macho.LoadCommand.LC_ID_DYLIB)
      .singleOrNull;
  Expect.isNotNull(idDylib);
  if (idDylib == null) return;
  final expectedName = testCase.type == TestType.ReplaceInstallName
      ? machoInstallName
      // No Utils::Basename implementation in runtime/platform for Windows
      // or Fuchsia, so for now an empty string is used instead of the full
      // path (which could leak information).
      : (Platform.isWindows || Platform.isFuchsia)
      ? ""
      : path.basename(testCase.snapshotPath);
  Expect.equals(expectedName, idDylib.info.name);
}

void checkRunPaths(TestCase testCase) {
  final runPathCommands = testCase.snapshot
      .commandsWhereType<macho.RunPathCommand>();
  if (testCase.type != TestType.AddRunPaths) {
    Expect.isEmpty(runPathCommands);
  } else {
    Expect.isNotEmpty(runPathCommands);
    for (final rpath in runPathCommands) {
      Expect.isTrue(
        machORunPaths.contains(rpath.path),
        "${rpath.path} not in [${machORunPaths.join(", ")}]",
      );
    }
  }
}

void checkCodeSignature(TestCase testCase) {
  final codeSignatureCommands = testCase.snapshot.commands.where(
    (s) => s.cmd == macho.LoadCommand.LC_CODE_SIGNATURE,
  );
  if (testCase.type == TestType.NoLinkerSignature) {
    Expect.isEmpty(codeSignatureCommands);
  } else {
    Expect.equals(1, codeSignatureCommands.length);
  }
}

void checkEncryptionInfo(TestCase testCase) {
  final encryptionInfoCommands = testCase.snapshot.commands
      .whereType<macho.EncryptionInfoCommand>();
  if (testCase.type == TestType.Encryptable) {
    Expect.equals(1, encryptionInfoCommands.length);
    final cmd = encryptionInfoCommands.singleOrNull;
    if (cmd != null) {
      // gen_snapshot doesn't encrypt any segments.
      Expect.equals(0, cmd.cryptId);
    }
  } else {
    Expect.isEmpty(encryptionInfoCommands);
  }
}

void checkBuildVersion(TestCase testCase) {
  final buildVersion = testCase.snapshot
      .commandsWhereType<macho.BuildVersionCommand>()
      .singleOrNull;
  if (buildVersion == null) {
    Expect.isFalse(isApplePlatform);
    return;
  }
  Expect.isTrue(isApplePlatform);
  final expectedPlatform = Platform.isIOS
      ? macho.Platform.PLATFORM_IOS
      : macho.Platform.PLATFORM_MACOS;
  Expect.equals(expectedPlatform, buildVersion.platform);
  if (testCase.type == TestType.MinOSVersion) {
    Expect.equals(expectedVersion, buildVersion.minOS);
    Expect.equals(expectedVersion, buildVersion.sdk);
  }
  Expect.isEmpty(buildVersion.toolVersions);
}
