import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
import '../auth/auth_bridge.dart';
import '../chat/active_session_notifier.dart';
import '../constants.dart';

part 'pairing_notifier.g.dart';

// States
sealed class PairingData {
  const PairingData();
}

class PairingInitialData extends PairingData {
  const PairingInitialData();
}

/// General-blast in flight. The chat_session row exists; backend has already
/// notified all available mitras and is waiting for the first to accept.
class PairingSearchingData extends PairingData {
  /// chat_session id (NOT payment_session id).
  final String sessionId;

  /// payment_session id — we keep it on the state so cancelSearch can call
  /// the payment-session-scoped cancel endpoint without re-prompting.
  final String paymentSessionId;

  /// Carried so a retryable PAIRING_FAILED can preserve the customer's original
  /// topic choice when looping back into Blast via retryBlast().
  final TopicSensitivity topicSensitivity;

  const PairingSearchingData({
    required this.sessionId,
    required this.paymentSessionId,
    required this.topicSensitivity,
  });
}

/// "Curhat lagi" 20s wait. The targeted-mitra request has been created and
/// we're waiting for either accept (→ paired), reject/timeout (→ bestie-unavailable
/// popup), or customer cancel (→ home).
///
/// `secondsRemaining` is decremented locally for the overlay countdown. The
/// server is the source of truth for the actual auto-reject; the local timer
/// is purely cosmetic.
class PairingTargetedWaitingData extends PairingData {
  final String paymentSessionId;
  final String mitraId;
  final String mitraName;
  final int secondsRemaining;
  // Carried so the fallback-to-blast path preserves the customer's original choice
  // — otherwise sensitive sessions silently get re-routed as regular.
  final TopicSensitivity topicSensitivity;

  const PairingTargetedWaitingData({
    required this.paymentSessionId,
    required this.mitraId,
    required this.mitraName,
    required this.secondsRemaining,
    required this.topicSensitivity,
  });

  PairingTargetedWaitingData copyWith({int? secondsRemaining}) {
    return PairingTargetedWaitingData(
      paymentSessionId: paymentSessionId,
      mitraId: mitraId,
      mitraName: mitraName,
      secondsRemaining: secondsRemaining ?? this.secondsRemaining,
      topicSensitivity: topicSensitivity,
    );
  }
}

class PairingBestieFoundData extends PairingData {
  final String sessionId;
  final String mitraName;
  const PairingBestieFoundData({required this.sessionId, required this.mitraName});
}

class PairingActiveData extends PairingData {
  final String sessionId;
  final String mitraName;
  const PairingActiveData({required this.sessionId, required this.mitraName});
}

/// Intermediate fail signalled by RETURNING_CHAT_TIMEOUT or RETURNING_CHAT_REJECTED,
/// or by a 409 `targeted_mitra_offline` at request time. Payment session is still
/// `confirmed` server-side — the customer can choose between fallback-to-blast
/// (general blast on the same payment) or going back home (which will leave the
/// payment to expire, no double-charge).
///
/// The UI surfaces this via the bestie-unavailable dialog.
class PairingTargetedUnavailableData extends PairingData {
  final String paymentSessionId;
  final String mitraName;
  final PairingFailureCause cause;
  // Carried so the fallback-to-blast call preserves the customer's original choice.
  final TopicSensitivity topicSensitivity;

  const PairingTargetedUnavailableData({
    required this.paymentSessionId,
    required this.mitraName,
    required this.cause,
    required this.topicSensitivity,
  });
}

/// Pairing failure surfaced on the S7 Timeout screen.
///
/// `isRetryable=true` means the backend kept the payment session `confirmed`
/// (audit-only failure) so the customer can re-blast on the same payment via
/// `retryBlast()`. `isRetryable=false` means the payment is in `failed_pairing`
/// and any retry must start from a fresh payment session.
class PairingFailedData extends PairingData {
  final PairingFailureCause cause;
  final String? paymentSessionId;
  final bool isRetryable;
  // Carried so retryBlast() can re-issue the blast with the customer's original
  // topic choice. Null when the failure originated before any topic was known.
  final TopicSensitivity? topicSensitivity;

