import {
  ClientMessageType,
  ServerMessageType,
  createAckMessage,
  createHeartbeatAckMessage,
  createLeaveMessage,
  createSyncPracticeInfoMessage,
  decodeServerMessage,
  getServerMessageTypeName,
} from "@/utils/matchProtocol";
import { MESSAGETYPESV2 } from "@/constants";
import { getDirectionText } from "@/util";
import {
  normalizeId,
  normalizeMatchInfo,
  normalizePlainObject,
  pickField,
} from "@/utils/matchAdapter";

// 比赛服 websocket 独立管理器：
// 负责连接、解码、ACK/LEAVE，并把比赛服消息适配成项目现有 socket-inbox 业务事件。
let socket = null;
let currentContext = null;
let isConnecting = false;
let manualClose = false;
let audioAckListenerReady = false;
let lastReadyRouteKey = "";
let reconnectTimer = null;
let connectTimeoutTimer = null;
let livenessTimer = null;
let reconnectAttempt = 0;
let networkConnected = true;
let lastNetworkType = "";

// 后端要求非心跳消息必须等前端语音播报完成后再 ACK。
const pendingAcks = [];
const ACK_AUDIO_TIMEOUT_MS = 9000;
const READY_ROUTE_FALLBACK_DELAY_MS = 300;
const CONNECT_TIMEOUT_MS = 8000;
const LIVENESS_TIMEOUT_MS = 30000;
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000];
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
const MATCH_STATUS_HALF_REST = 3;

// 比赛服消息类型先映射成项目里已有的 V2 业务消息，页面仍然复用原来的 socket-inbox 流程。
const BUSINESS_TYPE_BY_SERVER_TYPE = {
  [ServerMessageType.SERVER_MSG_MATCH_READY]: MESSAGETYPESV2.AboutToStart,
  [ServerMessageType.SERVER_MSG_MATCH_START]: MESSAGETYPESV2.BattleStart,
  [ServerMessageType.SERVER_MSG_NOW_YOU]: MESSAGETYPESV2.ToSomeoneShoot,
  [ServerMessageType.SERVER_MSG_SHOT]: MESSAGETYPESV2.ShootResult,
  [ServerMessageType.SERVER_MSG_NEW_ROUND]: MESSAGETYPESV2.NewRound,
  [ServerMessageType.SERVER_MSG_MATCH_END]: MESSAGETYPESV2.BattleEnd,
  [ServerMessageType.SERVER_MSG_CHECK]: MESSAGETYPESV2.TestDistance,
  [ServerMessageType.SERVER_MSG_NOT_ENOUGH_DISTANCE]: MESSAGETYPESV2.InvalidShot,
  [ServerMessageType.SERVER_MSG_PRACTICE_END]: MESSAGETYPESV2.BattleEnd,
};

const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
export const MATCH_WS_AUDIO_ACK_EVENT = "match-ws-audio-ack";
export const MATCH_WS_STATE_EVENT = "match-ws-state";
export const MATCH_WS_PRACTICE_SYNC_EVENT = "match-ws-practice-sync";

function normalizeShootData(shootData) {
  if (!shootData || typeof shootData !== "object") return shootData;
  const normalized = { ...shootData };
  if (normalized.distance === undefined && normalized.dst !== undefined) {
    normalized.distance = normalized.dst;
  }
  return normalized;
}

// 兼容普通接口通知的 camelCase 和 protobuf 解码后的 snake_case。
function normalizePracticeInfo(practiceInfo = {}) {
  if (!practiceInfo || typeof practiceInfo !== "object") return {};

  const normalized = normalizePlainObject(practiceInfo);
  if (practiceInfo.shoot_data || normalized.shootData) {
    normalized.shootData = normalizeShootData(
      normalizePlainObject(practiceInfo.shoot_data || normalized.shootData)
    );
  }
  if (Array.isArray(practiceInfo.details || normalized.details)) {
    normalized.details = (practiceInfo.details || normalized.details)
      .map(normalizePlainObject)
      .map(normalizeShootData);
  }

  return normalized;
}

function getCurrentMode(message = {}, normalizedInfo = {}) {
  const mode = Number(
    pickField(message, "mode") ??
      normalizedInfo.mode ??
      currentContext?.mode
  );
  return Number.isFinite(mode) ? mode : undefined;
}

function isMeleeMessage(message = {}, normalizedInfo = {}) {
  const mode = getCurrentMode(message, normalizedInfo);
  if (mode !== undefined) return mode > 3;
  if (currentContext?.isMelee !== undefined) return currentContext.isMelee;
  return normalizeRoute(getCurrentPageInfo()?.route) === "pages/melee-battle";
}

