import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/scheduler.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 '../constants.dart';

part 'mitra_chat_notifier.g.dart';

/// Per-second session countdown, decoupled from `mitraChatProvider` so the
/// `session_tick` WS frame doesn't invalidate the entire chat state (which
/// would force `ref.watch(mitraChatProvider)` callers — including the chat
/// screen — to rebuild every second). Watched only by the small timer
/// indicator in the chat AppBar. See mitra_app/CLAUDE.md for the wider perf
/// rationale.
///
/// Manually declared (no @Riverpod annotation) to keep .g.dart codegen
/// minimal — one-line Notifier with no special config.
class MitraChatRemainingSecondsNotifier
    extends AutoDisposeNotifier<int?> {
  @override
  int? build() => null;
  void update(int? seconds) => state = seconds;
  void clear() => state = null;
}

final mitraChatRemainingSecondsProvider =
    NotifierProvider.autoDispose<MitraChatRemainingSecondsNotifier, int?>(
  MitraChatRemainingSecondsNotifier.new,
);

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

class MitraChatInitialData extends MitraChatData {
  const MitraChatInitialData();
}

class MitraChatConnectingData extends MitraChatData {
  const MitraChatConnectingData();
}

class MitraChatConnectedData extends MitraChatData {
  final List<MitraChatMessage> messages;
  final bool isOtherTyping;
  final int? remainingSeconds;
  final bool sessionExpired;
  final bool sessionClosing;
  final bool goodbyeSubmitted;
  final Map<String, dynamic>? extensionRequest;
  final TopicSensitivity topicSensitivity;
  // Phase 4 ESP picks the customer made during onboarding. Read-only,
  // info-only — does not affect matching, pricing, or routing. Sourced from
  // `chat_sessions.topics` via the session info payload.
  final List<String> topics;
  // Phase 4 — voice-call mode badge in header. `chat` is the default (no pill).
  final SessionMode mode;
  // Customer display name + session deadline pulled from /info on connect.
  // AppBar reads displayName (falls back to route-arg "Customer" when null);
  // the timer pill ticks down against expiresAt via a local 1s ticker so it
  // doesn't sit at --:-- waiting for a server warning frame.
  final String? customerDisplayName;
  final DateTime? expiresAt;

  const MitraChatConnectedData({
    required this.messages,
    this.isOtherTyping = false,
    this.remainingSeconds,
    this.sessionExpired = false,
    this.sessionClosing = false,
    this.goodbyeSubmitted = false,
    this.extensionRequest,
    this.topicSensitivity = TopicSensitivity.regular,
    this.topics = const [],
    this.mode = SessionMode.chat,
    this.customerDisplayName,
    this.expiresAt,
  });

  MitraChatConnectedData copyWith({
    List<MitraChatMessage>? messages,
    bool? isOtherTyping,
    int? remainingSeconds,
    bool? sessionExpired,
    bool? sessionClosing,
    bool? goodbyeSubmitted,
    Map<String, dynamic>? extensionRequest,
    bool clearExtensionRequest = false,
    TopicSensitivity? topicSensitivity,
    List<String>? topics,
    SessionMode? mode,
    String? customerDisplayName,
    DateTime? expiresAt,
  }) {
    return MitraChatConnectedData(
      messages: messages ?? this.messages,
      isOtherTyping: isOtherTyping ?? this.isOtherTyping,
      remainingSeconds: remainingSeconds ?? this.remainingSeconds,
      sessionExpired: sessionExpired ?? this.sessionExpired,
      sessionClosing: sessionClosing ?? this.sessionClosing,
      goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
      extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
      topicSensitivity: topicSensitivity ?? this.topicSensitivity,
      topics: topics ?? this.topics,
      mode: mode ?? this.mode,
      customerDisplayName: customerDisplayName ?? this.customerDisplayName,
      expiresAt: expiresAt ?? this.expiresAt,
    );
  }
}

class MitraChatErrorData extends MitraChatData {
  final String message;
  const MitraChatErrorData(this.message);
}

// Message model
class MitraChatMessage {
  final String id;
  final String senderType;
  final String content;
  final String type;
  final String status;
  final DateTime createdAt;

  const MitraChatMessage({
    required this.id,
    required this.senderType,
    required this.content,
    this.type = MessageType.text,
    this.status = MessageStatus.sent,
    required this.createdAt,
  });

  MitraChatMessage copyWith({String? status}) {
    return MitraChatMessage(
      id: id,
      senderType: senderType,
      content: content,
      type: type,
      status: status ?? this.status,
      createdAt: createdAt,
    );
  }
}

@Riverpod(keepAlive: true)
class MitraChat extends _$MitraChat {
  WebSocketChannel? _channel;
  StreamSubscription? _wsSubscription;
  Timer? _typingTimer;
  // Local 1s ticker that updates mitraChatRemainingSecondsProvider against
  // the session's expires_at. Backend's session_timer WS frames only fire
  // at 3min + 1min warnings + expiry — without this ticker the pill stays
  // at --:-- for most of the session.
  Timer? _countdownTimer;
  // Survives `disconnect()` so a later `didChangeAppLifecycleState(resumed)`
  // can re-issue `connect(sessionId)` with the right session — disconnect()
  // resets `state` to MitraChatInitialData, which is otherwise the only
  // record of which chat we were attached to.
  String? _connectedSessionId;

