import 'dart:async';
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

import 'auth_session_event.dart';
import 'auth_token_provider.dart';
import 'backend_config.dart';

/// OIDC-Token-Provider gegen Keycloak (PKCE Authorization Code Flow).
///
/// Verantwortlich für:
/// * **Login** über `flutter_appauth` (Browser-Tab → Keycloak →
///   RedirectURI `holzleitner://oauth2redirect`).
/// * **Token-Refresh** mit dem persistierten Refresh-Token.
/// * **Session-Restore** beim App-Start (Refresh-Token aus Secure
///   Storage einlesen + sofortigen Refresh anstoßen).
/// * **Logout**: lokale Token droppen + Secure Storage säubern.
///
/// Access-Token und ID-Token-Claims liegen im Speicher (flüchtig),
/// der Refresh-Token wird in der Plattform-Secure-Storage abgelegt
/// (Keychain / EncryptedSharedPreferences).
///
/// Events werden über [events] gebroadcastet — der AuthBloc abonniert
/// und reagiert. Diese Indirektion vermeidet eine Abhängigkeit auf die
/// Bloc-Schicht.
class KeycloakOidcTokenProvider implements AuthTokenProvider {
  KeycloakOidcTokenProvider({
    required BackendConfig config,
    required this.issuerUrl,
    required this.redirectUrl,
    FlutterAppAuth? appAuth,
    FlutterSecureStorage? storage,
  }) : _config = config,
       _appAuth = appAuth ?? const FlutterAppAuth(),
       _storage = storage ?? const FlutterSecureStorage();

  final BackendConfig _config;
  final FlutterAppAuth _appAuth;
  final FlutterSecureStorage _storage;

  /// Vollständige Issuer-URL ohne `/.well-known/...`-Suffix.
  /// Beispiel: `http://localhost:8080/realms/holzleitner`.
  final String issuerUrl;

  /// Redirect-URI, die im Keycloak-Client erlaubt ist und auf das
  /// Custom-Scheme der App matcht. Beispiel:
  /// `holzleitner://oauth2redirect`.
  final String redirectUrl;

  String? _accessToken;
  DateTime? _expiresAt;
  String? _refreshToken;
  Map<String, dynamic>? _idTokenClaims;

  /// Single-flight-Guard: hält den gerade laufenden Refresh, damit mehrere
  /// gleichzeitige Aufrufer (Bootstrap: Restore + PaymentMethodsCubit +
  /// Folge-Requests) sich EINEN Refresh teilen statt parallele
  /// `flutter_appauth.token()`-Calls auszulösen (die nativ blockieren/haken
  /// können → App hängt nach Hot-Restart am Splash/Login).
  Future<String?>? _refreshInFlight;

  final StreamController<AuthSessionEvent> _events =
      StreamController<AuthSessionEvent>.broadcast();

  /// Stream der Auth-Events. Wird vom AuthBloc abonniert.
  Stream<AuthSessionEvent> get events => _events.stream;

  /// Discovery-URL für `flutter_appauth` — wird intern auch geprüft,
  /// bevor PKCE losläuft.
  String get _discoveryUrl => '$issuerUrl/.well-known/openid-configuration';

  /// ID-Token-Claims der aktuellen Session, oder `null` wenn nicht
  /// angemeldet.
  Map<String, dynamic>? get idTokenClaims => _idTokenClaims;

  bool get isAuthenticated => _accessToken != null;

  /// Triggert den PKCE-Login-Flow. Wirft, wenn der User abbricht oder
  /// Keycloak einen Fehler liefert.
  Future<void> login() async {
    final result = await _appAuth.authorizeAndExchangeCode(
      AuthorizationTokenRequest(
        _config.keycloakClientId,
        redirectUrl,
        discoveryUrl: _discoveryUrl,
        scopes: const ['openid', 'profile'],
        // Lokales Dev-Setup hat HTTP-Keycloak — der Default-iOS-Browser
        // würde sonst abbrechen. In Produktion (HTTPS) ist das ein
        // No-Op und kann bleiben.
        allowInsecureConnections: true,
        // Wichtig auf Android: ohne `prompt=login` würde Keycloak bei
        // bestehender SSO-Session sofort 302 nach holzleitner://...
        // antworten, Chrome schließt den Custom Tab dabei so schnell,
        // dass der Redirect-Intent unsere RedirectUriReceiverActivity
        // gar nicht erreicht — AppAuth meldet stattdessen
        // "User cancelled flow". Erzwingen der Login-Maske → echter
        // User-Click → sauberer Intent-Dispatch.
        promptValues: const ['login'],
      ),
    );

    _applyTokens(
      accessToken: result.accessToken,
      refreshToken: result.refreshToken,
      idToken: result.idToken,
      expiresAt: result.accessTokenExpirationDateTime,
    );
    await _persistRefreshToken();

    _events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
  }

  /// Versucht, eine vorhandene Session aus der Secure Storage zu
  /// reaktivieren. Liefert `true`, wenn anschließend ein gültiger
  /// Access-Token verfügbar ist.
  ///
  /// Schluckt Fehler aus dem Storage-Plugin (z. B. nicht-registriertes
  /// Native-Modul nach Plugin-Update ohne Cold-Restart) — der Caller
  /// landet dann sauber im "kein Restore möglich"-Pfad statt mit einer
  /// MissingPluginException auf dem AuthBloc-Stream.
  Future<bool> restoreSession() async {
    final String? stored;
    try {
      stored = await _storage.read(key: _refreshTokenStorageKey);
    } catch (e, st) {
      debugPrint(
        'restoreSession: konnte Refresh-Token nicht lesen: $e\n$st',
      );
      return false;
    }
    if (stored == null || stored.isEmpty) return false;
    _refreshToken = stored;

    final token = await currentAccessToken();
    if (token == null) return false;

    _events.add(AuthLoggedIn(_idTokenClaims ?? const <String, dynamic>{}));
    return true;
  }