  const PairingFailedData({
    required this.cause,
    this.paymentSessionId,
    this.isRetryable = false,
    this.topicSensitivity,
  });
}

class PairingCancelledData extends PairingData {
  const PairingCancelledData();
}

class PairingErrorData extends PairingData {
  final String message;
  const PairingErrorData(this.message);
}

@Riverpod(keepAlive: true)
class Pairing extends _$Pairing {
  Timer? _localCountdownTimer;
  WebSocketChannel? _channel;
  StreamSubscription? _wsSubscription;

  ApiClient get _apiClient => ref.read(apiClientProvider);

  @override
  PairingData build() => const PairingInitialData();

  /// General-blast against a confirmed payment session.
  /// Returns once the chat_session row is created server-side; subsequent
  /// transitions (paired / pairing_failed) arrive via WS.
  Future<void> startSearch({
    required String paymentSessionId,
    required TopicSensitivity topicSensitivity,
  }) async {
    state = const PairingInitialData();
    try {
      await _connectWebSocket();
      final response = await _apiClient.post(
        '/api/client/chat/request',
        data: {
          'payment_session_id': paymentSessionId,
          'topic_sensitivity': topicSensitivity.value,
        },
      );
      final data = response['data'] as Map<String, dynamic>;
      final sessionId = data['id'] as String;
      state = PairingSearchingData(
        sessionId: sessionId,
        paymentSessionId: paymentSessionId,
        topicSensitivity: topicSensitivity,
      );
    } on DioException catch (e) {
      _cleanup();
      final code = e.response?.data?['error']?['code'];
      if (code == 'NO_MITRA_AVAILABLE') {
        // Backend already failed the payment in this case — terminal.
        state = PairingFailedData(
          cause: PairingFailureCause.noMitraAvailable,
          paymentSessionId: paymentSessionId,
        );
      } else if (code == 'ALREADY_ACTIVE') {
        state = const PairingErrorData('Kamu sudah memiliki sesi aktif.');
      } else {
        state = const PairingErrorData('Gagal memulai. Coba lagi.');
      }
    }
  }

  /// Targeted "Curhat lagi" against a specific mitra. The backend creates a
  /// single-recipient notification + 20s server-side timer. Locally we run a
  /// cosmetic countdown for the overlay.
  ///
  /// On 409 `targeted_mitra_offline`: backend recorded an audit-only failure
  /// row (payment stays confirmed) — we transition to TargetedUnavailable so
  /// the UI can offer the fallback dialog.
  Future<void> startTargetedSearch({
    required String paymentSessionId,
    required String mitraId,
    required String mitraName,
    required TopicSensitivity topicSensitivity,
  }) async {
    state = const PairingInitialData();
    try {
      await _connectWebSocket();
      final response = await _apiClient.post(
        '/api/client/chat/chat-requests/returning',
        data: {
          'payment_session_id': paymentSessionId,
          'mitra_id': mitraId,
          'topic_sensitivity': topicSensitivity.value,
        },
      );
      // Backend returns the configured returning_chat_confirmation_timeout_seconds so
      // the overlay countdown matches the server-side timer exactly.
      final sessionData = response['data'] as Map<String, dynamic>?;
      final seconds = (sessionData?['confirmation_timeout_seconds'] as num?)?.toInt() ?? 20;
      state = PairingTargetedWaitingData(
        paymentSessionId: paymentSessionId,
        mitraId: mitraId,
        mitraName: mitraName,
        secondsRemaining: seconds,
        topicSensitivity: topicSensitivity,
      );
      _startLocalCountdown();
    } on DioException catch (e) {
      _cleanup();
      final code = e.response?.data?['error']?['code'];
      final reason = e.response?.data?['error']?['reason'];
      if (code == 'TARGETED_MITRA_OFFLINE' || reason == 'targeted_mitra_offline') {
        // Intermediate — payment session is still confirmed; show the
        // bestie-unavailable popup with a "Chat dengan bestie lain" option.
        state = PairingTargetedUnavailableData(
          paymentSessionId: paymentSessionId,
          mitraName: mitraName,
          cause: PairingFailureCause.targetedMitraOffline,
          topicSensitivity: topicSensitivity,
        );
      } else if (code == 'ALREADY_ACTIVE') {
        state = const PairingErrorData('Kamu sudah memiliki sesi aktif.');
      } else {
        state = const PairingErrorData('Gagal memulai. Coba lagi.');
      }
    }
  }