function isHalfRestTimeout(message = {}, matchInfo = {}) {
  if (message.type !== ServerMessageType.SERVER_MSG_TIMEOUT) return false;
  if (!isMeleeMessage(message, matchInfo)) return false;

  const status = Number(pickField(message, "status") ?? matchInfo.status);
  return status === MATCH_STATUS_HALF_REST || !Number.isFinite(status);
}

function getBusinessType(message, matchInfo) {
  if (isHalfRestTimeout(message, matchInfo)) return MESSAGETYPESV2.HalfRest;
  if (message.type === ServerMessageType.SERVER_MSG_TIMEOUT) {
    return MESSAGETYPESV2.BattleEnd;
  }
  return BUSINESS_TYPE_BY_SERVER_TYPE[message.type];
}

function buildBusinessMessage(message) {
  const matchInfo = normalizeMatchInfo(message.match_info);
  if (
    message.type === ServerMessageType.SERVER_MSG_MATCH_READY &&
    (matchInfo.status === undefined ||
      matchInfo.status === null ||
      matchInfo.status === "")
  ) {
    matchInfo.status = 0;
  }
  const practiceInfo = normalizePracticeInfo(message.practice_info);
  const businessType = getBusinessType(message, matchInfo);
  if (!businessType) return null;

  const mode = getCurrentMode(message, matchInfo);
  const isMelee = isMeleeMessage(message, matchInfo);
  const isSecondHalfStart =
    message.type === ServerMessageType.SERVER_MSG_MATCH_START &&
    isMelee &&
    currentContext?.meleeHalfRest === true;

  if (currentContext) {
    if (mode !== undefined) {
      currentContext.mode = mode;
      currentContext.isMelee = isMelee;
    } else if (isMelee) {
      currentContext.isMelee = true;
    }
    if (businessType === MESSAGETYPESV2.HalfRest) {
      currentContext.meleeHalfRest = true;
    } else if (message.type === ServerMessageType.SERVER_MSG_MATCH_START) {
      currentContext.meleeHalfRest = false;
    }
  }

  const matchId = normalizeId(
    pickField(message, "matchId", "match_id") ||
      matchInfo.matchId ||
      practiceInfo.id ||
      currentContext?.matchId
  );
  const shootData = normalizeShootData(
    matchInfo.shootData ||
      practiceInfo.shootData ||
      (message.shoot_data ? normalizePlainObject(message.shoot_data) : undefined)
  );
  const details = Array.isArray(practiceInfo.details)
    ? practiceInfo.details
    : matchInfo.details;

  return {
    ...matchInfo,
    ...practiceInfo,
    type: businessType,
    id: matchId,
    matchId,
    shootData,
    details,
    sequence: message.sequence,
    timestamp: message.timestamp,
    matchWsType: message.type,
    matchWsTypeName: getServerMessageTypeName(message.type),
    isSecondHalfStart,
  };
}

function buildPracticeSyncMessage(message) {
  const practiceInfo = normalizePracticeInfo(message.practice_info);
  const matchId = normalizeId(
    pickField(message, "matchId", "match_id") ||
      practiceInfo.id ||
      currentContext?.matchId
  );

  return {
    matchId,
    timestamp: message.timestamp,
    sequence: message.sequence,
    practiceInfo,
  };
}

function getReadySnapshotKey(matchId) {
  return `${MATCH_READY_SNAPSHOT_PREFIX}${normalizeId(matchId)}`;
}

function persistReadyTeams(message) {
  const teams = message?.teams || {};
  if (Number(message?.mode) > 3) return;

  const bluePlayers = teams[1]?.players || teams["1"]?.players;
  const redPlayers = teams[2]?.players || teams["2"]?.players;
  if (Array.isArray(bluePlayers)) uni.setStorageSync("blue-team", bluePlayers);
  if (Array.isArray(redPlayers)) uni.setStorageSync("red-team", redPlayers);
}

function persistReadySnapshot(message) {
  const matchId = normalizeId(message?.matchId || message?.id);
  if (!matchId) return;
  uni.setStorageSync(getReadySnapshotKey(matchId), {
    ...message,
    savedAt: Date.now(),
  });
}

function getBattlePageUrl(message) {
  const matchId = normalizeId(message?.matchId || message?.id);
  const mode = Number(message?.mode);
  if (!matchId) return "";
  if (!Number.isFinite(mode)) return "";
  return mode <= 3
    ? `/pages/team-battle/index?battleId=${matchId}`
    : `/pages/melee-battle?battleId=${matchId}`;
}