  /// Bricht die Session ab — droppt alle Tokens lokal. Ein
  /// serverseitiges `endSession` (Keycloak Single-Logout) machen wir
  /// bewusst nicht: der Refresh-Token läuft beim Server normal aus,
  /// und der ID-Token-Hint würde uns zwingen, den rohen ID-Token mit
  /// in der Session zu halten.
  Future<void> logout() async {
    _clearSession();
    try {
      await _storage.delete(key: _refreshTokenStorageKey);
    } catch (e) {
      debugPrint('logout: Refresh-Token konnte nicht gelöscht werden: $e');
    }
    _events.add(const AuthLoggedOut());
  }

  @override
  Future<String?> currentAccessToken() async {
    final cached = _accessToken;
    final expiresAt = _expiresAt;
    final now = DateTime.now().toUtc();

    // Schwellwert 30 s: Pufferzeit gegen Clock-Drift und
    // mid-flight-Expiry.
    if (cached != null &&
        expiresAt != null &&
        expiresAt.isAfter(now.add(const Duration(seconds: 30)))) {
      return cached;
    }

    final rt = _refreshToken;
    if (rt == null) return null;

    // Single-flight: läuft bereits ein Refresh, hängen wir uns dran, statt
    // einen zweiten `flutter_appauth.token()`-Call zu starten. `??=`
    // evaluiert die rechte Seite nur, wenn noch kein Refresh läuft.
    return _refreshInFlight ??= _performRefresh(rt).whenComplete(() {
      _refreshInFlight = null;
    });
  }

  /// Führt EINEN Token-Refresh aus. Bei Erfolg werden die Tokens übernommen
  /// und der neue Access-Token zurückgegeben (ohne Event — stiller Refresh).
  /// Bei Fehler ist die Session tot: lokal aufräumen, `AuthSessionExpired`
  /// emittieren, `null` zurück.
  Future<String?> _performRefresh(String rt) async {
    try {
      final result = await _appAuth.token(
        TokenRequest(
          _config.keycloakClientId,
          redirectUrl,
          discoveryUrl: _discoveryUrl,
          refreshToken: rt,
          scopes: const ['openid', 'profile'],
          allowInsecureConnections: true,
        ),
      );
      _applyTokens(
        accessToken: result.accessToken,
        refreshToken: result.refreshToken ?? rt,
        idToken: result.idToken,
        expiresAt: result.accessTokenExpirationDateTime,
      );
      await _persistRefreshToken();
      return _accessToken;
    } on Exception {
      // Refresh hat nicht funktioniert — Session ist tot, nicht
      // wiederherstellbar. Reihenfolge bewusst: erst State leeren + Event
      // feuern, DANN best-effort den Storage löschen — so kann ein
      // werfendes `delete` weder das Event verschlucken noch eine Exception
      // aus `currentAccessToken()` leaken.
      _clearSession();
      _events.add(const AuthSessionExpired());
      try {
        await _storage.delete(key: _refreshTokenStorageKey);
      } catch (e) {
        debugPrint('currentAccessToken: Refresh-Token-Delete fehlgeschlagen: $e');
      }
      return null;
    }
  }

  void _applyTokens({
    required String? accessToken,
    required String? refreshToken,
    required String? idToken,
    required DateTime? expiresAt,
  }) {
    _accessToken = accessToken;
    _refreshToken = refreshToken;
    _expiresAt = expiresAt?.toUtc();
    if (idToken != null) {
      _idTokenClaims = _decodeJwtPayload(idToken);
    }
  }

  Future<void> _persistRefreshToken() async {
    final rt = _refreshToken;
    if (rt == null) return;
    try {
      await _storage.write(key: _refreshTokenStorageKey, value: rt);
    } catch (e) {
      // Nicht fatal — Refresh-Token bleibt in-memory verfügbar,
      // wir verlieren nur das Restore-Verhalten beim nächsten Start.
      debugPrint('Refresh-Token konnte nicht persistiert werden: $e');
    }
  }

  void _clearSession() {
    _accessToken = null;
    _expiresAt = null;
    _refreshToken = null;
    _idTokenClaims = null;
  }

  /// Dispose-Hook für Tests / Hot-Restarts.
  Future<void> dispose() async {
    await _events.close();
  }

  static const String _refreshTokenStorageKey =
      'holzleitner_keycloak_refresh_token';

  /// Dekodiert das Payload-Segment eines JWT. Wirft bei strukturellen
  /// Auffälligkeiten — Signatur wird **nicht** geprüft (das macht der
  /// Server bei jedem Request neu).
  static Map<String, dynamic> _decodeJwtPayload(String token) {
    final parts = token.split('.');
    if (parts.length != 3) {
      throw FormatException('Token-Format unerwartet: ${parts.length} Teile');
    }
    String payload = parts[1];
    // base64url ohne Padding → wieder padden, sonst wirft base64Url.decode.
    switch (payload.length % 4) {
      case 0:
        break;
      case 2:
        payload += '==';
      case 3:
        payload += '=';
      default:
        throw const FormatException('Token-Payload hat ungültige Länge');
    }
    final bytes = base64Url.decode(payload);
    final json = utf8.decode(bytes);
    final decoded = jsonDecode(json);
    if (decoded is! Map<String, dynamic>) {
      throw const FormatException('Token-Payload ist kein JSON-Objekt');
    }
    return decoded;
  }
}