  String? get connectedSessionId => _connectedSessionId;

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

  @override
  MitraChatData build() => const MitraChatInitialData();

  Future<void> connect(String sessionId) async {
    _connectedSessionId = sessionId;
    state = const MitraChatConnectingData();
    try {
      final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
      final sessionData = sessionInfo['data'] as Map<String, dynamic>?;
      final sessionStatus = sessionData?['status'] as String?;
      if (sessionStatus == SessionStatus.completed ||
          sessionStatus == SessionStatus.cancelled ||
          sessionStatus == SessionStatus.expired) {
        state = const MitraChatErrorData('Sesi sudah berakhir.');
        return;
      }

      final isClosing = sessionStatus == SessionStatus.closing;
      final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false;
      final sessionTopic = TopicSensitivity.fromString(sessionData?['topic_sensitivity'] as String?);
      final sessionMode = SessionMode.fromString(sessionData?['mode'] as String?);
      final rawTopics = sessionData?['topics'];
      final espTopics = rawTopics is List
          ? rawTopics.whereType<String>().toList(growable: false)
          : const <String>[];
      final customerDisplayName = sessionData?['customer_display_name'] as String?;
      final expiresAtRaw = sessionData?['expires_at'] as String?;
      final expiresAt = expiresAtRaw == null ? null : DateTime.tryParse(expiresAtRaw);

      // If the customer requested an extension while we were off-WS (e.g.
      // mitra on Home, app backgrounded, FCM tap cold-started us here),
      // `/info` carries the pending row so we can paint `_buildExtensionView`
      // immediately. Without this, the in-chat extension flow would only
      // recover if the customer happened to request again after we connect.
      final pendingExt = sessionData?['pending_extension'] as Map<String, dynamic>?;

      final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
      final messagesData = response['data'] as List<dynamic>;
      final messages = messagesData.map((m) => MitraChatMessage(
        id: m['id'] as String,
        senderType: m['sender_type'] as String,
        content: m['content'] as String,
        type: m['type'] as String? ?? MessageType.text,
        status: m['status'] as String? ?? MessageStatus.sent,
        createdAt: DateTime.parse(m['created_at'] as String),
      )).toList();

      final token = ref.read(authBridgeProvider).accessToken;
      if (token == null) {
        state = const MitraChatErrorData('Sesi berakhir. Silakan login ulang.');
        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>;
          _onMessageReceived(data);
        },
        onError: (_) {},
        onDone: () {},
      );

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

      state = MitraChatConnectedData(
        messages: messages,
        sessionClosing: isClosing,
        goodbyeSubmitted: goodbyeSubmittedByMe,
        topicSensitivity: sessionTopic,
        topics: espTopics,
        mode: sessionMode,
        extensionRequest: pendingExt,
        customerDisplayName: customerDisplayName,
        expiresAt: expiresAt,
      );

      // Kick off the 1s ticker if we have a deadline. Updates the auto-dispose
      // remaining-seconds provider used by the timer pill.
      if (expiresAt != null) _startCountdownTicker(expiresAt);
    } catch (e) {
      state = const MitraChatErrorData('Gagal terhubung ke chat.');
    }
  }

  void disconnect() {
    _cleanup();
    _connectedSessionId = null;
    // State reset is deferred to post-frame: disconnect() is called from
    // mitra_chat_screen's deactivate() during back-nav, and a synchronous
    // `state =` here notifies watchers while the chat screen's widget tree is
    // still being torn down — Riverpod throws "Tried to modify a provider
    // while the widget tree was building". WS is already closed by _cleanup(),
    // so deferring the state reset is a no-op for users.
    SchedulerBinding.instance.addPostFrameCallback((_) {
      state = const MitraChatInitialData();
    });
  }

  void sendMessage(String content) {
    if (state is! MitraChatConnectedData || _channel == null) return;
    final current = state as MitraChatConnectedData;

    final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
    final msg = MitraChatMessage(
      id: tempId,
      senderType: UserType.mitra,
      content: content,
      status: 'sending',
      createdAt: DateTime.now(),
    );

    state = current.copyWith(messages: [...current.messages, msg]);

    _channel!.sink.add(jsonEncode({
      'type': WsMessage.message,
      'content': content,
      '_temp_id': tempId,
    }));
  }

  void sendTyping() {
    if (_channel == null) return;
    _channel!.sink.add(jsonEncode({'type': WsMessage.typing}));
  }

  void markDelivered(List<String> messageIds) {
    if (_channel == null) return;
    _channel!.sink.add(jsonEncode({'type': WsMessage.delivered, 'message_ids': messageIds}));
  }

  void markRead(List<String> messageIds) {
    if (_channel == null) return;
    _channel!.sink.add(jsonEncode({'type': WsMessage.read, 'message_ids': messageIds}));
  }

  void _onMessageReceived(Map<String, dynamic> data) {
    if (state is! MitraChatConnectedData) return;
    final current = state as MitraChatConnectedData;
    final type = data['type'] as String?;

    switch (type) {
      case WsMessage.authOk:
        break;

      case WsMessage.message:
        final msg = MitraChatMessage(
          id: data['message_id'] as String,
          senderType: data['sender_type'] as String,
          content: data['content'] as String,
          type: data['message_type'] as String? ?? MessageType.text,
          status: MessageStatus.sent,
          createdAt: DateTime.parse(data['created_at'] as String),
        );
        state = current.copyWith(messages: [...current.messages, msg]);
        markDelivered([msg.id]);
        break;

      case WsMessage.messageAck:
        final messageId = data['message_id'] as String;
        final status = data['status'] as String;
        final updatedMessages = current.messages.map((m) {
          if (m.status == 'sending') return m.copyWith(status: status);
          return m;
        }).toList();
        final idx = updatedMessages.indexWhere((m) => m.status == status && m.senderType == UserType.mitra);
        if (idx >= 0) {
          final old = updatedMessages[idx];
          updatedMessages[idx] = MitraChatMessage(
            id: messageId,
            senderType: old.senderType,
            content: old.content,
            type: old.type,
            status: status,
            createdAt: old.createdAt,
          );
        }
        state = current.copyWith(messages: updatedMessages);
        break;

      case WsMessage.messageStatus:
        final messageIds = (data['message_ids'] as List<dynamic>).cast<String>();
        final status = data['status'] as String;
        final updatedMessages = current.messages.map((m) {
          if (messageIds.contains(m.id)) return m.copyWith(status: status);
          return m;
        }).toList();
        state = current.copyWith(messages: updatedMessages);
        break;

      case WsMessage.typing:
        state = current.copyWith(isOtherTyping: true);
        _typingTimer?.cancel();
        _typingTimer = Timer(const Duration(seconds: 3), () {
          if (state is MitraChatConnectedData) {
            state = (state as MitraChatConnectedData).copyWith(isOtherTyping: false);
          }
        });
        break;

      case WsMessage.sessionTimer:
        // Route timer ticks to the dedicated provider so the chat state isn't
        // invalidated every second. See [mitraChatRemainingSecondsProvider].
        ref
            .read(mitraChatRemainingSecondsProvider.notifier)
            .update(data['remaining_seconds'] as int?);
        break;

      case WsMessage.sessionExpired:
        state = current.copyWith(sessionExpired: true);
        break;

      case WsMessage.extensionRequest:
        state = current.copyWith(extensionRequest: data);
        break;

      case WsMessage.sessionResumed:
        state = current.copyWith(sessionExpired: false, sessionClosing: false, clearExtensionRequest: true);
        break;

      case WsMessage.sessionClosing:
        state = current.copyWith(sessionClosing: true, clearExtensionRequest: true);
        break;

      case WsMessage.sessionCompleted:
        _cleanup();
        break;

      case WsMessage.sessionTopicUpdated:
        final newValue = TopicSensitivity.fromString(data['topic_sensitivity'] as String?);
        state = current.copyWith(topicSensitivity: newValue);
        break;
    }
  }

  /// Flip the session's topic sensitivity. Returns error code on failure, null on success.
  Future<String?> flipTopic(String sessionId, TopicSensitivity toValue) async {
    try {
      final response = await _apiClient.patch(
        '/api/shared/chat/sessions/$sessionId/topic',
        data: {'topic_sensitivity': toValue.value},
      );
      final updated = TopicSensitivity.fromString(response['data']?['topic_sensitivity'] as String?);
      final current = state;
      if (current is MitraChatConnectedData) {
        state = current.copyWith(topicSensitivity: updated);
      }
      return null;
    } on DioException catch (e) {
      return e.response?.data?['error']?['code'] as String? ?? 'FLIP_FAILED';
    } catch (_) {
      return 'FLIP_FAILED';
    }
  }

  void _cleanup() {
    _wsSubscription?.cancel();
    _wsSubscription = null;
    _channel?.sink.close();
    _channel = null;
    _typingTimer?.cancel();
    _typingTimer = null;
    _countdownTimer?.cancel();
    _countdownTimer = null;
  }

  /// Drives the remaining-seconds provider every second against [deadline].
  /// Backend session_timer WS frames still arrive (3min/1min warnings,
  /// expiry) — they overwrite via the existing _onMessageReceived handler.
  /// On extension accept the connect() flow re-runs (sessionData refreshes)
  /// and the new deadline restarts the ticker.
  void _startCountdownTicker(DateTime deadline) {
    _countdownTimer?.cancel();
    void tick() {
      final remaining = deadline.difference(DateTime.now()).inSeconds;
      final clamped = remaining < 0 ? 0 : remaining;
      ref.read(mitraChatRemainingSecondsProvider.notifier).update(clamped);
    }
    tick(); // initial paint — don't wait a second for the first value
    _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) => tick());
  }
}