function getCurrentPageInfo() {
  if (typeof getCurrentPages !== "function") return null;
  const pages = getCurrentPages();
  return pages[pages.length - 1] || null;
}

function normalizeRoute(route) {
  return String(route || "").replace(/^\//, "");
}

function getRouteFromUrl(url) {
  return normalizeRoute(String(url || "").split("?")[0]);
}

function isCurrentBattlePage(url, matchId) {
  const page = getCurrentPageInfo();
  const route = normalizeRoute(page?.route);
  const targetRoute = getRouteFromUrl(url);
  if (!targetRoute || route !== targetRoute) return false;

  const options = page?.options || page?.$page?.options || {};
  const expectedMatchId = normalizeId(matchId);
  if (!expectedMatchId) return true;

  const routeMatchId = normalizeId(options.battleId);
  if (routeMatchId) return routeMatchId === expectedMatchId;

  return Boolean(
    options.fromReturn &&
      normalizeId(currentContext?.matchId) === expectedMatchId
  );
}

function scheduleReadyRouteFallback(message) {
  const url = getBattlePageUrl(message);
  const matchId = normalizeId(message?.matchId || message?.id);
  const routeKey = `${matchId}:${url}`;
  if (!url || lastReadyRouteKey === routeKey) return;

  lastReadyRouteKey = routeKey;
  setTimeout(() => {
    if (isCurrentBattlePage(url, matchId)) return;

    const currentRoute = normalizeRoute(getCurrentPageInfo()?.route);
    // battle-room 和 match-page 本身会消费 AboutToStart；这里不绕过页面内状态，避免好友房误触退房逻辑。
    if (currentRoute === "pages/battle-room" || currentRoute === "pages/match-page") return;

    uni.redirectTo({
      url,
      fail: (err) => {
        console.log("[match-ws] ready route fallback failed", err, { url });
      },
    });
  }, READY_ROUTE_FALLBACK_DELAY_MS);
}

function emitBusinessMessage(message) {
  if (!message) return;
  if (message.type === MESSAGETYPESV2.AboutToStart) {
    persistReadyTeams(message);
    persistReadySnapshot(message);
    scheduleReadyRouteFallback(message);
  }

  uni.$emit("socket-inbox", message);
}

function resolvePlayerTeam(message, playerId) {
  const teams = message?.teams || {};
  const redPlayers = teams[2]?.players || teams["2"]?.players || [];
  return redPlayers.some((item) => String(item.id) === String(playerId))
    ? "red"
    : "blue";
}

function getNowYouAudioKeys(message) {
  const current = message?.current || {};
  const keys = [];

  if (current.goldRound) {
    keys.push("决金箭轮");
  } else if (current.round) {
    const roundIndex = Math.max(0, Number(current.round) - 1);
    const roundName = ROUND_AUDIO_NAMES[roundIndex] || current.round;
    keys.push(`第${roundName}轮`);
  }

  if (String(current.playerId) === String(currentContext?.userId)) {
    keys.push("轮到你了");
    return keys;
  }

  const team = resolvePlayerTeam(message, current.playerId);
  keys.push(team === "red" ? "请红方射箭" : "请蓝方射箭");
  return keys;
}

function getShootResultAudioKeys(shootData) {
  if (!shootData) return [];
  const keys = [
    shootData.ring ? `${shootData.ringX ? "X" : shootData.ring}环` : "未上靶",
  ];
  if (shootData.angle !== null && shootData.angle !== undefined) {
    keys.push(`向${getDirectionText(shootData.angle)}调整`);
  }
  if (shootData.threeConsecutive10Rings === true) {
    keys.push("tententen");
  }
  return keys;
}

function getTestDistanceAudioKeys(shootData) {
  const distance = Number(shootData?.distance ?? shootData?.dst);
  if (Number.isNaN(distance)) return [];
  if (distance === 0) return ["未发现靶纸，请瞄准靶纸射箭"];
  return [distance / 100 >= 5 ? "\u8ddd\u79bb\u5408\u683c" : "\u8ddd\u79bb\u4e0d\u8db3"];
}

function getAckAudioKeys(message, businessMessage) {
  switch (message.type) {
    case ServerMessageType.SERVER_MSG_MATCH_START:
      return [businessMessage?.isSecondHalfStart ? "下半场开始" : "比赛开始"];
    case ServerMessageType.SERVER_MSG_NOW_YOU:
      if (isMeleeMessage(message, businessMessage)) return [];
      return getNowYouAudioKeys(businessMessage);
    case ServerMessageType.SERVER_MSG_SHOT:
      if (
        isMeleeMessage(message, businessMessage) &&
        String(businessMessage?.shootData?.playerId) !== String(currentContext?.userId)
      ) {
        return [];
      }
      return getShootResultAudioKeys(businessMessage?.shootData);
    case ServerMessageType.SERVER_MSG_MATCH_END:
      return ["比赛结束"];
    case ServerMessageType.SERVER_MSG_TIMEOUT:
      if (businessMessage?.type === MESSAGETYPESV2.HalfRest) return ["中场休息"];
      return ["比赛结束"];
    case ServerMessageType.SERVER_MSG_PRACTICE_END:
      return [currentContext?.practiceEndAudioKey || "比赛结束"];
    case ServerMessageType.SERVER_MSG_CHECK:
      return getTestDistanceAudioKeys(businessMessage?.shootData);
    case ServerMessageType.SERVER_MSG_NOT_ENOUGH_DISTANCE:
      return ["射击无效"];
    default:
      return [];
  }
}

function shouldCloseAfterAck(message, businessMessage) {
  if (
    message.type === ServerMessageType.SERVER_MSG_TIMEOUT &&
    businessMessage?.type === MESSAGETYPESV2.HalfRest
  ) {
    return false;
  }

  return (
    message.type === ServerMessageType.SERVER_MSG_MATCH_END ||
    message.type === ServerMessageType.SERVER_MSG_TIMEOUT ||
    message.type === ServerMessageType.SERVER_MSG_PRACTICE_END
  );
}

// 追加 token 查询参数；如果后端地址已经带 token，则保持原样。
function appendQuery(url, key, value) {
  if (!value || new RegExp(`[?&]${key}=`).test(url)) return url;
  return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(
    value
  )}`;
}

// 后端可能下发完整 ws 地址，也可能只给 host:port，这里统一转成可连接地址。
function normalizeServerUrl(serverAddr, token) {
  if (!serverAddr || typeof serverAddr !== "string") return "";

  let url = serverAddr.trim();
  if (!url) return "";

  if (!/^wss?:\/\//i.test(url)) {
    const [address, query = ""] = url.split("?");
    const hasPath = address.includes("/");
    const host = hasPath
      ? address
      : `${address}${address.includes(":") ? "" : ":8011"}/ws`;
    url = `ws://${host}${query ? `?${query}` : ""}`;
  }

  return appendQuery(url, "token", token);
}

