@TestOn('windows')
library;

import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';

import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite3/open.dart' as sqlite_open;
import 'package:pyramid/core/app_database.dart';

/// LIVE-End-to-End-Test der SQLCipher-Verschlüsselung mit einem ECHTEN
/// Matrix-Login gegen steggi-matrix.work (Test-Account, NICHT Bernd/Uta).
///
/// Bewusst doppelt gegated, damit er nie in einem normalen `flutter test`
/// mitläuft (Netzwerk + echter Homeserver):
///  1. Umgebungsvariable `PYRAMID_LIVE_TEST=1` muss gesetzt sein.
///  2. Die Zugangsdaten-Datei (`PYRAMID_TEST_ACCOUNTS` bzw.
///     `%USERPROFILE%\.pyramid-autopilot\test-accounts.txt`) muss existieren –
///     sie liegt AUSSERHALB des Repos, hier stehen keine Secrets.
///
/// Was der Test wirklich beweist (auf Windows, mit der SQLCipher-DLL aus dem
/// App-Build):
///  * Eine frisch angelegte DB ist auf der Platte verschlüsselt: Header ist
///    nicht "SQLite format 3", und weder der echte Access-Token noch der
///    Klartext einer echten gesendeten Nachricht sind in den Datei-Bytes
///    auffindbar (inkl. WAL-Sidecar).
///  * Die Byte-Suche selbst funktioniert (Positiv-Kontrolle: in einer
///    Klartext-DB werden Token UND Nachricht gefunden).
///  * "Neustart": Die Session (Access-Token) und die Nachricht kommen
///    nachweislich aus der verschlüsselten DB (Raw-Query mit Schlüssel,
///    Client-Init ohne erneute Passwort-Eingabe).
///  * Logout → erneuter Login → die alte Nachricht ist wieder lesbar.
///  * Utas Update-Fall: eine Klartext-DB MIT ECHTER eingeloggter Session wird
///    migriert; Session und Nachricht überleben, danach ist nichts mehr im
///    Klartext auf der Platte.
///
/// NICHT abgedeckt (ehrlich): flutter_secure_storage (der Test injiziert den
/// Schlüssel direkt – der Keystore-Pfad ist durch die echten App-Läufe auf
/// Windows und dem Android-Emulator belegt) und E2EE-verschlüsselte Räume
/// (kein vodozemac im Host-Test; der Testraum ist unverschlüsselt, was für
/// die Klartext-Suche sogar der härtere Fall ist).

String? _findCipherDll() {
  for (final config in ['Debug', 'Profile', 'Release']) {
    final file = File('build/windows/x64/runner/$config/sqlite3.dll');
    if (file.existsSync()) return file.absolute.path;
  }
  return null;
}

class _TestAccounts {
  _TestAccounts({
    required this.homeserver,
    required this.user1,
    required this.pass1,
    required this.user2,
    required this.pass2,
  });

  final Uri homeserver;
  final String user1;
  final String pass1;
  final String user2;
  final String pass2;

  static _TestAccounts? load() {
    final override = Platform.environment['PYRAMID_TEST_ACCOUNTS'];
    final profile = Platform.environment['USERPROFILE'] ?? '';
    final path = (override != null && override.isNotEmpty)
        ? override
        : '$profile\\.pyramid-autopilot\\test-accounts.txt';
    final file = File(path);
    if (!file.existsSync()) return null;
    final map = <String, String>{};
    for (final line in file.readAsLinesSync()) {
      final trimmed = line.trim();
      if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
      final idx = trimmed.indexOf('=');
      if (idx <= 0) continue;
      map[trimmed.substring(0, idx)] = trimmed.substring(idx + 1);
    }
    final hs = map['homeserver'];
    if (hs == null ||
        map['user1'] == null ||
        map['pass1'] == null ||
        map['user2'] == null ||
        map['pass2'] == null) {
      return null;
    }
    return _TestAccounts(
      homeserver: Uri.parse(hs),
      user1: map['user1']!,
      pass1: map['pass1']!,
      user2: map['user2']!,
      pass2: map['pass2']!,
    );
  }
}

