import 'dart:developer';
import 'dart:io';

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
import 'package:package_info_plus/package_info_plus.dart';

import '../api/demo/demo_mode.dart';
import '../api/marianumcloud/app_password/delete_app_password.dart';
import '../api/marianumcloud/app_password/get_app_password.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../model/account_data.dart';
import '../model/endpoint_data.dart';
import 'nextcloud_push_api.dart';
import 'push_keypair.dart';
import 'push_registration_store.dart';
import 'push_registration_type.dart';

/// Orchestrates the full push-v2 registration lifecycle:
/// Nextcloud device registration → MarianumConnect proxy registration, plus
/// unregister and token-refresh handling.
///
/// Every device maintains TWO Nextcloud registrations (see
/// [PushRegistrationType]) sharing one keypair: a `general` one and a
/// Talk-classified one, because NC routes Talk pushes only to apptype=talk
/// subscriptions once the user has any (e.g. the official Talk app).
class PushRegistration {
  /// User agents matching nextcloud/server `IRequest` Talk patterns
  /// (`USER_AGENT_TALK_ANDROID = '/^Mozilla\/5\.0 \(Android\) Nextcloud\-Talk
  /// v([^ ]*).*$/'`, `USER_AGENT_TALK_IOS = '/^Mozilla\/5\.0 \(iOS\)
  /// Nextcloud\-Talk v([^ ]*).*$/'`). Sent only on the talk registration so
  /// NC stores it with apptype `talk`.
  static const String talkUserAgentAndroid =
      'Mozilla/5.0 (Android) Nextcloud-Talk v1.0.0 MarianumMobile';
  static const String talkUserAgentIos =
      'Mozilla/5.0 (iOS) Nextcloud-Talk v1.0.0 MarianumMobile';
  final PushKeypair _keypair;
  final PushRegistrationStore _store;
  final NextcloudPushApi _nextcloud;

  PushRegistration({
    PushKeypair? keypair,
    PushRegistrationStore? store,
    NextcloudPushApi? nextcloud,
  }) : _keypair = keypair ?? const PushKeypair(),
       _store = store ?? const PushRegistrationStore(),
       _nextcloud = nextcloud ?? NextcloudPushApi();

  String get _platform => Platform.isIOS ? 'ios' : 'android';

  String get _talkUserAgent =>
      Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;

  /// Derives the push-proxy base URL from the active MarianumConnect endpoint,
  /// so a beta/dev build registers against the matching proxy automatically.
  /// Public so the push status view can compare it against the stored binding.
  String get currentProxyServer =>
      '${MarianumConnectEndpoint.current()}/push-proxy/';

  /// Nextcloud origin the registration targets (full origin, no trailing
  /// slash) — persisted alongside the registration to detect endpoint changes.
  String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';

  /// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
  /// registration binds to it, so it must be obtained before registering.
  Future<void> ensureAppPassword() async {
    if (AccountData().hasAppPassword()) return;
    if (AccountData().usesLoginFlow) {
      // Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt,
      // stilles Minting ist unmöglich. Reparatur nur interaktiv über
      // Einstellungen → „Nextcloud neu verbinden".
      log('Push: login-flow account without app password, cannot mint silently');
      return;
    }
    try {
      final appPassword = await GetAppPassword().run();
      await AccountData().setAppPassword(appPassword);
    } on Object catch (e) {
      log('Push: could not obtain app password (non-blocking): $e');
    }
  }

  /// Ensures the second app password backing the Talk registration exists
  /// (each `getapppassword` call with the real password mints a fresh one).
  Future<void> ensureTalkAppPassword() async {
    if (AccountData().hasAppPasswordTalk()) return;
    // Flow-Konten können still kein zweites App-Passwort münzen — das
    // Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin
    // teilt sich die Talk-Registrierung das eine App-Passwort (siehe
    // AccountData.getTalkBasicAuthHeader).
    if (AccountData().usesLoginFlow) return;
    try {
      final appPassword = await GetAppPassword().run();
      await AccountData().setAppPasswordTalk(appPassword);
    } on Object catch (e) {
      log('Push: could not obtain talk app password (non-blocking): $e');
    }
  }