  /// Customer-initiated cancel during a search/wait. Terminal — payment
  /// session moves to `failed_pairing` server-side. We route the UI to home
  /// (NOT to the failed-pairing screen) since the customer chose this.
  Future<void> cancelSearch() async {
    String? paymentSessionId;
    final current = state;
    if (current is PairingSearchingData) {
      paymentSessionId = current.paymentSessionId;
    } else if (current is PairingTargetedWaitingData) {
      paymentSessionId = current.paymentSessionId;
    }
    if (paymentSessionId == null) {
      _cleanup();
      state = const PairingCancelledData();
      return;
    }
    try {
      await _apiClient.post(
        '/api/client/chat/chat-requests/cancel',
        data: {'payment_session_id': paymentSessionId},
      );
    } catch (_) {
      // Best-effort. Backend will still fail the payment if/when it
      // sweeps stale rows.
    }
    _cleanup();
    state = const PairingCancelledData();
  }

  /// "Chat dengan bestie lain" tapped from the bestie-unavailable dialog.
  /// Reuses the same payment session — backend transitions back into the
  /// general-blast path.
  Future<void> fallbackToBlast({
    required String paymentSessionId,
    required TopicSensitivity topicSensitivity,
  }) async {
    state = const PairingInitialData();
    try {
      await _connectWebSocket();
      final response = await _apiClient.post(
        '/api/client/chat/chat-requests/$paymentSessionId/fallback-to-blast',
        data: {'topic_sensitivity': topicSensitivity.value},
      );
      final data = response['data'] as Map<String, dynamic>;
      final sessionId = data['id'] as String;
      state = PairingSearchingData(
        sessionId: sessionId,
        paymentSessionId: paymentSessionId,
        topicSensitivity: topicSensitivity,
      );
    } on DioException catch (e) {
      _cleanup();
      final code = e.response?.data?['error']?['code'];
      if (code == 'NO_MITRA_AVAILABLE') {
        state = PairingFailedData(
          cause: PairingFailureCause.noMitraAvailable,
          paymentSessionId: paymentSessionId,
        );
      } else {
        state = const PairingErrorData('Gagal memulai. Coba lagi.');
      }
    }
  }

  /// Reset back to initial — used when the failed-pairing screen "Kembali ke
  /// beranda" CTA is tapped, or when the bestie-unavailable dialog is
  /// dismissed via "Kembali".
  void reset() {
    _cleanup();
    state = const PairingInitialData();
  }

  /// "Coba Cari Lagi" CTA on the S7 Timeout screen when the payment was kept
  /// `confirmed` (retryable failure). Re-blasts on the same payment session.
  ///
  /// Caller should only invoke this when `state is PairingFailedData &&
  /// state.isRetryable && paymentSessionId != null && topicSensitivity != null`.
  Future<void> retryBlast() async {
    final current = state;
    if (current is! PairingFailedData
        || !current.isRetryable
        || current.paymentSessionId == null
        || current.topicSensitivity == null) {
      return;
    }
    await startSearch(
      paymentSessionId: current.paymentSessionId!,
      topicSensitivity: current.topicSensitivity!,
    );
  }

  // ---- Internal ---------------------------------------------------------

  Future<void> _connectWebSocket() async {
    _closeWebSocket();
    final token = ref.read(authBridgeProvider).accessToken;
    if (token == null) return;

    final wsUrl = ApiClient.baseUrl
        .replaceFirst('https://', 'wss://')
        .replaceFirst('http://', 'ws://');

    _channel = WebSocketChannel.connect(Uri.parse('$wsUrl/api/shared/ws'));

    _wsSubscription = _channel!.stream.listen(
      (raw) {
        final data = jsonDecode(raw as String) as Map<String, dynamic>;
        if (data['type'] == WsMessage.authOk) return;
        _onWsEvent(data);
      },
      onError: (_) {},
      onDone: () {},
    );

    _channel!.sink.add(jsonEncode({
      'type': WsMessage.auth,
      'token': token,
    }));
  }