function getServerEndpoint(url) {
  return String(url || "").split("?")[0];
}

function getUrlToken(url) {
  const match = String(url || "").match(/[?&]token=([^&#]+)/i);
  if (!match?.[1]) return "";
  try {
    return decodeURIComponent(match[1]);
  } catch (_) {
    return match[1];
  }
}

function emitMatchSocketState(state, extra = {}) {
  uni.$emit(MATCH_WS_STATE_EVENT, {
    state,
    matchId: currentContext?.matchId || "",
    ...extra,
  });
}

function clearReconnectTimer() {
  if (!reconnectTimer) return;
  clearTimeout(reconnectTimer);
  reconnectTimer = null;
}

function clearConnectTimeout() {
  if (!connectTimeoutTimer) return;
  clearTimeout(connectTimeoutTimer);
  connectTimeoutTimer = null;
}

function clearLivenessTimer() {
  if (!livenessTimer) return;
  clearTimeout(livenessTimer);
  livenessTimer = null;
}

function clearSocketHealthTimers() {
  clearConnectTimeout();
  clearLivenessTimer();
}

function closeSocketTask(socketTask, reason) {
  if (!socketTask) return;
  try {
    socketTask.close({ reason });
  } catch (err) {
    console.log("[match-ws] close socket task failed", reason, err);
  }
}

function detachCurrentSocket(reason) {
  const currentSocket = socket;
  socket = null;
  isConnecting = false;
  clearSocketHealthTimers();
  closeSocketTask(currentSocket, reason);
}

function scheduleReconnect(reason, { immediate = false } = {}) {
  if (
    manualClose ||
    !currentContext ||
    !networkConnected ||
    reconnectTimer ||
    socket ||
    isConnecting
  ) {
    return;
  }

  const delay = immediate
    ? 0
    : RECONNECT_DELAYS_MS[
        Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)
      ];
  const attempt = reconnectAttempt + 1;
  reconnectAttempt = attempt;
  emitMatchSocketState("reconnecting", { reason, delay, attempt });

  reconnectTimer = setTimeout(() => {
    reconnectTimer = null;
    if (manualClose || !currentContext || !networkConnected) return;
    connectMatchWebSocket({
      ...currentContext,
      force: true,
      reconnecting: true,
      reconnectReason: reason,
    });
  }, delay);
}

function handleUnexpectedDisconnect(
  socketTask,
  reason,
  detail,
  { closeTask = true } = {}
) {
  if (socket !== socketTask) return;

  socket = null;
  isConnecting = false;
  clearSocketHealthTimers();
  if (closeTask) closeSocketTask(socketTask, reason);

  console.log("[match-ws] unexpected disconnect", {
    reason,
    detail,
    context: currentContext,
  });
  emitMatchSocketState(networkConnected ? "disconnected" : "offline", {
    reason,
  });
  scheduleReconnect(reason);
}

function startConnectTimeout(socketTask) {
  clearConnectTimeout();
  connectTimeoutTimer = setTimeout(() => {
    handleUnexpectedDisconnect(socketTask, "connect-timeout");
  }, CONNECT_TIMEOUT_MS);
}

function resetLivenessTimer(socketTask) {
  clearLivenessTimer();
  livenessTimer = setTimeout(() => {
    handleUnexpectedDisconnect(socketTask, "heartbeat-timeout");
  }, LIVENESS_TIMEOUT_MS);
}

export function forceReconnectMatchWebSocket(reason = "force-reconnect") {
  if (manualClose || !currentContext) return false;

  clearReconnectTimer();
  detachCurrentSocket(reason);
  if (!networkConnected) {
    emitMatchSocketState("offline", { reason });
    return false;
  }

  reconnectAttempt = 0;
  scheduleReconnect(reason, { immediate: true });
  return true;
}

export function handleMatchNetworkStatusChange(status = {}) {
  const previousConnected = networkConnected;
  const networkType = String(status.networkType || "");
  const nextConnected =
    status.isConnected !== false && networkType.toLowerCase() !== "none";
  const networkTypeChanged =
    !!lastNetworkType && !!networkType && lastNetworkType !== networkType;

  networkConnected = nextConnected;
  if (networkType) lastNetworkType = networkType;
  if (manualClose || !currentContext) return;

  if (!nextConnected) {
    clearReconnectTimer();
    detachCurrentSocket("network-offline");
    emitMatchSocketState("offline", { reason: "network-offline" });
    return;
  }

  if (!previousConnected || networkTypeChanged) {
    forceReconnectMatchWebSocket(
      networkTypeChanged ? "network-type-changed" : "network-recovered"
    );
  }
}

// 统一发送二进制 protobuf 消息，并在日志里保留发送类型。
function sendBuffer(buffer, label, clientMessage) {
  if (!socket || !buffer) return;

  const currentSocket = socket;
  currentSocket.send({
    data: buffer,
    success: () => {
      console.log("发送比赛服 WebSocket 消息", label, clientMessage);
    },
    fail: (err) => {
      if (socket !== currentSocket) return;
      console.log("比赛服 WebSocket 消息发送失败", label, clientMessage, err);
      handleUnexpectedDisconnect(currentSocket, "send-failed", err);
    },
  });
}

function sendHeartbeatAck() {
  // 心跳 ACK 不进入 pendingAcks，收到后立即回复。
  const clientMessage = {
    type: ClientMessageType.CLIENT_MSG_HEARTBEAT_ACK,
  };
  sendBuffer(
    createHeartbeatAckMessage(),
    "CLIENT_MSG_HEARTBEAT_ACK",
    clientMessage
  );
}

function sendPracticeInfoSync() {
  if (!socket || !currentContext?.matchId || !currentContext?.userId) return;

  const clientMessage = {
    type: ClientMessageType.CLIENT_MSG_SYNC_PRACTICE_INFO,
    match_id: currentContext.matchId,
    user_id: currentContext.userId,
  };
  sendBuffer(
    createSyncPracticeInfoMessage({
      matchId: clientMessage.match_id,
      userId: clientMessage.user_id,
    }),
    "CLIENT_MSG_SYNC_PRACTICE_INFO",
    clientMessage
  );
}

function sendAck({ matchId, sequence }) {
  // sequence 由后端处理，前端只原样带回，不做重排和断线补发。
  if (sequence === undefined || sequence === null || sequence === "") return;
  const clientMessage = {
    type: ClientMessageType.CLIENT_MSG_ACK,
    match_id: matchId,
    sequence,
  };
  sendBuffer(
    createAckMessage({ matchId, sequence }),
    "CLIENT_MSG_ACK",
    clientMessage
  );
}

function completeAckTask(task) {
  if (task?.timer) clearTimeout(task.timer);
  sendAck(task);
  if (task.leaveAfterAck) {
    setTimeout(() => {
      closeMatchWebSocket({ reason: "match-end" });
    }, 0);
  }
}

function isSameAckTask(task, payload = {}) {
  if (String(task?.sequence) !== String(payload.sequence)) return false;
  if (!payload.matchId) return true;
  return String(task?.matchId) === String(payload.matchId);
}

function completePendingAckByMessage(payload = {}) {
  if (payload.sequence === undefined || payload.sequence === null || payload.sequence === "") {
    return;
  }

  const index = pendingAcks.findIndex((task) => isSameAckTask(task, payload));
  if (index === -1) return;

  const [task] = pendingAcks.splice(index, 1);
  console.log("[match-ws] ack after page audio", task.sequence, payload);
  completeAckTask(task);
}

function flushPendingAcks() {
  // 每次语音播报完成，只确认队首那条等待相同语音 key 的服务端消息。
  if (!pendingAcks.length) return;
  const endedKey = arguments[0];
  const task = pendingAcks[0];
  if (task.expectedAudioKey && endedKey && task.expectedAudioKey !== endedKey) {
    return;
  }
  completeAckTask(pendingAcks.shift());
}

function ensureAudioAckListener() {
  // 复用现有全局 audioEnded 事件，确保 ACK 时机落在播报结束之后。
  if (audioAckListenerReady) return;
  uni.$on("audioEnded", flushPendingAcks);
  uni.$on(MATCH_WS_AUDIO_ACK_EVENT, completePendingAckByMessage);
  audioAckListenerReady = true;
}

function removeAudioAckListener() {
  if (!audioAckListenerReady) return;
  uni.$off("audioEnded", flushPendingAcks);
  uni.$off(MATCH_WS_AUDIO_ACK_EVENT, completePendingAckByMessage);
  audioAckListenerReady = false;
}

function queueAckAfterAudio(message, businessMessage) {
  // 终止消息即使没有 sequence，也要等结束语音完成后主动关闭连接。
  const leaveAfterAck = shouldCloseAfterAck(message, businessMessage);
  const hasSequence =
    message.sequence !== undefined &&
    message.sequence !== null &&
    message.sequence !== "";
  if (!hasSequence && !leaveAfterAck) return;

  const task = {
    matchId: normalizeId(
      pickField(message, "matchId", "match_id") || currentContext?.matchId
    ),
    sequence: message.sequence,
    leaveAfterAck,
  };
  const actionLabel = hasSequence ? "ack" : "terminal close";

  const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean);
  if (!audioKeys.length) {
    console.log(
      `[match-ws] ${actionLabel} immediately without audio`,
      getServerMessageTypeName(message.type),
      message.sequence
    );
    completeAckTask(task);
    return;
  }

  task.expectedAudioKey = audioKeys[audioKeys.length - 1];
  task.timer = setTimeout(() => {
    const index = pendingAcks.indexOf(task);
    if (index === -1) return;
    pendingAcks.splice(index, 1);
    console.log(
      `[match-ws] ${actionLabel} audio wait timeout`,
      getServerMessageTypeName(message.type),
      message.sequence,
      task.expectedAudioKey
    );
    completeAckTask(task);
  }, ACK_AUDIO_TIMEOUT_MS);
  pendingAcks.push(task);
  console.log(
    `[match-ws] ${actionLabel} queued until audioEnded`,
    getServerMessageTypeName(message.type),
    message.sequence,
    task.expectedAudioKey
  );
}

function handleMessage(data) {
  // 比赛服下发 raw protobuf frame，先解码，再根据消息类型决定是否 ACK。
  let message;
  try {
    message = decodeServerMessage(data);
  } catch (err) {
    console.log("[match-ws] decode failed", err, data);
    return;
  }

  if (message.type === ServerMessageType.SERVER_MSG_HEARTBEAT) {
    sendHeartbeatAck();
    return;
  }

  const typeName = getServerMessageTypeName(message.type);
  console.log("收到比赛服 WebSocket 消息", typeName, message);

  const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id"));
  if (message.type === ServerMessageType.SERVER_MSG_SYNC_PRACTICE_INFO) {
    // 同步响应是完整快照，不映射成开始/报靶等实时事件，避免重放页面副作用。
    queueAckAfterAudio(message, null);
    if (
      decodedMatchId &&
      currentContext?.matchId &&
      decodedMatchId !== currentContext.matchId
    ) {
      console.log("[match-ws] ignore mismatched practice sync", {
        expectedMatchId: currentContext.matchId,
        receivedMatchId: decodedMatchId,
      });
      return;
    }

    const syncMessage = buildPracticeSyncMessage(message);
    if (syncMessage.matchId && currentContext) {
      currentContext.matchId = syncMessage.matchId;
    }
    uni.$emit(MATCH_WS_PRACTICE_SYNC_EVENT, syncMessage);
    return;
  }

  if (decodedMatchId && currentContext) {
    currentContext.matchId = decodedMatchId;
  }

  const businessMessage = buildBusinessMessage(message);
  if (businessMessage?.matchId && currentContext) {
    currentContext.matchId = businessMessage.matchId;
  }
  queueAckAfterAudio(message, businessMessage);
  emitBusinessMessage(businessMessage);
}

function sendLeave() {
  // 主动关闭、比赛结束、页面离开时发 CLIENT_MSG_LEAVE。
  if (!socket || !currentContext?.matchId) return;
  const clientMessage = {
    type: ClientMessageType.CLIENT_MSG_LEAVE,
    match_id: currentContext.matchId,
    user_id: currentContext.userId,
  };
  sendBuffer(
    createLeaveMessage({
      matchId: clientMessage.match_id,
      userId: clientMessage.user_id,
    }),
    "CLIENT_MSG_LEAVE",
    clientMessage
  );
}

export function connectMatchWebSocket(options = {}) {
  // 入口参数来自原 websocket 的比赛服地址通知，不影响原有 websocket 连接。
  const {
    serverAddr,
    matchId,
    userId,
    token,
    mode,
    requestPracticeInfoOnOpen = false,
    appHideResumable = false,
    practiceEndAudioKey = "",
    force = false,
    reconnecting = false,
    reconnectReason = "",
  } = options;
  const normalizedMatchId = normalizeId(matchId);
  const normalizedUserId = normalizeId(userId);
  const normalizedMode = Number(mode);
  const incomingUrl = normalizeServerUrl(serverAddr, token);
  const currentUrlToken = getUrlToken(currentContext?.url);
  const shouldKeepAuthenticatedUrl = !!(
    currentContext &&
    currentContext.matchId === normalizedMatchId &&
    getServerEndpoint(currentContext.url) === getServerEndpoint(incomingUrl) &&
    currentUrlToken &&
    !getUrlToken(incomingUrl)
  );
  const url = shouldKeepAuthenticatedUrl ? currentContext.url : incomingUrl;
  const resolvedServerAddr = shouldKeepAuthenticatedUrl
    ? currentContext.serverAddr
    : serverAddr;
  const resolvedToken = shouldKeepAuthenticatedUrl
    ? currentContext.token || currentUrlToken
    : token;

  if (shouldKeepAuthenticatedUrl) {
    console.log("[match-ws] keep authenticated url for same match", {
      matchId: normalizedMatchId,
      endpoint: getServerEndpoint(url),
    });
  }

  if (!url || !normalizedMatchId) {
    console.log("[match-ws] missing serverAddr or matchId", options);
    return;
  }

  const isSameContext =
    currentContext?.url === url &&
    currentContext?.matchId === normalizedMatchId;
  if (!force && socket && isSameContext) {
    // 同一场比赛同一地址重复通知时不重复建连。
    console.log("[match-ws] already connected or connecting", {
      matchId: normalizedMatchId,
      url,
    });
    return;
  }

  if (force && isSameContext) {
    // 网络切换或假死重连时只销毁旧链路，保留比赛上下文且不发送 LEAVE。
    detachCurrentSocket(reconnectReason || "force-reconnect");
  } else if (!isSameContext) {
    // 切换比赛服地址时先关闭旧连接，不发送 LEAVE，避免误通知旧比赛离场。
    closeMatchWebSocket({ sendLeave: false, reason: "switch" });
  }

  clearReconnectTimer();
  manualClose = false;
  isConnecting = true;
  if (!isSameContext) reconnectAttempt = 0;
  const connectedOnce = isSameContext && currentContext?.connectedOnce === true;
  currentContext = {
    serverAddr: resolvedServerAddr,
    matchId: normalizedMatchId,
    userId: normalizedUserId,
    token: resolvedToken,
    url,
    mode: Number.isFinite(normalizedMode) ? normalizedMode : undefined,
    isMelee:
      Number.isFinite(normalizedMode) ? normalizedMode > 3 : undefined,
    requestPracticeInfoOnOpen: requestPracticeInfoOnOpen === true,
    appHideResumable: appHideResumable === true,
    practiceEndAudioKey: String(practiceEndAudioKey || "").trim(),
    meleeHalfRest: isSameContext
      ? currentContext?.meleeHalfRest === true
      : false,
    connectedOnce,
  };
  ensureAudioAckListener();
  emitMatchSocketState(reconnecting ? "reconnecting" : "connecting", {
    reason: reconnectReason,
    attempt: reconnectAttempt,
  });

  const socketTask = uni.connectSocket({
    url,
    success: () => {
      console.log("[match-ws] connect requested", {
        matchId: normalizedMatchId,
        url,
      });
    },
    fail: (err) => {
      if (socket !== socketTask) return;
      socket = null;
      isConnecting = false;
      console.log("[match-ws] connect failed", err);
      clearSocketHealthTimers();
      emitMatchSocketState(networkConnected ? "disconnected" : "offline", {
        reason: "connect-failed",
      });
      scheduleReconnect("connect-failed");
    },
  });

  socket = socketTask;
  startConnectTimeout(socketTask);

  socketTask.onOpen(() => {
    if (socket !== socketTask) return;
    clearConnectTimeout();
    isConnecting = false;
    const wasReconnected =
      currentContext?.connectedOnce === true || reconnecting || reconnectAttempt > 0;
    if (currentContext) currentContext.connectedOnce = true;
    reconnectAttempt = 0;
    resetLivenessTimer(socketTask);
    console.log("[match-ws] connected", {
      matchId: normalizedMatchId,
      url,
      reconnected: wasReconnected,
    });
    emitMatchSocketState("open", {
      reason: reconnectReason,
      reconnected: wasReconnected,
    });
    if (currentContext?.requestPracticeInfoOnOpen) {
      sendPracticeInfoSync();
    }
  });

  socketTask.onMessage((res) => {
    if (socket !== socketTask) return;
    resetLivenessTimer(socketTask);
    handleMessage(res.data);
  });

  socketTask.onError((err) => {
    if (socket !== socketTask) return;
    console.log("[match-ws] socket error", err);
    handleUnexpectedDisconnect(socketTask, "socket-error", err);
  });

  socketTask.onClose((result) => {
    if (socket !== socketTask) return;
    console.log("[match-ws] closed", {
      manualClose,
      result,
      context: currentContext,
    });
    if (manualClose) return;
    handleUnexpectedDisconnect(socketTask, "socket-close", result, {
      closeTask: false,
    });
  });
}

export function connectMatchWebSocketFromNotice(notice, fallbackUserId) {
  // 原 websocket 下发的比赛服通知统一从这里转成连接参数。
  if (!notice) return;
  connectMatchWebSocket({
    serverAddr: pickField(notice, "serverAddr", "server_addr"),
    matchId: pickField(notice, "matchId", "match_id"),
    userId: pickField(notice, "userId", "user_id") || fallbackUserId,
    mode: pickField(notice, "mode"),
    token: notice.token || notice.wsToken || notice.ws_token,
  });
}

export function setMatchAppHideResumable(enabled) {
  if (!currentContext) return;
  currentContext.appHideResumable = enabled === true;
}

export function closeMatchWebSocket(options = {}) {
  // 默认关闭时会发送 LEAVE；内部切换连接可通过 sendLeave:false 跳过。
  const { reason = "manual" } = options;
  // 可恢复训练切后台只断开传输层，不能把它上报成主动离场。
  const shouldSendLeave =
    options.sendLeave === undefined
      ? !(
          reason === "app-hide" &&
          currentContext?.appHideResumable === true
        )
      : options.sendLeave === true;

  manualClose = true;
  clearReconnectTimer();
  clearSocketHealthTimers();
  reconnectAttempt = 0;
  pendingAcks.forEach((task) => {
    if (task?.timer) clearTimeout(task.timer);
  });
  pendingAcks.length = 0;

  if (socket) {
    const currentSocket = socket;
    if (shouldSendLeave) sendLeave();
    socket = null;
    try {
      currentSocket.close({ reason });
    } catch (err) {
      console.log("[match-ws] close failed", err);
    }
  }

  isConnecting = false;
  removeAudioAckListener();
  console.log("[match-ws] close requested", { reason, currentContext });
  currentContext = null;
}

export default {
  ClientMessageType,
  ServerMessageType,
  connectMatchWebSocket,
  connectMatchWebSocketFromNotice,
  setMatchAppHideResumable,
  closeMatchWebSocket,
  forceReconnectMatchWebSocket,
  handleMatchNetworkStatusChange,
};