  /// Registers this device end-to-end: both Nextcloud registrations (general,
  /// then talk) each followed by their MarianumConnect proxy registration.
  /// Partial results are persisted per type — one failing registration never
  /// blocks the other. Returns true only when BOTH succeeded. No-op-safe:
  /// transport failures are logged and swallowed so callers can
  /// fire-and-forget (and simply ignore the result).
  Future<bool> register() async {
    if (DemoMode.active) return false;
    final String? fcmToken;
    try {
      fcmToken = await FirebaseMessaging.instance.getToken();
    } on Object catch (e) {
      log('Push: could not obtain FCM token: $e');
      await _recordAttempts('Kein FCM-Token verfügbar');
      return false;
    }
    if (fcmToken == null || fcmToken.isEmpty) {
      log('Push: no FCM token, skipping registration');
      await _recordAttempts('Kein FCM-Token verfügbar');
      return false;
    }

    await ensureAppPassword();
    await ensureTalkAppPassword();
    await _persistNativeAuthContext();

    final PushKeypairPems pems;
    try {
      pems = await _keypair.ensure();
    } on Object catch (e) {
      log('Push: keypair unavailable: $e');
      await _recordAttempts(_shortError(e));
      return false;
    }

    String? appVersion;
    try {
      appVersion = (await PackageInfo.fromPlatform()).version;
    } on Object {
      appVersion = null;
    }

    final types = registrationTypesFor(
      usesLoginFlow: AccountData().usesLoginFlow,
      hasTalkAppPassword: AccountData().hasAppPasswordTalk(),
    );
    if (!types.contains(PushRegistrationType.general)) {
      await _recordAttempt(
        PushRegistrationType.general,
        'Ohne zweite Nextcloud-Freigabe nicht verfügbar (nur Talk-Push)',
      );
    }

    var allOk = true;
    for (final type in types) {
      final ok = await _registerType(
        type: type,
        fcmToken: fcmToken,
        pems: pems,
        appVersion: appVersion,
      );
      allOk = allOk && ok;
    }
    return allOk;
  }

  Future<bool> _registerType({
    required PushRegistrationType type,
    required String fcmToken,
    required PushKeypairPems pems,
    required String? appVersion,
  }) async {
    try {
      final proxyServer = currentProxyServer;
      final ncBaseUrl = currentNcBaseUrl;
      final isTalk = type == PushRegistrationType.talk;
      final registration = await _nextcloud.register(
        pushTokenHash: generatePushTokenHash(pushTokenVariant(fcmToken, type)),
        devicePublicKeyPem: pems.publicKeyPem,
        proxyServer: proxyServer,
        authorizationHeader: isTalk
            ? AccountData().getTalkBasicAuthHeader()
            : null,
        userAgent: isTalk ? _talkUserAgent : null,
      );

      await _store.save(
        type: type,
        deviceIdentifier: registration.deviceIdentifier,
        serverPublicKeyPem: registration.publicKey,
        fcmToken: fcmToken,
        proxyServer: proxyServer,
        ncBaseUrl: ncBaseUrl,
      );

      await PushDeviceRegister().run(
        deviceIdentifier: registration.deviceIdentifier,
        deviceIdentifierSignature: registration.signature,
        userPublicKey: registration.publicKey,
        pushToken: fcmToken,
        platform: _platform,
        registrationType: type.wireName,
        appVersion: appVersion,
      );
      log(
        'Push: registered ${type.wireName} '
        '(created=${registration.created})',
      );
      await _recordAttempt(type, null);
      return true;
    } on Object catch (e) {
      log('Push: ${type.wireName} registration failed: $e');
      await _recordAttempt(type, _shortError(e));
      return false;
    }
  }

  /// Persists the attempt outcome for the push status view. Storage failures
  /// must never mask the actual registration result.
  Future<void> _recordAttempt(PushRegistrationType type, String? error) async {
    try {
      await _store.saveLastRegistrationAttempt(
        type: type,
        at: DateTime.now(),
        error: error,
      );
    } on Object {
      // ignore — the status view simply shows the previous attempt
    }
  }

  Future<void> _recordAttempts(String? error) async {
    for (final type in PushRegistrationType.values) {
      await _recordAttempt(type, error);
    }
  }