  Future<void> _onWsEvent(Map<String, dynamic> data) async {
    final type = data['type'] as String?;
    final current = state;

    if (type == WsMessage.paired) {
      _cleanup();
      final mitraName = data['mitra_display_name'] as String? ?? 'Bestie';
      final sessionId = data['session_id'] as String;
      state = PairingBestieFoundData(sessionId: sessionId, mitraName: mitraName);
      // ignore: unawaited_futures
      ref.read(activeSessionProvider.notifier).refresh();
      await Future.delayed(const Duration(seconds: 2));
      state = PairingActiveData(sessionId: sessionId, mitraName: mitraName);
      return;
    }

    if (type == WsMessage.pairingFailed) {
      final causeTag = data['cause_tag'] as String?;
      final paymentSessionId = data['payment_session_id'] as String?;
      // Missing flag = terminal (backward-compat with older emit sites). When
      // false, the backend kept the payment confirmed and we can re-blast.
      final isRetryable = data['is_terminal'] == false;
      final carriedTopic = current is PairingSearchingData
          ? current.topicSensitivity
          : null;
      _cleanup();
      state = PairingFailedData(
        cause: PairingFailureCause.fromString(causeTag),
        paymentSessionId: paymentSessionId,
        isRetryable: isRetryable,
        topicSensitivity: carriedTopic,
      );
      return;
    }

    if (type == WsMessage.returningChatTimeout || type == WsMessage.returningChatRejected) {
      // Intermediate — payment still confirmed. Show the bestie-unavailable
      // dialog (UI surfaces via state listener).
      _stopLocalCountdown();
      final paymentSessionId = data['payment_session_id'] as String?;
      // Pull mitra name + topic from the prior targeted-waiting state (we know it from
      // the request payload). If we somehow lost it, fall back to safe defaults.
      String mitraName = 'Bestie';
      TopicSensitivity carriedTopic = TopicSensitivity.regular;
      if (current is PairingTargetedWaitingData) {
        mitraName = current.mitraName;
        carriedTopic = current.topicSensitivity;
      }
      state = PairingTargetedUnavailableData(
        paymentSessionId: paymentSessionId ?? (current is PairingTargetedWaitingData ? current.paymentSessionId : ''),
        mitraName: mitraName,
        topicSensitivity: carriedTopic,
        cause: type == WsMessage.returningChatTimeout
            ? PairingFailureCause.targetedMitraTimeout
            : PairingFailureCause.targetedMitraRejected,
      );
      return;
    }

    if (type == SessionStatus.expired) {
      // Legacy event from the older pairing path — treat as terminal "no mitra".
      _cleanup();
      state = const PairingFailedData(cause: PairingFailureCause.noMitraAvailable);
    }
  }

  void _startLocalCountdown() {
    _stopLocalCountdown();
    _localCountdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
      final current = state;
      if (current is! PairingTargetedWaitingData) {
        _stopLocalCountdown();
        return;
      }
      final next = current.secondsRemaining - 1;
      if (next <= 0) {
        _stopLocalCountdown();
        // We don't transition here — the server is the source of truth for
        // the actual auto-reject. The WS event will land within ~1s and
        // transition us to TargetedUnavailable.
        state = current.copyWith(secondsRemaining: 0);
      } else {
        state = current.copyWith(secondsRemaining: next);
      }
    });
  }

  void _stopLocalCountdown() {
    _localCountdownTimer?.cancel();
    _localCountdownTimer = null;
  }

  void _closeWebSocket() {
    _wsSubscription?.cancel();
    _wsSubscription = null;
    _channel?.sink.close();
    _channel = null;
  }

  void _cleanup() {
    _stopLocalCountdown();
    _closeWebSocket();
  }
}