/// Zufälliger 32-Byte-Schlüssel wie in AppDatabase._getOrCreateKey – nur
/// direkt injiziert statt über den Keystore (siehe Kopfkommentar).
String _randomHexKey() {
  final rnd = Random.secure();
  return List.generate(
    32,
    (_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'),
  ).join();
}

String _randomMarker(String prefix) {
  final rnd = Random.secure();
  final suffix =
      List.generate(8, (_) => rnd.nextInt(16).toRadixString(16)).join();
  return 'PYRAMID_${prefix}_PROBE_$suffix';
}

/// Naive ASCII-Substring-Suche über rohe Datei-Bytes (DB + WAL). Bewusst kein
/// String-Decode der Gesamtdatei: Binärdaten sind kein gültiges UTF-8.
bool _bytesContain(Uint8List haystack, String needle) {
  final n = ascii.encode(needle);
  if (n.isEmpty || haystack.length < n.length) return false;
  outer:
  for (var i = 0; i <= haystack.length - n.length; i++) {
    for (var j = 0; j < n.length; j++) {
      if (haystack[i + j] != n[j]) continue outer;
    }
    return true;
  }
  return false;
}

/// Durchsucht Hauptdatei + WAL/SHM-Sidecars nach einem Klartext-String.
Future<bool> _filesContain(String dbPath, String needle) async {
  for (final path in [dbPath, '$dbPath-wal', '$dbPath-shm']) {
    final file = File(path);
    if (!await file.exists()) continue;
    if (_bytesContain(await file.readAsBytes(), needle)) return true;
  }
  return false;
}

/// Öffnet die verschlüsselte DB mit Schlüssel und sucht den String in allen
/// Tabelleninhalten – beweist, dass die Daten IN der verschlüsselten Datei
/// stecken (und nicht etwa vom Server nachgeladen werden müssten).
Future<bool> _encryptedDbContains(
  DatabaseFactory factory,
  String dbPath,
  String hexKey,
  String needle,
) async {
  final db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
  try {
    final tables = await db.rawQuery(
      "SELECT name FROM sqlite_master "
      "WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
    );
    for (final row in tables) {
      final name = row['name'] as String;
      final rows = await db.rawQuery('SELECT * FROM "$name"');
      for (final r in rows) {
        for (final v in r.values) {
          if (v == null) continue;
          if (v is Uint8List) {
            if (_bytesContain(v, needle)) return true;
          } else if (v.toString().contains(needle)) {
            return true;
          }
        }
      }
    }
    return false;
  } finally {
    await db.close();
  }
}

Future<Client> _newClient(
  Database db, {
  required String name,
  bool waitForRooms = false,
}) async {
  final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
  final client = Client(name, database: sdkDb);
  // waitForRooms: Die Raumliste wird beim Init ASYNCHRON aus der DB geladen –
  // wer direkt danach client.rooms prüfen will, muss darauf warten.
  await client.init(
    waitForFirstSync: false,
    waitUntilLoadCompletedLoaded: waitForRooms,
  );
  return client;
}

Future<void> _login(Client client, _TestAccounts acc,
    {required String user, required String pass, required String device}) async {
  await client.checkHomeserver(acc.homeserver);
  await client.login(
    LoginType.mLoginPassword,
    identifier: AuthenticationUserIdentifier(user: user),
    password: pass,
    initialDeviceDisplayName: device,
    refreshToken: true,
  );
}

/// Holt (oder erzeugt) den unverschlüsselten Autopilot-Testraum.
Future<Room> _ensureTestRoom(Client client) async {
  const roomName = 'Pyramid Autopilot Testraum';
  await client.oneShotSync().timeout(const Duration(seconds: 60));
  for (final room in client.rooms) {
    if (room.name == roomName && room.membership == Membership.join) {
      return room;
    }
  }
  final roomId = await client.createRoom(
    name: roomName,
    preset: CreateRoomPreset.privateChat,
  );
  await client.waitForRoomInSync(roomId, join: true).timeout(
        const Duration(seconds: 60),
      );
  final room = client.getRoomById(roomId);
  expect(room, isNotNull, reason: 'Testraum nach createRoom nicht im Sync');
  return room!;
}

/// Sucht die Marker-Nachricht in der Timeline (lädt bei Bedarf Historie nach).
Future<bool> _timelineContains(Room room, String marker) async {
  final timeline = await room.getTimeline();
  try {
    for (var i = 0; i < 10; i++) {
      if (timeline.events.any(
        (e) => e.type == EventTypes.Message && e.body.contains(marker),
      )) {
        return true;
      }
      if (!timeline.canRequestHistory) break;
      await timeline.requestHistory(historyCount: 100);
    }
    return timeline.events.any(
      (e) => e.type == EventTypes.Message && e.body.contains(marker),
    );
  } finally {
    timeline.cancelSubscriptions();
  }
}

void main() {
  final dllPath = _findCipherDll();
  final accounts = _TestAccounts.load();
  final liveEnabled = Platform.environment['PYRAMID_LIVE_TEST'] == '1';

  final skip = !liveEnabled
      ? 'Live-Test nur mit PYRAMID_LIVE_TEST=1 (echter Homeserver!)'
      : dllPath == null
          ? 'SQLCipher-DLL fehlt (erst "flutter build windows")'
          : accounts == null
              ? 'Test-Account-Datei fehlt (~/.pyramid-autopilot/test-accounts.txt)'
              : null;

  final factory = dllPath == null
      ? null
      : createDatabaseFactoryFfi(
          ffiInit: () {
            sqlite_open.open.overrideFor(
              sqlite_open.OperatingSystem.windows,
              () => DynamicLibrary.open(dllPath),
            );
          },
          noIsolate: true,
        );

  late Directory tempDir;

  setUp(() async {
    tempDir = await Directory.systemTemp.createTemp('pyramid_live_test');
  });

  tearDown(() async {
    try {
      await tempDir.delete(recursive: true);
    } catch (_) {}
  });

  test(
    'Frische Installation: Login → Platte verschlüsselt → Neustart → '
    'Logout → Re-Login → alte Nachricht lesbar',
    () async {
      final acc = accounts!;
      final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite';
      final hexKey = _randomHexKey();
      final marker = _randomMarker('ENC');

      // ── Phase 1: frisch verschlüsselt anlegen, echter Login, Nachricht ──
      var db = await AppDatabase.openEncrypted(factory!, dbPath, hexKey);
      await db.execute('PRAGMA journal_mode=WAL');
      var client = await _newClient(db, name: 'PyramidLiveTest');
      await _login(client, acc,
          user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test');
      expect(client.isLogged(), isTrue);
      final accessToken = client.accessToken!;
      final room = await _ensureTestRoom(client);
      final eventId = await room.sendTextEvent(marker);
      expect(eventId, isNotNull, reason: 'Senden der Probe-Nachricht schlug fehl');
      await client.oneShotSync().timeout(const Duration(seconds: 60));
      await client.dispose(closeDatabase: true);

      // ── Phase 2: Byte-Prüfung auf der Platte ──
      expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse,
          reason: 'DB-Header ist "SQLite format 3" – NICHT verschlüsselt!');
      expect(await _filesContain(dbPath, accessToken), isFalse,
          reason: 'Access-Token im Klartext auf der Platte gefunden!');
      expect(await _filesContain(dbPath, marker), isFalse,
          reason: 'Nachrichtentext im Klartext auf der Platte gefunden!');

      // Beweis, dass die Daten wirklich IN der Datei stecken (nur eben
      // verschlüsselt): Raw-Query mit Schlüssel findet beides.
      expect(
        await _encryptedDbContains(factory, dbPath, hexKey, accessToken),
        isTrue,
        reason: 'Access-Token fehlt in der DB – falsche Datei geprüft?',
      );
      expect(
        await _encryptedDbContains(factory, dbPath, hexKey, marker),
        isTrue,
        reason: 'Nachricht fehlt in der DB – falsche Datei geprüft?',
      );

      // ── Phase 3: „Neustart" – Session kommt aus der verschlüsselten DB ──
      db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
      client = await _newClient(db, name: 'PyramidLiveTest');
      expect(client.isLogged(), isTrue,
          reason: 'Session hat den Neustart nicht überlebt');
      expect(client.accessToken, accessToken,
          reason: 'Access-Token nach Neustart nicht aus der DB restauriert');
      expect(client.userID?.toLowerCase(), acc.user1.toLowerCase());

      // ── Phase 4: Logout → Re-Login → alte Nachricht wieder lesbar ──
      await client.logout();
      expect(client.isLogged(), isFalse);
      await _login(client, acc,
          user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test 2');
      final roomAfter = await _ensureTestRoom(client);
      expect(await _timelineContains(roomAfter, marker), isTrue,
          reason: 'Alte Nachricht nach Logout+Re-Login nicht mehr lesbar');
      // Aufräumen: Session serverseitig beenden, DB wird dabei geleert.
      await client.logout();
      await client.dispose(closeDatabase: true);
    },
    skip: skip,
    timeout: const Timeout(Duration(minutes: 8)),
  );

  test(
    'Utas Update-Fall: Klartext-DB mit ECHTER Session → Migration → '
    'Session + Nachricht überleben, Platte danach dicht',
    () async {
      final acc = accounts!;
      final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite';
      final hexKey = _randomHexKey();
      final marker = _randomMarker('MIG');

      // ── Phase 1: Klartext-DB wie vor der SQLCipher-Einführung ──
      var db = await factory!.openDatabase(dbPath);
      await db.execute('PRAGMA journal_mode=WAL');
      var client = await _newClient(db, name: 'PyramidLiveTest');
      await _login(client, acc,
          user: acc.user1, pass: acc.pass1, device: 'Autopilot Mig-Test');
      final accessToken = client.accessToken!;
      final room = await _ensureTestRoom(client);
      final eventId = await room.sendTextEvent(marker);
      expect(eventId, isNotNull);
      await client.oneShotSync().timeout(const Duration(seconds: 60));
      await client.dispose(closeDatabase: true);

      // Positiv-Kontrolle: In der Klartext-DB findet die Byte-Suche BEIDES –
      // damit ist bewiesen, dass die Suche Leaks erkennen würde.
      expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isTrue);
      expect(await _filesContain(dbPath, accessToken), isTrue,
          reason: 'Kontrolle kaputt: Token nicht mal im Klartext gefunden');
      expect(await _filesContain(dbPath, marker), isTrue,
          reason: 'Kontrolle kaputt: Nachricht nicht mal im Klartext gefunden');

      // ── Phase 2: Migration (exakt der App-Codepfad) ──
      final ok = await AppDatabase.migratePlaintextToEncrypted(
        factory: factory,
        dbPath: dbPath,
        hexKey: hexKey,
      );
      expect(ok, isTrue, reason: 'Migration fehlgeschlagen');
      expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse);
      expect(File('$dbPath.premigration').existsSync(), isTrue);
      expect(await _filesContain(dbPath, accessToken), isFalse,
          reason: 'Token nach Migration weiterhin im Klartext!');
      expect(await _filesContain(dbPath, marker), isFalse,
          reason: 'Nachricht nach Migration weiterhin im Klartext!');

      // ── Phase 3: Session + Daten intakt? ──
      expect(
        await _encryptedDbContains(factory, dbPath, hexKey, marker),
        isTrue,
        reason: 'Nachricht hat die Migration nicht überlebt',
      );
      db = await AppDatabase.openEncrypted(factory, dbPath, hexKey);
      client = await _newClient(db, name: 'PyramidLiveTest', waitForRooms: true);
      expect(client.isLogged(), isTrue,
          reason: 'Session hat die Migration nicht überlebt');
      expect(client.accessToken, accessToken);
      final roomAfter = client.getRoomById(room.id);
      expect(roomAfter, isNotNull,
          reason: 'Testraum nach Migration nicht mehr in der DB');
      // Aufräumen (Session serverseitig beenden).
      await client.logout();
      await client.dispose(closeDatabase: true);
    },
    skip: skip,
    timeout: const Timeout(Duration(minutes: 8)),
  );
}