  static String _shortError(Object e) {
    final text = e.toString();
    return text.length > 300 ? '${text.substring(0, 300)}…' : text;
  }

  /// Writes the username and Nextcloud base URL into the shared keychain so the
  /// native iOS Talk action handler can authenticate OCS calls without the
  /// Flutter engine. Best-effort — a failure here must not abort registration.
  Future<void> _persistNativeAuthContext() async {
    try {
      final endpoint = EndpointData().nextcloud();
      await _store.saveNativeAuthContext(
        username: AccountData().getUsername(),
        baseUrl: 'https://${endpoint.full()}',
      );
    } on Object catch (e) {
      log('Push: could not persist native auth context: $e');
    }
  }

  /// Removes both subscriptions from Nextcloud and the proxy. Best-effort —
  /// each step is independent so one failure never blocks the rest.
  Future<void> unregister() async {
    for (final type in PushRegistrationType.values) {
      final deviceIdentifier = await _store.deviceIdentifier(type);
      try {
        // The DELETE removes the subscription bound to the authenticating
        // session token — each registration with its own app password.
        await _nextcloud.unregister(
          authorizationHeader: type == PushRegistrationType.talk
              ? AccountData().getTalkBasicAuthHeader()
              : null,
        );
      } on Object catch (e) {
        log('Push: NC unregister (${type.wireName}) failed: $e');
      }
      if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
        try {
          await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
        } on Object catch (e) {
          log('Push: proxy unregister (${type.wireName}) failed: $e');
        }
      }
    }
    await _store.clear();
  }

  /// Pure decision which Nextcloud registrations this session can maintain.
  /// Flow-Konten (2FA), die nur den ersten Login-Flow-Durchlauf abgeschlossen
  /// haben, besitzen eine einzige NC-Session — Nextcloud bindet pro Session
  /// genau eine Subscription, also bleibt nur die (wichtigere)
  /// Talk-Registrierung. Mit dem zweiten (Talk-)App-Passwort aus dem
  /// optionalen zweiten Durchlauf laufen wieder beide.
  static List<PushRegistrationType> registrationTypesFor({
    required bool usesLoginFlow,
    required bool hasTalkAppPassword,
  }) => usesLoginFlow && !hasTalkAppPassword
      ? const [PushRegistrationType.talk]
      : PushRegistrationType.values;

  /// Pure decision for whether a persisted registration endpoint no longer
  /// matches the currently active one. A missing/empty stored value never
  /// forces a re-registration — old installs (pre endpoint-tracking) heal via
  /// the regular register-on-start path instead of a forced extra roundtrip.
  static bool endpointChanged({
    required String? registered,
    required String current,
  }) => registered != null && registered.isNotEmpty && registered != current;

  /// True when any existing registration was made against a different
  /// MarianumConnect proxy or Nextcloud base URL than the ones currently
  /// configured (dev-tools endpoint switch, live/beta/custom).
  Future<bool> needsEndpointReRegistration() async {
    for (final type in PushRegistrationType.values) {
      if (!await _store.isRegistered(type)) continue;
      final changed =
          endpointChanged(
            registered: await _store.registeredProxyServer(type),
            current: currentProxyServer,
          ) ||
          endpointChanged(
            registered: await _store.registeredNcBaseUrl(type),
            current: currentNcBaseUrl,
          );
      if (changed) return true;
    }
    return false;
  }

  /// Re-registers when the active endpoints diverge from the registered ones.
  /// The NC POST updates the existing subscription server-side to the new
  /// proxyServer URL; afterwards the device row lands at the NEW backend via
  /// `PUT me/push-device`. No DELETE at the old proxy: its bearer token belongs
  /// to a different token universe (cleared on endpoint switch) and the stale
  /// row ages out through the backend's cleanup cron once pushes stop.
  Future<void> reRegisterIfEndpointChanged() async {
    if (!await needsEndpointReRegistration()) return;
    log('Push: endpoint changed, re-registering');
    await register();
  }

  /// Pure decision for whether push may be delivered given an OS permission
  /// [status]: only an explicit denial blocks registration. `authorized` and
  /// `provisional` obviously allow it; `notDetermined` is kept permissive so a
  /// transient plugin/platform hiccup never silently disables push (the OS
  /// simply won't show notifications until the user decides).
  static bool isPermissionUsable(AuthorizationStatus status) =>
      status != AuthorizationStatus.denied;

  /// Requests the OS notification permission (covers iOS + Android 13) and
  /// returns whether registration should proceed. Errors from the plugin are
  /// treated as usable — better a possibly-idle registration than silently
  /// losing push over a transient failure.
  static Future<bool> requestOsPermission() async {
    try {
      final settings = await FirebaseMessaging.instance.requestPermission();
      return isPermissionUsable(settings.authorizationStatus);
    } on Object catch (e) {
      log('Push: requestPermission failed: $e');
      return true;
    }
  }

  /// True when the user has explicitly denied the OS notification permission.
  /// Read-only (no prompt) — used by the settings UI to surface the state.
  static Future<bool> isOsPermissionDenied() async {
    try {
      final settings = await FirebaseMessaging.instance
          .getNotificationSettings();
      return settings.authorizationStatus == AuthorizationStatus.denied;
    } on Object {
      return false;
    }
  }

  /// True when the OS notification permission is already granted
  /// (`authorized`/`provisional`). Read-only — never triggers the OS prompt.
  /// Used by the cold-start/self-heal path so it registers only for devices
  /// that already opted in, leaving the actual prompt to the first Talk visit.
  static Future<bool> isOsPermissionGranted() async {
    try {
      final settings = await FirebaseMessaging.instance
          .getNotificationSettings();
      return settings.authorizationStatus == AuthorizationStatus.authorized ||
          settings.authorizationStatus == AuthorizationStatus.provisional;
    } on Object {
      return false;
    }
  }

  /// Registers this device whenever the backend advertises the push capability.
  /// Deliberately independent of the in-app notification toggle: a user who
  /// turned notifications off stays registered so silent sync pushes keep
  /// flowing — the display is suppressed downstream via the mirrored flag (see
  /// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS
  /// notification permission is *already* granted — it never triggers the OS
  /// prompt itself. Requesting the permission is the job of the first Talk visit
  /// (see `maybePromptTalkNotifications`), which keeps the prompt out of the
  /// cold-start path. Safe to call on every start — Nextcloud dedups an
  /// unchanged registration — which also self-heals a device whose registration
  /// was lost.
  /// Returns whether registration was actually *attempted* (all gates passed).
  /// Even a partial success persists the `general` device identifier, so the
  /// caller re-emits telemetry on `true` to reflect the fresh registration in
  /// the same session instead of lagging until the next launch.
  static Future<bool> syncSubscription({required bool capable}) async {
    if (!capable) return false;
    if (!await isOsPermissionGranted()) {
      log('Push: OS notification permission not granted, skipping registration');
      return false;
    }
    final registration = PushRegistration();
    // register() below refreshes an unchanged subscription anyway; the check
    // only surfaces the endpoint switch in the log for diagnosability.
    if (await registration.needsEndpointReRegistration()) {
      log('Push: registered endpoints outdated, re-registering');
    }
    await registration.register();
    return true;
  }

  /// Re-registers after an FCM token refresh. The Nextcloud device identifier
  /// stays stable across refreshes, so this simply re-runs registration (NC
  /// first, then the proxy) with the new token.
  Future<void> onTokenRefresh() => register();

  /// Full teardown for logout: unregister push, revoke BOTH app passwords
  /// (each authenticated with itself — the endpoint revokes the credential it
  /// is called with), then clear them locally. Ordered so the proxy stops
  /// pushing before credentials are gone.
  Future<void> logoutCleanup() async {
    if (DemoMode.active) return;
    await unregister();
    try {
      await DeleteAppPassword().run();
    } on Object catch (e) {
      log('Push: delete app password failed: $e');
    }
    try {
      if (AccountData().hasAppPasswordTalk()) {
        await DeleteAppPassword().run(
          authorizationHeader: AccountData().getTalkBasicAuthHeader(),
        );
      }
    } on Object catch (e) {
      log('Push: delete talk app password failed: $e');
    }
    await AccountData().clearAppPassword();
    await AccountData().clearAppPasswordTalk();
  }
}
