#include "gst_webrtc_pipeline.h"
#include "ws_signaling.h"
#include "webrtcpush_log.h"
#include "webrtcpush_config.h"

#include <gst/sdp/sdp.h>
#include <gst/video/video-event.h>
#include <gst/webrtc/datachannel.h>
#include <gst/webrtc/webrtc.h>
#include <math.h>
#include <stdio.h>
#include <string.h>

/* linux/videodev2.h V4L2_CAP_*（与 sysfs device_caps 一致） */
#define WRTC_V4L2_CAP_VIDEO_CAPTURE         (1u << 0)
#define WRTC_V4L2_CAP_VIDEO_CAPTURE_MPLANE  (1u << 12)

/* 依次检查 /dev/video0、video1…，用 device_caps 判断是否为采集节点（含 MPLANE，适配 RK） */
static gchar *pick_v4l2_capture_device(void) {
  for (int i = 0; i < 32; i++) {
    gchar *path = g_strdup_printf("/dev/video%d", i);
    if (!g_file_test(path, G_FILE_TEST_EXISTS)) {
      g_free(path);
      continue;
    }

    gchar *sys_cap = g_strdup_printf("/sys/class/video4linux/video%d/device_caps", i);
    gboolean use = FALSE;

    if (g_file_test(sys_cap, G_FILE_TEST_EXISTS)) {
      gchar *contents = NULL;
      if (g_file_get_contents(sys_cap, &contents, NULL, NULL)) {
        g_strstrip(contents);
        guint64 caps = g_ascii_strtoull(contents, NULL, 0);
        use = (caps & WRTC_V4L2_CAP_VIDEO_CAPTURE) != 0 || (caps & WRTC_V4L2_CAP_VIDEO_CAPTURE_MPLANE) != 0;
        g_free(contents);
      }
    } else {
      /* 无 device_caps 的旧内核：存在即尝试 */
      use = TRUE;
    }

    g_free(sys_cap);

    if (use)
      return path;

    g_free(path);
  }
  return NULL;
}

static void start_bitrate_timer(AppState *app);
static void stop_bitrate_timer(void);
static void ensure_datachannel(GstElement *webrtc);
static void apply_video_bitrate_bps(guint bitrate_bps);
static void ramp_video_bitrate(void);
static guint mbps_to_target_bps(gdouble mbps);
static void reset_video_bitrate_state(void);
static void open_audio_valve(AppState *app);
static void hold_alsasrc_capture(GstElement *pipe);

static void configure_leaky_queue(GstElement *q) {
  if (!q)
    return;
  /* WebRTC 快进快出：不堆积，满则丢旧帧 */
  g_object_set(q,
      "max-size-buffers", 1,
      "max-size-bytes", 0,
      "max-size-time", (guint64)0,
      "leaky", 2, /* downstream */
      NULL);
}

static void configure_v4l2_low_latency(GstElement *src) {
  if (!src)
    return;
  g_object_set(src, "do-timestamp", TRUE, NULL);
}

static void configure_audio_queue(GstElement *q) {
  if (!q)
    return;
  /* 音频不丢样：小缓冲即可，避免 alsasrc “can't record fast enough” */
  g_object_set(q,
      "max-size-buffers", 0,
      "max-size-bytes", 0,
      "max-size-time", (guint64)(200 * GST_MSECOND),
      "leaky", 0,
      NULL);
}

static void configure_alsa_capture(GstElement *asrc) {
  if (!asrc)
    return;
  g_object_set(asrc,
      "buffer-time", (gint64)40000,
      "latency-time", (gint64)10000,
      "do-timestamp", TRUE,
      "provide-clock", FALSE,
      NULL);
}

static void configure_pulse_capture(GstElement *src) {
  if (!src)
    return;
  g_object_set(src, "provide-clock", FALSE, "do-timestamp", TRUE, NULL);
}

static gchar *alsa_device_to_pulse_name(const gchar *alsa_dev) {
  const gchar *p = alsa_dev;
  int card, dev;

  if (!alsa_dev || !alsa_dev[0])
    return NULL;
  if (!strncmp(p, "plughw:", 7))
    p += 7;
  else if (!strncmp(p, "hw:", 3))
    p += 3;
  else
    return NULL;
  if (sscanf(p, "%d,%d", &card, &dev) != 2)
    return NULL;
  return g_strdup_printf("alsa_input.hw_%d_%d", card, dev);
}

static GstElement *make_audio_capture_source(AppState *app, gboolean *hold_until_ice) {
  if (!app || !app->audio_alsa_device)
    return NULL;

  if (!g_strcmp0(app->audio_alsa_device, "test")) {
    GstElement *ts = gst_element_factory_make("audiotestsrc", "asrc");
    if (ts)
      g_object_set(ts, "is-live", TRUE, "wave", 0, "freq", 440, NULL);
    *hold_until_ice = FALSE;
    return ts;
  }

  /* 仅采集麦克风上行；整车 audio_init 的 Pulse 已注册 USB 麦 input，与之共用不抢喇叭 playback */
  gchar *pulse_dev = alsa_device_to_pulse_name(app->audio_alsa_device);
  if (pulse_dev) {
    GstElement *ps = gst_element_factory_make("pulsesrc", "asrc");
    if (ps) {
      /* 浏览器使用 Pulse 默认麦克风；固定 hw 名会命中重复加载后的旧/静音 source。 */
      configure_pulse_capture(ps);
      my_zlog_info("webrtcpush mic capture: Pulse default source (ALSA hint %s)", pulse_dev);
      g_free(pulse_dev);
      *hold_until_ice = TRUE;
      return ps;
    }
    my_zlog_warn("webrtcpush audio: pulsesrc missing, fallback alsasrc");
    g_free(pulse_dev);
  }

  GstElement *asrc = gst_element_factory_make("alsasrc", "asrc");
  if (asrc && app->audio_alsa_device[0])
    g_object_set(asrc, "device", app->audio_alsa_device, NULL);
  configure_alsa_capture(asrc);
  my_zlog_info("webrtcpush audio: alsasrc mic %s (uplink only)", app->audio_alsa_device);
  *hold_until_ice = TRUE;
  return asrc;
}

static GstElement *s_pay = NULL;
static GstElement *s_opus_pay = NULL;
static GstElement *s_queue = NULL;
static GstElement *s_aqueue = NULL;
static GstElement *s_audio_valve = NULL;

/* signal.js receiveCandidate(message) 把整个 JSON 交给 new RTCIceCandidate(message)，
 * 必须是扁平 RTCIceCandidateInit；且要先 setRemoteDescription(answer) 再 addIceCandidate */
static GQueue *s_ice_pending = NULL;
static GQueue *s_remote_ice_pending = NULL;
static gboolean s_sdp_answer_sent = FALSE;
static gboolean s_remote_desc_applied = FALSE;
static gchar *s_offer_mids[8];
static guint s_offer_mid_count = 0;
static GstElement *s_video_enc = NULL;
static GstWebRTCDataChannel *s_datachannel = NULL;
static GstWebRTCDataChannel *s_phone_datachannel = NULL;
static guint s_bitrate_timer_id = 0;
static guint s_offer_retry_id = 0;
static gdouble s_bw_estimate_mbps = 1.7;
static gdouble s_last_bitrate_mbps = 1.7;
static guint s_current_video_bps = 0;
static guint s_target_video_bps = 0;
static guint s_applied_video_bps = 0;
static guint64 s_prev_rtp_bytes = 0;
static gint64 s_prev_rtp_stats_us = 0;
static gboolean s_bw_stats_missing_logged = FALSE;
static guint64 s_prev_packets_sent = 0;
static guint64 s_prev_packets_lost = 0;
static guint s_network_good_ticks = 0;
static guint s_network_log_ticks = 0;
static gdouble s_loss_ema = 0.0;
static gdouble s_rtt_ema_ms = 0.0;
static gdouble s_jitter_ema_ms = 0.0;
static gboolean s_network_ema_ready = FALSE;
static gboolean s_ice_playing_started = FALSE;
static gboolean s_ice_media_ok = FALSE;
static gboolean s_create_offer_busy = FALSE;
static guint64 s_rtp_frames_sent = 0;
static guint64 s_enc_frames_out = 0;
static gulong s_rtp_probe_id = 0;
static gulong s_enc_probe_id = 0;
static gulong s_sprop_probe_id = 0;
static GstElement *s_h264_parse = NULL;
static gchar *s_sprop_parameter_sets = NULL;
static gboolean s_sprop_ready = FALSE;
static gint s_offer_h264_pt = 103;
static gint s_offer_h264_rtx_pt = -1;
static gint s_offer_opus_pt = 111;
static guint32 s_video_ssrc = 0;
static guint32 s_audio_ssrc = 0;
static guint64 s_audio_rtp_packets = 0;
static gulong s_audio_rtp_probe_id = 0;
static gboolean s_hold_alsasrc = FALSE;
static gboolean s_media_degraded = FALSE;
static gint64 s_answer_sent_at_us = 0;
static gint64 s_last_video_rtp_us = 0;
static gboolean s_renegotiating = FALSE;
static gboolean s_post_renegotiate_keyframe = FALSE;
static gboolean s_phone_left_pending = FALSE;
static gboolean s_driver_switch_pending = FALSE;
static guint s_leave_no_offer_timer_id = 0;
static guint s_room_alone_teardown_id = 0;
static gchar *s_last_remote_ice_ufrag = NULL;
static gchar *s_pending_offer_ice_ufrag = NULL;

#define WRTC_ANSWER_SPROP_WAIT_MS 350
#define WRTC_MEDIA_GRACE_AFTER_ANSWER_MS 30000
#define WRTC_RTP_ACTIVE_WINDOW_US (10 * G_USEC_PER_SEC)
#define WRTC_ROOM_ALONE_TEARDOWN_SEC 3
#define WRTC_LEAVE_NO_OFFER_SEC 12

static void mark_media_degraded(const char *reason) {
  if (!s_media_degraded && reason)
    my_zlog_warn("webrtcpush: media degraded (%s)", reason);
  s_media_degraded = TRUE;
}

/* SCTP/DataChannel 闪断不等于视频断流（安卓开始驾驶会重建 PC） */
static gboolean sctp_error_affects_media(void) {
  gint64 now = g_get_monotonic_time();
  if (!s_ice_media_ok)
    return TRUE;
  if (s_last_video_rtp_us > 0 && (now - s_last_video_rtp_us) < WRTC_RTP_ACTIVE_WINDOW_US)
    return FALSE;
  return TRUE;
}

static gboolean media_session_healthy(const AppState *app) {
  gint64 now = g_get_monotonic_time();
  if (!app || !app->pipeline || s_media_degraded)
    return FALSE;
  /* 近期有视频 RTP：仅用于判断重复 Offer 是否可忽略 */
  if (s_last_video_rtp_us > 0 && (now - s_last_video_rtp_us) < WRTC_RTP_ACTIVE_WINDOW_US)
    return TRUE;
  if (!s_sdp_answer_sent)
    return FALSE;
  if (s_answer_sent_at_us > 0 &&
      (now - s_answer_sent_at_us) < (WRTC_MEDIA_GRACE_AFTER_ANSWER_MS * (gint64)1000))
    return s_ice_media_ok || app->remote_description_set;
  return s_ice_media_ok && (s_rtp_frames_sent > 0 || s_enc_frames_out > 0);
}

static gchar *sdp_extract_ice_ufrag(const gchar *sdp) {
  const gchar *p;

  if (!sdp)
    return NULL;
  p = strstr(sdp, "a=ice-ufrag:");
  if (!p)
    return NULL;
  p += 12;
  const gchar *e = p;
  while (*e && *e != '\r' && *e != '\n')
    e++;
  if (e == p)
    return NULL;
  return g_strndup(p, (gsize)(e - p));
}

static void remember_remote_ice_ufrag(const gchar *sdp) {
  gchar *ufrag = sdp_extract_ice_ufrag(sdp);
  if (!ufrag)
    return;
  g_free(s_last_remote_ice_ufrag);
  s_last_remote_ice_ufrag = ufrag;
  g_free(s_pending_offer_ice_ufrag);
  s_pending_offer_ice_ufrag = NULL;
}

static gboolean remote_ice_ufrag_changed(const gchar *sdp) {
  gchar *ufrag = sdp_extract_ice_ufrag(sdp);
  gboolean changed = TRUE;

  if (!ufrag)
    changed = FALSE;
  else if (s_last_remote_ice_ufrag && !strcmp(ufrag, s_last_remote_ice_ufrag))
    changed = FALSE;
  g_free(ufrag);
  return changed;
}

void gst_webrtc_pipeline_debug_log_state(const AppState *app, const char *tag) {
#if WEBRTCPUSH_DEBUG
  const char *ufrag = s_last_remote_ice_ufrag ? s_last_remote_ice_ufrag : "-";
  my_zlog_info(
      "WRTC[%s]: pipe=%d linked=%d/%d remote=%d answer=%d ice_ok=%d degraded=%d "
      "rtp=%" G_GUINT64_FORMAT " enc=%" G_GUINT64_FORMAT " audio_rtp=%" G_GUINT64_FORMAT " ufrag=%s",
      tag ? tag : "?",
      app && app->pipeline ? 1 : 0,
      app && app->payloader_linked ? 1 : 0,
      app && app->audio_linked ? 1 : 0,
      app && app->remote_description_set ? 1 : 0,
      s_sdp_answer_sent ? 1 : 0,
      s_ice_media_ok ? 1 : 0,
      s_media_degraded ? 1 : 0,
      s_rtp_frames_sent,
      s_enc_frames_out,
      s_audio_rtp_packets,
      ufrag);
#else
  (void)app;
  (void)tag;
#endif
}

/* client.js ontrack 依赖 event.streams[0]；msid 须合法且与 a=ssrc msid 一致 */
#define WRTC_MSID_STREAM_ID "orangepi-camera"
#define WRTC_MSID_TRACK_ID  "video_track0"
#define WRTC_MSID_AUDIO_TRACK_ID "audio_track0"

static GstPadProbeReturn rtp_buffer_probe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data) {
  (void)pad;
  (void)user_data;
  if (GST_PAD_PROBE_INFO_TYPE(info) & GST_PAD_PROBE_TYPE_BUFFER) {
    s_rtp_frames_sent++;
    s_last_video_rtp_us = g_get_monotonic_time();
    if (s_rtp_frames_sent == 1)
      my_zlog_info("RTP H.264 started");
    else if ((s_rtp_frames_sent % 120) == 0)
      my_zlog_debug("RTP H.264 packets sent: %" G_GUINT64_FORMAT, s_rtp_frames_sent);
  }
  return GST_PAD_PROBE_OK;
}

static void attach_rtp_probe(void) {
  if (s_rtp_probe_id || !s_pay)
    return;
  GstPad *src = gst_element_get_static_pad(s_pay, "src");
  if (!src)
    return;
  s_rtp_frames_sent = 0;
  s_rtp_probe_id = gst_pad_add_probe(src, GST_PAD_PROBE_TYPE_BUFFER, rtp_buffer_probe, NULL, NULL);
  gst_object_unref(src);
}

static void detach_rtp_probe(void) {
  if (!s_rtp_probe_id || !s_pay)
    return;
  GstPad *src = gst_element_get_static_pad(s_pay, "src");
  if (src) {
    gst_pad_remove_probe(src, s_rtp_probe_id);
    gst_object_unref(src);
  }
  s_rtp_probe_id = 0;
  s_rtp_frames_sent = 0;
}

static GstPadProbeReturn audio_rtp_buffer_probe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data) {
  (void)pad;
  (void)user_data;
  if (GST_PAD_PROBE_INFO_TYPE(info) & GST_PAD_PROBE_TYPE_BUFFER) {
    s_audio_rtp_packets++;
    if (s_audio_rtp_packets == 1)
      my_zlog_info("webrtcpush audio RTP started (ssrc=%u)", s_audio_ssrc);
    else if ((s_audio_rtp_packets % 500) == 0)
      my_zlog_debug("RTP Opus packets sent: %" G_GUINT64_FORMAT, s_audio_rtp_packets);
  }
  return GST_PAD_PROBE_OK;
}

static void attach_audio_rtp_probe(void) {
  if (s_audio_rtp_probe_id || !s_opus_pay)
    return;
  GstPad *src = gst_element_get_static_pad(s_opus_pay, "src");
  if (!src)
    return;
  s_audio_rtp_packets = 0;
  s_audio_rtp_probe_id =
      gst_pad_add_probe(src, GST_PAD_PROBE_TYPE_BUFFER, audio_rtp_buffer_probe, NULL, NULL);
  gst_object_unref(src);
}

static void detach_audio_rtp_probe(void) {
  if (!s_audio_rtp_probe_id || !s_opus_pay)
    return;
  GstPad *src = gst_element_get_static_pad(s_opus_pay, "src");
  if (src) {
    gst_pad_remove_probe(src, s_audio_rtp_probe_id);
    gst_object_unref(src);
  }
  s_audio_rtp_probe_id = 0;
  s_audio_rtp_packets = 0;
}

static void hold_alsasrc_capture(GstElement *pipe) {
  if (!pipe || !s_hold_alsasrc)
    return;
  GstElement *asrc = gst_bin_get_by_name(GST_BIN(pipe), "asrc");
  if (!asrc)
    return;
  gst_element_set_state(asrc, GST_STATE_NULL);
  gst_element_set_locked_state(asrc, TRUE);
  gst_object_unref(asrc);
}

static GstPadProbeReturn enc_buffer_probe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data) {
  (void)pad;
  (void)user_data;
  if (GST_PAD_PROBE_INFO_TYPE(info) & GST_PAD_PROBE_TYPE_BUFFER) {
    s_enc_frames_out++;
    if (s_enc_frames_out == 1)
      my_zlog_info("H.264 encoder started");
    else if ((s_enc_frames_out % 120) == 0)
      my_zlog_debug("H.264 encoder frames: %" G_GUINT64_FORMAT, s_enc_frames_out);
  }
  return GST_PAD_PROBE_OK;
}

static void attach_enc_probe(void) {
  if (s_enc_probe_id || !s_video_enc)
    return;
  GstPad *src = gst_element_get_static_pad(s_video_enc, "src");
  if (!src)
    return;
  s_enc_frames_out = 0;
  s_enc_probe_id = gst_pad_add_probe(src, GST_PAD_PROBE_TYPE_BUFFER, enc_buffer_probe, NULL, NULL);
  gst_object_unref(src);
}

static void detach_enc_probe(void) {
  if (!s_enc_probe_id || !s_video_enc)
    return;
  GstPad *src = gst_element_get_static_pad(s_video_enc, "src");
  if (src) {
    gst_pad_remove_probe(src, s_enc_probe_id);
    gst_object_unref(src);
  }
  s_enc_probe_id = 0;
  s_enc_frames_out = 0;
}

static void log_webrtc_video_sink_caps(AppState *app) {
  if (!app || !app->webrtc || !s_queue)
    return;
  GstPad *peer = gst_element_get_static_pad(s_queue, "src");
  if (!peer)
    return;
  GstPad *sink = gst_pad_get_peer(peer);
  gst_object_unref(peer);
  if (!sink)
    return;
  GstCaps *caps = gst_pad_get_current_caps(sink);
  if (caps) {
    gchar *s = gst_caps_to_string(caps);
    g_print("webrtcbin video sink caps: %s\n", s);
    GstStructure *st = gst_caps_get_structure(caps, 0);
    gint payload = 0;
    if (gst_structure_get_int(st, "payload", &payload) && payload > 0 &&
        s_offer_h264_pt > 0 && payload != s_offer_h264_pt) {
      g_printerr("warning: RTP payload %d != SDP/Offer pt %d — phone may black-screen\n",
          payload, s_offer_h264_pt);
    }
    g_free(s);
    gst_caps_unref(caps);
  }
  gst_object_unref(sink);
}

static void log_sdp_video_summary(const gchar *label, const gchar *sdp_text) {
  if (!sdp_text)
    return;
  const gchar *video = g_strstr_len(sdp_text, -1, "m=video ");
  if (!video) {
    g_print("%s: no video m-section\n", label);
    return;
  }
  const gchar *line_end = strchr(video, '\n');
  if (!line_end)
    line_end = video + strlen(video);
  gchar *mline = g_strndup(video, (gsize)(line_end - video));
  g_strchomp(mline);

  const gchar *next_m = g_strstr_len(video, -1, "\nm=");
  gsize len = next_m ? (gsize)(next_m - video) : strlen(video);
  gchar *section = g_strndup(video, len);

  const gchar *dir = "unknown";
  if (g_strstr_len(section, -1, "a=sendonly"))
    dir = "sendonly";
  else if (g_strstr_len(section, -1, "a=recvonly"))
    dir = "recvonly";
  else if (g_strstr_len(section, -1, "a=sendrecv"))
    dir = "sendrecv";
  else if (g_strstr_len(section, -1, "a=inactive"))
    dir = "inactive";

  const gchar *fmtp = g_strstr_len(section, -1, "a=fmtp:");
  if (fmtp) {
    const gchar *eol = strchr(fmtp, '\n');
    gchar *fline = eol ? g_strndup(fmtp, (gsize)(eol - fmtp)) : g_strdup(fmtp);
    g_strchomp(fline);
    g_print("%s: %s | dir=%s | %s\n", label, mline, dir, fline);
    g_free(fline);
  } else if (g_strstr_len(section, -1, "H264/90000") ||
             g_strstr_len(section, -1, "h264/90000")) {
    g_print("%s: %s | dir=%s | H264 present\n", label, mline, dir);
  } else {
    g_print("%s: %s | dir=%s | WARNING: no H264 rtpmap/fmtp\n", label, mline, dir);
  }
  g_free(mline);
  g_free(section);
}

/* 只在 video m-section 内判断，避免误判其它行 */
static gboolean sdp_video_section_has_h264(const gchar *sdp_text) {
  if (!sdp_text)
    return FALSE;

  const gchar *video = g_strstr_len(sdp_text, -1, "\nm=video ");
  if (!video) {
    if (g_str_has_prefix(sdp_text, "m=video "))
      video = sdp_text;
    else
      return FALSE;
  } else {
    video += 1; /* skip leading '\n' */
  }

  const gchar *next_m = g_strstr_len(video, -1, "\nm=");
  gsize section_len = next_m ? (gsize)(next_m - video) : strlen(video);

  return g_strstr_len(video, (gssize)section_len, "H264/90000") != NULL ||
      g_strstr_len(video, (gssize)section_len, "h264/90000") != NULL ||
      g_strstr_len(video, (gssize)section_len, "H264") != NULL ||
      g_strstr_len(video, (gssize)section_len, "h264") != NULL ||
      g_strstr_len(video, (gssize)section_len, " RTP/SAVPF 103") != NULL ||
      g_strstr_len(video, (gssize)section_len, "a=fmtp:103") != NULL ||
      g_strstr_len(video, (gssize)section_len, "a=rtpmap:103") != NULL ||
      g_strstr_len(video, (gssize)section_len, "profile-level-id=") != NULL;
}

/* mpph264enc level=3.1 实际 SPS 为 42c01f（webrtcbin caps 可确认） */
#define WRTC_H264_PROFILE_LEVEL_ID "42c01f"
#define WRTC_VIDEO_WIDTH  1280
#define WRTC_VIDEO_HEIGHT 720
#define WRTC_VIDEO_FPS 24
#define WRTC_VIDEO_GOP (WRTC_VIDEO_FPS * 2)
#define WRTC_VIDEO_MIN_BITRATE_BPS 500000u
#define WRTC_VIDEO_START_BITRATE_BPS 1400000u
#define WRTC_VIDEO_DEFAULT_BITRATE_BPS 2200000u
/* 浏览器上限为 3.75 Mbps；原生端收紧到 3.2 Mbps，兼顾 720p 运动画质和排队延迟。 */
#define WRTC_VIDEO_MAX_BITRATE_BPS 3200000u
/* 链路容量先扣除 18% 的音频、RTP/RTCP、重传余量，再得到视频目标码率。 */
#define WRTC_LINK_CAP_MBPS 4.00
#define WRTC_BW_UTILIZATION 0.82
#define WRTC_BW_EMA_ALPHA_UP 0.32
#define WRTC_BW_EMA_ALPHA_DOWN 0.14
#define WRTC_BITRATE_TICK_MS 1000

static gchar *base64_nal_unit(const guint8 *nal, gsize len) {
  return len > 0 ? g_base64_encode(nal, (gsize)len) : NULL;
}

static gboolean extract_sprop_from_au(const guint8 *data, gsize size) {
  if (!data || size < 5 || s_sprop_ready)
    return FALSE;

  gchar *sps_b64 = NULL;
  gchar *pps_b64 = NULL;

  for (gsize i = 0; i + 4 < size;) {
    gsize nal_start = 0;
    if (data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1) {
      nal_start = i + 3;
      i += 3;
    } else if (data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 0 && data[i + 3] == 1) {
      nal_start = i + 4;
      i += 4;
    } else {
      i++;
      continue;
    }

    guint8 nal_type = data[nal_start] & 0x1f;
    gsize j = nal_start + 1;
    while (j + 3 < size) {
      if (data[j] == 0 && data[j + 1] == 0 && (data[j + 2] == 1 || (data[j + 2] == 0 && data[j + 3] == 1)))
        break;
      j++;
    }
    if (j + 3 >= size)
      j = size;

    gsize nal_len = j - nal_start;
    if (nal_type == 7 && !sps_b64)
      sps_b64 = base64_nal_unit(data + nal_start, nal_len);
    else if (nal_type == 8 && !pps_b64)
      pps_b64 = base64_nal_unit(data + nal_start, nal_len);

    if (sps_b64 && pps_b64)
      break;
    i = j;
  }

  if (sps_b64 && pps_b64) {
    g_free(s_sprop_parameter_sets);
    s_sprop_parameter_sets = g_strdup_printf("%s,%s", sps_b64, pps_b64);
    s_sprop_ready = TRUE;
    g_print("H.264 sprop captured for SDP (%zu+%zu bytes b64)\n", strlen(sps_b64), strlen(pps_b64));
  }
  g_free(sps_b64);
  g_free(pps_b64);
  return s_sprop_ready;
}

static GstPadProbeReturn h264_sprop_probe(GstPad *pad, GstPadProbeInfo *info, gpointer user_data) {
  (void)pad;
  (void)user_data;
  if (s_sprop_ready)
    return GST_PAD_PROBE_REMOVE;

  if (GST_PAD_PROBE_INFO_TYPE(info) & GST_PAD_PROBE_TYPE_BUFFER) {
    GstBuffer *buf = gst_pad_probe_info_get_buffer(info);
    if (!buf)
      return GST_PAD_PROBE_OK;
    GstMapInfo map;
    if (gst_buffer_map(buf, &map, GST_MAP_READ)) {
      if (extract_sprop_from_au(map.data, map.size))
        return GST_PAD_PROBE_REMOVE;
      gst_buffer_unmap(buf, &map);
    }
  }
  return GST_PAD_PROBE_OK;
}

static void attach_sprop_probe(void) {
  if (s_sprop_probe_id || !s_h264_parse)
    return;
  GstPad *src = gst_element_get_static_pad(s_h264_parse, "src");
  if (!src)
    return;
  s_sprop_probe_id = gst_pad_add_probe(src, GST_PAD_PROBE_TYPE_BUFFER, h264_sprop_probe, NULL, NULL);
  gst_object_unref(src);
}

static void detach_sprop_probe(void) {
  if (!s_sprop_probe_id || !s_h264_parse)
    return;
  GstPad *src = gst_element_get_static_pad(s_h264_parse, "src");
  if (src) {
    gst_pad_remove_probe(src, s_sprop_probe_id);
    gst_object_unref(src);
  }
  s_sprop_probe_id = 0;
}

static gchar *str_replace_once(const gchar *haystack, const gchar *needle, const gchar *replacement) {
  const gchar *pos = strstr(haystack, needle);
  if (!pos)
    return g_strdup(haystack);
  gsize prefix_len = (gsize)(pos - haystack);
  gsize needle_len = strlen(needle);
  gsize repl_len = strlen(replacement);
  gsize suffix_len = strlen(pos + needle_len);
  gchar *out = g_malloc(prefix_len + repl_len + suffix_len + 1);
  memcpy(out, haystack, prefix_len);
  memcpy(out + prefix_len, replacement, repl_len);
  memcpy(out + prefix_len + repl_len, pos + needle_len, suffix_len + 1);
  return out;
}

static gchar *sdp_patch_video_msid(const gchar *sdp_text) {
  if (!sdp_text || strstr(sdp_text, "a=msid:"))
    return g_strdup(sdp_text);

  const gchar *vsec = strstr(sdp_text, "m=video ");
  if (!vsec)
    return g_strdup(sdp_text);
  const gchar *mid = strstr(vsec, "a=mid:");
  if (!mid)
    return g_strdup(sdp_text);
  const gchar *eol = strchr(mid, '\n');
  if (!eol)
    return g_strdup(sdp_text);

  GString *out = g_string_new_len(sdp_text, (gsize)(eol + 1 - sdp_text));
  g_string_append(out, "a=msid:" WRTC_MSID_STREAM_ID " " WRTC_MSID_TRACK_ID "\r\n");
  g_string_append(out, eol + 1);
  g_print("SDP patched: a=msid (mobile track binding)\n");
  return g_string_free(out, FALSE);
}

static gchar *sanitize_sprop_for_sdp(const gchar *raw) {
  if (!raw || !raw[0])
    return NULL;
  GString *s = g_string_sized_new(strlen(raw));
  for (const gchar *p = raw; *p; p++) {
    if (*p == '"')
      continue;
    if (*p == '\\' && (p[1] == '=' || p[1] == ',')) {
      g_string_append_c(s, p[1]);
      p++;
      continue;
    }
    g_string_append_c(s, *p);
  }
  return g_string_free(s, FALSE);
}

static gint sdp_find_h264_payload_type(const gchar *sdp_text) {
  const gchar *p = sdp_text;
  while (p && *p) {
    const gchar *rm = strstr(p, "a=rtpmap:");
    if (!rm)
      break;
    gint pt = 0;
    char codec[32] = {0};
    if (sscanf(rm, "a=rtpmap:%d %31[^/\r\n]", &pt, codec) >= 2 &&
        g_ascii_strcasecmp(codec, "H264") == 0)
      return pt;
    p = rm + 9;
  }
  return 103;
}

static gint sdp_find_rtx_payload_type(const gchar *sdp_text, gint apt) {
  if (!sdp_text || apt <= 0)
    return -1;
  const gchar *video = strstr(sdp_text, "m=video ");
  if (!video)
    return -1;
  const gchar *end = strstr(video + 8, "\r\nm=");
  if (!end)
    end = strstr(video + 8, "\nm=");
  if (!end)
    end = sdp_text + strlen(sdp_text);

  for (const gchar *p = video; p < end;) {
    const gchar *fmtp = g_strstr_len(p, (gssize)(end - p), "a=fmtp:");
    if (!fmtp || fmtp >= end)
      break;
    gint pt = -1;
    gint mapped = -1;
    if (sscanf(fmtp, "a=fmtp:%d apt=%d", &pt, &mapped) == 2 && mapped == apt)
      return pt;
    p = fmtp + 7;
  }
  return -1;
}

static gchar *sdp_get_media_attr(const gchar *sec, const gchar *sec_end, const gchar *name) {
  gchar *needle = g_strdup_printf("a=%s:", name);
  const gchar *p = sec;
  while (p < sec_end) {
    const gchar *hit = g_strstr_len(p, (gssize)(sec_end - p), needle);
    if (!hit || hit >= sec_end)
      break;
    const gchar *val = hit + strlen(needle);
    const gchar *eol = val;
    while (eol < sec_end && *eol != '\n' && *eol != '\r')
      eol++;
    gchar *out = g_strndup(val, (gsize)(eol - val));
    g_free(needle);
    return out;
  }
  g_free(needle);
  return NULL;
}

static gint refresh_h264_pt_from_webrtc_caps(AppState *app) {
  (void)app;
  return s_offer_h264_pt > 0 ? s_offer_h264_pt : 103;
}

static guint32 alloc_rtp_ssrc(guint32 *slot) {
  if (slot && *slot > 0)
    return *slot;
  guint32 ssrc;
  do {
    ssrc = (guint32)(((guint32)g_random_int()) | 1u);
  } while (ssrc == 0);
  if (slot)
    *slot = ssrc;
  return ssrc;
}

static void apply_h264_payloader_pt(void) {
  if (!s_pay || s_offer_h264_pt <= 0)
    return;
  g_object_set(s_pay, "pt", (guint)s_offer_h264_pt, NULL);
  g_print("rtph264pay pt=%d (match Offer H264)\n", s_offer_h264_pt);
}

static void apply_opus_payloader_pt(void) {
  if (!s_opus_pay || s_offer_opus_pt <= 0)
    return;
  guint32 ssrc = alloc_rtp_ssrc(&s_audio_ssrc);
  g_object_set(s_opus_pay, "pt", (guint)s_offer_opus_pt, "ssrc", ssrc, NULL);
  g_print("rtpopuspay pt=%d ssrc=%u\n", s_offer_opus_pt, ssrc);
}

static gint sdp_find_opus_payload_type(const gchar *sdp_text) {
  if (!sdp_text)
    return 111;

  const gchar *audio = strstr(sdp_text, "m=audio ");
  if (!audio)
    return 111;
  const gchar *end = strstr(audio + 8, "\r\nm=");
  if (!end)
    end = strstr(audio + 8, "\nm=");
  if (!end)
    end = sdp_text + strlen(sdp_text);

  for (const gchar *p = audio; p < end;) {
    const gchar *rtp = g_strstr_len(p, (gssize)(end - p), "a=rtpmap:");
    if (!rtp || rtp >= end)
      break;
    if (g_strstr_len(rtp, (gssize)(end - rtp), "opus/48000") ||
        g_strstr_len(rtp, (gssize)(end - rtp), "OPUS/48000")) {
      gint pt = atoi(rtp + 9);
      return pt > 0 ? pt : 111;
    }
    p = rtp + 9;
  }
  return 111;
}

static guint32 sdp_get_section_ssrc(const gchar *start, const gchar *end) {
  if (!start)
    return 0;
  for (const gchar *p = start; p < end;) {
    guint32 ssrc = 0;
    if (sscanf(p, "a=ssrc:%u", &ssrc) == 1 && ssrc > 0)
      return ssrc;
    const gchar *nl = strchr(p, '\n');
    if (!nl || nl >= end)
      break;
    p = nl + 1;
  }
  return 0;
}

static void refresh_video_ssrc_from_webrtc_caps(AppState *app) {
  if (!app || !s_queue)
    return;
  GstPad *peer = gst_element_get_static_pad(s_queue, "src");
  if (!peer)
    return;
  GstPad *sink = gst_pad_get_peer(peer);
  gst_object_unref(peer);
  if (!sink)
    return;
  GstCaps *caps = gst_pad_get_current_caps(sink);
  gst_object_unref(sink);
  if (!caps)
    return;
  GstStructure *st = gst_caps_get_structure(caps, 0);
  guint ssrc = 0;
  if (gst_structure_get_uint(st, "ssrc", &ssrc) && ssrc > 0)
    s_video_ssrc = ssrc;
  gst_caps_unref(caps);
}

static void refresh_audio_ssrc_from_webrtc_caps(AppState *app) {
  if (!app || !s_aqueue)
    return;
  GstPad *peer = gst_element_get_static_pad(s_aqueue, "src");
  if (!peer)
    return;
  GstPad *sink = gst_pad_get_peer(peer);
  gst_object_unref(peer);
  if (!sink)
    return;
  GstCaps *caps = gst_pad_get_current_caps(sink);
  gst_object_unref(sink);
  if (!caps)
    return;
  GstStructure *st = gst_caps_get_structure(caps, 0);
  guint ssrc = 0;
  if (gst_structure_get_uint(st, "ssrc", &ssrc) && ssrc > 0)
    s_audio_ssrc = ssrc;
  gst_caps_unref(caps);
}

static gchar *sdp_ensure_msid_semantic(const gchar *sdp) {
  if (!sdp || strstr(sdp, "a=msid-semantic:"))
    return g_strdup(sdp);
  const gchar *m = strstr(sdp, "m=video ");
  if (!m)
    return g_strdup(sdp);
  GString *out = g_string_new_len(sdp, (gsize)(m - sdp));
  g_string_append_printf(out, "a=msid-semantic: WMS %s\r\n", WRTC_MSID_STREAM_ID);
  g_string_append(out, m);
  return g_string_free(out, FALSE);
}

static gchar *sdp_rewrite_video_h264_sendonly(const gchar *sdp, gint pt, const gchar *profile_id,
    const gchar *sprop) {
  if (!sdp || pt <= 0 || !profile_id)
    return g_strdup(sdp);

  const gchar *vstart = strstr(sdp, "m=video ");
  if (!vstart)
    return g_strdup(sdp);
  const gchar *vend = strstr(vstart, "\r\nm=");
  if (!vend)
    vend = strstr(vstart, "\nm=");
  if (!vend)
    vend = sdp + strlen(sdp);

  gchar *ufrag = sdp_get_media_attr(vstart, vend, "ice-ufrag");
  gchar *pwd = sdp_get_media_attr(vstart, vend, "ice-pwd");
  gchar *mid = sdp_get_media_attr(vstart, vend, "mid");
  gchar *setup = sdp_get_media_attr(vstart, vend, "setup");
  gchar *fp = sdp_get_media_attr(vstart, vend, "fingerprint");
  guint32 ssrc = sdp_get_section_ssrc(vstart, vend);
  if (!ssrc)
    ssrc = s_video_ssrc;

  GString *vsec = g_string_new(NULL);
  if (s_offer_h264_rtx_pt > 0)
    g_string_append_printf(vsec, "m=video 9 UDP/TLS/RTP/SAVPF %d %d\r\n", pt,
        s_offer_h264_rtx_pt);
  else
    g_string_append_printf(vsec, "m=video 9 UDP/TLS/RTP/SAVPF %d\r\n", pt);
  g_string_append(vsec, "c=IN IP4 0.0.0.0\r\n");
  if (ufrag)
    g_string_append_printf(vsec, "a=ice-ufrag:%s\r\n", ufrag);
  if (pwd)
    g_string_append_printf(vsec, "a=ice-pwd:%s\r\n", pwd);
  if (mid)
    g_string_append_printf(vsec, "a=mid:%s\r\n", mid);
  g_string_append_printf(vsec, "a=msid:%s %s\r\n", WRTC_MSID_STREAM_ID, WRTC_MSID_TRACK_ID);
  g_string_append(vsec, "a=rtcp-mux\r\n");
  g_string_append(vsec, "a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\r\n");
  g_string_append_printf(vsec, "a=setup:%s\r\n", setup ? setup : "active");
  g_string_append_printf(vsec, "a=rtpmap:%d H264/90000\r\n", pt);
  g_string_append_printf(vsec, "a=rtcp-fb:%d nack pli\r\n", pt);
  g_string_append_printf(vsec, "a=rtcp-fb:%d ccm fir\r\n", pt);
  g_string_append_printf(vsec, "a=rtcp-fb:%d transport-cc\r\n", pt);
  if (s_offer_h264_rtx_pt > 0) {
    g_string_append_printf(vsec, "a=rtpmap:%d rtx/90000\r\n", s_offer_h264_rtx_pt);
    g_string_append_printf(vsec, "a=fmtp:%d apt=%d\r\n", s_offer_h264_rtx_pt, pt);
  }
  if (sprop && sprop[0]) {
    g_string_append_printf(vsec,
        "a=fmtp:%d packetization-mode=1;profile-level-id=%s;level-asymmetry-allowed=1;"
        "sprop-parameter-sets=%s\r\n",
        pt, profile_id, sprop);
  } else {
    g_string_append_printf(vsec,
        "a=fmtp:%d packetization-mode=1;profile-level-id=%s;level-asymmetry-allowed=1\r\n",
        pt, profile_id);
  }
  g_string_append(vsec, "a=sendonly\r\n");
  if (ssrc > 0) {
    g_string_append_printf(vsec, "a=ssrc:%u cname:orangepi@webrtc\r\n", ssrc);
    g_string_append_printf(vsec, "a=ssrc:%u msid:%s %s\r\n", ssrc, WRTC_MSID_STREAM_ID,
        WRTC_MSID_TRACK_ID);
  }
  if (fp)
    g_string_append_printf(vsec, "a=fingerprint:%s\r\n", fp);

  GString *out = g_string_new_len(sdp, (gsize)(vstart - sdp));
  g_string_append(out, vsec->str);
  /* vend 含 \r\nm=audio；vsec 已以 \r\n 结尾，勿再拼出空行（浏览器 Invalid SDP line） */
  const gchar *rest = vend;
  while (*rest == '\r' || *rest == '\n')
    rest++;
  g_string_append(out, rest);

  g_free(ufrag);
  g_free(pwd);
  g_free(mid);
  g_free(setup);
  g_free(fp);
  g_string_free(vsec, TRUE);
  g_print("SDP rewritten: H264 pt=%d sendonly msid=%s/%s ssrc=%u%s\n", pt, WRTC_MSID_STREAM_ID,
      WRTC_MSID_TRACK_ID, ssrc, sprop ? " + sprop" : "");
  return g_string_free(out, FALSE);
}

static gchar *sdp_rewrite_audio_opus_sendonly(const gchar *sdp, gint pt) {
  if (!sdp || pt <= 0)
    return g_strdup(sdp);

  const gchar *astart = strstr(sdp, "m=audio ");
  if (!astart) {
    g_printerr("SDP rewrite: no m=audio section\n");
    return g_strdup(sdp);
  }
  const gchar *aend = strstr(astart + 8, "\r\nm=");
  if (!aend)
    aend = strstr(astart + 8, "\nm=");
  if (!aend)
    aend = sdp + strlen(sdp);

  const gchar *vstart = strstr(sdp, "m=video ");
  const gchar *vend = NULL;
  if (vstart) {
    vend = strstr(vstart, "\r\nm=");
    if (!vend)
      vend = strstr(vstart, "\nm=");
    if (!vend)
      vend = sdp + strlen(sdp);
  }

  gchar *ufrag = NULL;
  gchar *pwd = NULL;
  gchar *mid = NULL;
  gchar *setup = NULL;
  gchar *fp = NULL;
  if (vstart && vend) {
    ufrag = sdp_get_media_attr(vstart, vend, "ice-ufrag");
    pwd = sdp_get_media_attr(vstart, vend, "ice-pwd");
    setup = sdp_get_media_attr(vstart, vend, "setup");
    fp = sdp_get_media_attr(vstart, vend, "fingerprint");
  }
  mid = sdp_get_media_attr(astart, aend, "mid");
  guint32 ssrc = sdp_get_section_ssrc(astart, aend);
  if (!ssrc)
    ssrc = s_audio_ssrc;
  if (!ssrc)
    ssrc = alloc_rtp_ssrc(&s_audio_ssrc);

  GString *asec = g_string_new(NULL);
  g_string_append_printf(asec, "m=audio 9 UDP/TLS/RTP/SAVPF %d\r\n", pt);
  g_string_append(asec, "c=IN IP4 0.0.0.0\r\n");
  if (ufrag)
    g_string_append_printf(asec, "a=ice-ufrag:%s\r\n", ufrag);
  if (pwd)
    g_string_append_printf(asec, "a=ice-pwd:%s\r\n", pwd);
  if (mid)
    g_string_append_printf(asec, "a=mid:%s\r\n", mid);
  g_string_append_printf(asec, "a=msid:%s %s\r\n", WRTC_MSID_STREAM_ID, WRTC_MSID_AUDIO_TRACK_ID);
  g_string_append(asec, "a=rtcp-mux\r\n");
  g_string_append(asec, "a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\r\n");
  g_string_append_printf(asec, "a=setup:%s\r\n", setup ? setup : "active");
  g_string_append_printf(asec, "a=rtpmap:%d opus/48000/2\r\n", pt);
  g_string_append_printf(asec, "a=rtcp-fb:%d transport-cc\r\n", pt);
  g_string_append_printf(asec, "a=fmtp:%d minptime=10;useinbandfec=1\r\n", pt);
  g_string_append(asec, "a=sendonly\r\n");
  g_string_append_printf(asec, "a=ssrc:%u cname:orangepi@webrtc\r\n", ssrc);
  g_string_append_printf(asec, "a=ssrc:%u msid:%s %s\r\n", ssrc, WRTC_MSID_STREAM_ID,
      WRTC_MSID_AUDIO_TRACK_ID);
  if (fp)
    g_string_append_printf(asec, "a=fingerprint:%s\r\n", fp);

  GString *out = g_string_new_len(sdp, (gsize)(astart - sdp));
  g_string_append(out, asec->str);
  const gchar *rest = aend;
  while (*rest == '\r' || *rest == '\n')
    rest++;
  g_string_append(out, rest);

  g_free(ufrag);
  g_free(pwd);
  g_free(mid);
  g_free(setup);
  g_free(fp);
  g_string_free(asec, TRUE);
  g_print("SDP rewritten: Opus pt=%d sendonly msid=%s/%s ssrc=%u\n", pt, WRTC_MSID_STREAM_ID,
      WRTC_MSID_AUDIO_TRACK_ID, ssrc);
  return g_string_free(out, FALSE);
}

static void refresh_sprop_from_webrtc_caps(AppState *app) {
  if (!app || !s_queue)
    return;
  GstPad *peer = gst_element_get_static_pad(s_queue, "src");
  if (!peer)
    return;
  GstPad *sink = gst_pad_get_peer(peer);
  gst_object_unref(peer);
  if (!sink)
    return;
  GstCaps *caps = gst_pad_get_current_caps(sink);
  gst_object_unref(sink);
  if (!caps)
    return;
  GstStructure *st = gst_caps_get_structure(caps, 0);
  const gchar *sprop = gst_structure_get_string(st, "sprop-parameter-sets");
  if (sprop && sprop[0]) {
    gchar *clean = sanitize_sprop_for_sdp(sprop);
    g_free(s_sprop_parameter_sets);
    s_sprop_parameter_sets = clean ? clean : g_strdup(sprop);
    s_sprop_ready = TRUE;
    g_print("H.264 sprop from webrtcbin caps\n");
  }
  gst_caps_unref(caps);
}

static gchar *sdp_patch_audio_msid(const gchar *sdp) {
  if (!sdp || !strstr(sdp, "m=audio "))
    return g_strdup(sdp);

  if (strstr(sdp, "a=msid:" WRTC_MSID_STREAM_ID " " WRTC_MSID_AUDIO_TRACK_ID))
    return g_strdup(sdp);

  const gchar *p = strstr(sdp, "m=audio ");
  const gchar *mid = p ? strstr(p, "a=mid:") : NULL;
  if (!mid)
    return g_strdup(sdp);

  const gchar *eol = strstr(mid, "\r\n");
  if (!eol)
    eol = strchr(mid, '\n');
  if (!eol)
    return g_strdup(sdp);

  GString *out = g_string_new(NULL);
  g_string_append_len(out, sdp, (gsize)(eol - sdp));
  g_string_append_printf(out, "\r\na=msid:%s %s", WRTC_MSID_STREAM_ID, WRTC_MSID_AUDIO_TRACK_ID);
  g_string_append(out, eol);
  g_print("SDP patched: audio msid=%s/%s (same WMS as video for client.html)\n",
      WRTC_MSID_STREAM_ID, WRTC_MSID_AUDIO_TRACK_ID);
  return g_string_free(out, FALSE);
}

static void log_sdp_audio_summary(const gchar *label, const gchar *sdp_text) {
  if (!sdp_text)
    return;
  const gchar *audio = g_strstr_len(sdp_text, -1, "m=audio ");
  if (!audio) {
    my_zlog_warn("%s: no m=audio (phone will have no sound)", label);
    return;
  }
  const gchar *next_m = g_strstr_len(audio, -1, "\nm=");
  gsize len = next_m ? (gsize)(next_m - audio) : strlen(audio);
  gboolean sendonly = g_strstr_len(audio, (gssize)len, "a=sendonly") != NULL;
  gboolean has_msid = g_strstr_len(audio, (gssize)len, "a=msid:") != NULL;
  gboolean has_opus = g_strstr_len(audio, (gssize)len, "opus/48000") != NULL ||
      g_strstr_len(audio, (gssize)len, "OPUS/48000") != NULL;
  gboolean has_ssrc_msid = g_strstr_len(audio, (gssize)len, "a=ssrc:") != NULL &&
      g_strstr_len(audio, (gssize)len, "msid:" WRTC_MSID_STREAM_ID) != NULL;

  if (!has_opus || !sendonly || !has_msid || !has_ssrc_msid) {
    my_zlog_warn("%s: audio SDP incomplete | opus=%s | sendonly=%s | msid=%s | ssrc_msid=%s",
        label, has_opus ? "yes" : "no", sendonly ? "yes" : "no", has_msid ? "yes" : "no",
        has_ssrc_msid ? "yes" : "no");
    return;
  }
  my_zlog_info("%s: audio OK (opus, msid=%s/%s)", label, WRTC_MSID_STREAM_ID,
      WRTC_MSID_AUDIO_TRACK_ID);
  g_print("%s: m=audio | sendonly=yes | msid=yes | opus=yes\n", label);
}

static gchar *finalize_signaling_answer_sdp(AppState *app, const gchar *base_sdp) {
  if (!base_sdp)
    return NULL;

  refresh_sprop_from_webrtc_caps(app);
  refresh_video_ssrc_from_webrtc_caps(app);
  refresh_audio_ssrc_from_webrtc_caps(app);
  if (app->enable_audio && s_audio_ssrc == 0)
    alloc_rtp_ssrc(&s_audio_ssrc);
  gint vpt = refresh_h264_pt_from_webrtc_caps(app);
  gchar *rewritten = sdp_rewrite_video_h264_sendonly(base_sdp, vpt, WRTC_H264_PROFILE_LEVEL_ID,
      s_sprop_parameter_sets);
  if (app->enable_audio) {
    gint apt = s_offer_opus_pt > 0 ? s_offer_opus_pt : sdp_find_opus_payload_type(base_sdp);
    gchar *with_audio = sdp_rewrite_audio_opus_sendonly(rewritten, apt);
    g_free(rewritten);
    rewritten = with_audio;
  }
  gchar *final = sdp_ensure_msid_semantic(rewritten);
  g_free(rewritten);
  log_sdp_audio_summary("local SDP answer", final);
  return final;
}

static GstWebRTCSessionDescription *answer_desc_from_sdp_text(const gchar *sdp_text) {
  if (!sdp_text)
    return NULL;
  GstSDPMessage *sdp = NULL;
  if (gst_sdp_message_new(&sdp) != GST_SDP_OK ||
      gst_sdp_message_parse_buffer((guint8 *)sdp_text, strlen(sdp_text), sdp) != GST_SDP_OK) {
    g_printerr("answer SDP parse failed\n");
    if (sdp)
      gst_sdp_message_free(sdp);
    return NULL;
  }
  return gst_webrtc_session_description_new(GST_WEBRTC_SDP_TYPE_ANSWER, sdp);
}

static gchar *sdp_patch_h264_fmtp(const gchar *sdp_text) {
  if (!sdp_text)
    return g_strdup(sdp_text);

  gchar *out = g_strdup(sdp_text);
  if (!strstr(out, "profile-level-id")) {
    gchar *with_la = g_strdup_printf(
        "packetization-mode=1;profile-level-id=%s;level-asymmetry-allowed=1",
        WRTC_H264_PROFILE_LEVEL_ID);
    gchar *plain = g_strdup_printf("packetization-mode=1;profile-level-id=%s", WRTC_H264_PROFILE_LEVEL_ID);

    if (strstr(out, "packetization-mode=1;level-asymmetry-allowed=1")) {
      gchar *replaced = str_replace_once(out, "packetization-mode=1;level-asymmetry-allowed=1", with_la);
      g_free(out);
      out = replaced;
    } else if (strstr(out, "packetization-mode=1")) {
      gchar *replaced = str_replace_once(out, "packetization-mode=1", plain);
      g_free(out);
      out = replaced;
    }
    g_print("SDP patched: profile-level-id=%s (1280x720 H.264)\n", WRTC_H264_PROFILE_LEVEL_ID);
    g_free(with_la);
    g_free(plain);
  }

  if (s_sprop_parameter_sets && !strstr(out, "sprop-parameter-sets")) {
    gchar *needle = g_strdup_printf("profile-level-id=%s", WRTC_H264_PROFILE_LEVEL_ID);
    gchar *insert = g_strdup_printf("profile-level-id=%s;sprop-parameter-sets=%s",
        WRTC_H264_PROFILE_LEVEL_ID, s_sprop_parameter_sets);
    if (strstr(out, needle)) {
      gchar *replaced = str_replace_once(out, needle, insert);
      g_free(out);
      out = replaced;
      g_print("SDP patched: sprop-parameter-sets (mobile H.264 decode)\n");
    }
    g_free(needle);
    g_free(insert);
  }

  return out;
}

static gchar *sdp_patch_for_browser(const gchar *sdp_text) {
  gchar *fmtp = sdp_patch_h264_fmtp(sdp_text);
  gchar *msid = sdp_patch_video_msid(fmtp);
  g_free(fmtp);
  return msid;
}

static GstWebRTCSessionDescription *patch_session_description(
    GstWebRTCSessionDescription *desc, GstWebRTCSDPType type, gchar **signaling_sdp_out) {
  if (!desc || !desc->sdp)
    return desc;

  gchar *sdp_text = gst_sdp_message_as_text(desc->sdp);
  gchar *patched = NULL;
  if (type == GST_WEBRTC_SDP_TYPE_ANSWER) {
    gint pt = s_offer_h264_pt > 0 ? s_offer_h264_pt : sdp_find_h264_payload_type(sdp_text);
    gchar *rewritten = sdp_rewrite_video_h264_sendonly(sdp_text, pt, WRTC_H264_PROFILE_LEVEL_ID,
        s_sprop_parameter_sets);
    gchar *with_audio = rewritten;
    if (s_offer_opus_pt > 0 && strstr(sdp_text, "m=audio "))
      with_audio = sdp_rewrite_audio_opus_sendonly(rewritten, s_offer_opus_pt);
    if (with_audio != rewritten)
      g_free(rewritten);
    patched = sdp_ensure_msid_semantic(with_audio);
    if (patched != with_audio)
      g_free(with_audio);
  } else {
    patched = sdp_patch_for_browser(sdp_text);
  }

  if (signaling_sdp_out)
    *signaling_sdp_out = g_strdup(patched);

  gboolean changed = strcmp(patched, sdp_text) != 0;
  g_free(sdp_text);

  if (!changed) {
    g_free(patched);
    return desc;
  }

  GstSDPMessage *new_sdp = NULL;
  if (gst_sdp_message_new(&new_sdp) != GST_SDP_OK ||
      gst_sdp_message_parse_buffer((guint8 *)patched, strlen(patched), new_sdp) != GST_SDP_OK) {
    g_printerr("SDP patch parse failed; set-local uses original GStreamer SDP\n");
    g_free(patched);
    if (new_sdp)
      gst_sdp_message_free(new_sdp);
    return desc;
  }
  g_free(patched);
  gst_webrtc_session_description_free(desc);
  return gst_webrtc_session_description_new(type, new_sdp);
}

static void reset_ice_out_state(void) {
  s_sdp_answer_sent = FALSE;
  s_remote_desc_applied = FALSE;
  s_offer_mid_count = 0;
  s_sprop_ready = FALSE;
  s_offer_h264_pt = 103;
  s_offer_h264_rtx_pt = -1;
  s_offer_opus_pt = 111;
  s_video_ssrc = 0;
  s_audio_ssrc = 0;
  g_free(s_sprop_parameter_sets);
  s_sprop_parameter_sets = NULL;
  if (s_ice_pending) {
    gchar *m;
    while ((m = g_queue_pop_head(s_ice_pending)) != NULL)
      g_free(m);
  }
  if (s_remote_ice_pending) {
    JsonObject *o;
    while ((o = g_queue_pop_head(s_remote_ice_pending)) != NULL)
      json_object_unref(o);
  }
}

static void ice_out_clear_remote_pending(void) {
  if (s_remote_ice_pending) {
    JsonObject *o;
    while ((o = g_queue_pop_head(s_remote_ice_pending)) != NULL)
      json_object_unref(o);
  }
}

static gboolean ice_candidate_matches_ufrag(const gchar *cand, const gchar *ufrag) {
  gchar *needle;

  if (!cand || !ufrag || !ufrag[0])
    return TRUE;
  needle = g_strdup_printf("ufrag %s", ufrag);
  gboolean ok = strstr(cand, needle) != NULL;
  g_free(needle);
  return ok;
}

static void ensure_ice_queue(void) {
  if (!s_ice_pending)
    s_ice_pending = g_queue_new();
  if (!s_remote_ice_pending)
    s_remote_ice_pending = g_queue_new();
}

/* 仅清空待发 ICE；勿在 teardown 里把 s_sdp_answer_sent 置 FALSE，否则 WS 断线后会误打印「queued」 */
static void ice_out_clear_pending_only(void) {
  if (s_ice_pending) {
    gchar *m;
    while ((m = g_queue_pop_head(s_ice_pending)) != NULL)
      g_free(m);
  }
}

/* set-local/set-remote 成功时 reply 可能为空结构，不能用 get_reply==NULL 判断失败 */
static gboolean promise_replied(GstPromise *promise) {
  return gst_promise_wait(promise) == GST_PROMISE_RESULT_REPLIED;
}

static void store_offer_mids(const gchar *sdp_text) {
  s_offer_mid_count = 0;
  for (guint i = 0; i < G_N_ELEMENTS(s_offer_mids); i++) {
    g_free(s_offer_mids[i]);
    s_offer_mids[i] = NULL;
  }
  if (!sdp_text)
    return;

  const gchar *p = sdp_text;
  while (s_offer_mid_count < G_N_ELEMENTS(s_offer_mids)) {
    const gchar *mid = g_strstr_len(p, -1, "a=mid:");
    if (!mid)
      break;
    mid += 6;
    const gchar *end = strchr(mid, '\r');
    if (!end)
      end = strchr(mid, '\n');
    if (!end)
      break;
    s_offer_mids[s_offer_mid_count++] = g_strndup(mid, (gsize)(end - mid));
    p = end;
  }
}

static const gchar *mid_for_mline(guint mline) {
  if (mline < s_offer_mid_count && s_offer_mids[mline])
    return s_offer_mids[mline];
  return "";
}

static void flush_pending_remote_ice(AppState *app) {
  JsonObject *obj;
  guint n = 0;
  if (!s_remote_ice_pending || !app)
    return;
  while ((obj = g_queue_pop_head(s_remote_ice_pending)) != NULL) {
    gst_webrtc_handle_remote_ice(app, obj);
    json_object_unref(obj);
    n++;
  }
  if (n > 0)
    my_zlog_info("Flushed %u queued remote ICE candidate(s)", n);
}

static void flush_pending_ice(AppState *app) {
  gchar *msg;
  if (!s_ice_pending || !app)
    return;
  while ((msg = g_queue_pop_head(s_ice_pending)) != NULL) {
    ws_send_text(app, msg);
    g_free(msg);
  }
}

static gboolean bus_call(GstBus *bus, GstMessage *msg, gpointer data) {
  (void)bus;
  (void)data;
  switch (GST_MESSAGE_TYPE(msg)) {
  case GST_MESSAGE_ERROR: {
    GError *err = NULL;
    gchar *dbg = NULL;
    gst_message_parse_error(msg, &err, &dbg);
    /* SCTP/DataChannel 闪断常见，勿因此拆管道（会触发手机重复 Offer → 崩溃） */
    if (err && (strstr(err->message, "SCTP") || strstr(err->message, "sctp") ||
                (dbg && (strstr(dbg, "sctpenc") || strstr(dbg, "SctpEnc"))))) {
      my_zlog_warn("GStreamer SCTP (ignored teardown): %s", err->message);
      if (sctp_error_affects_media())
        mark_media_degraded("SCTP error");
      else
        my_zlog_info("webrtcpush: SCTP error while video RTP active (Android PC swap, keep session)");
    } else {
      g_printerr("GStreamer ERROR: %s\n%s\n", err->message, dbg ? dbg : "");
    }
    g_error_free(err);
    g_free(dbg);
    break;
  }
  case GST_MESSAGE_WARNING: {
    GError *err = NULL;
    gchar *dbg = NULL;
    gst_message_parse_warning(msg, &err, &dbg);
    g_printerr("GStreamer WARNING: %s\n%s\n", err->message, dbg ? dbg : "");
    g_error_free(err);
    g_free(dbg);
    break;
  }
  default:
    break;
  }
  return TRUE;
}

/* 与 signal.js / client.js 一致：先 video 后 audio，故 H264 -> sink_0，Opus -> sink_1 */
static GstPad *request_video_sink_pad(GstElement *webrtc) {
  GstPad *sink = gst_element_request_pad_simple(webrtc, "sink_0");
  if (sink)
    return sink;
  return gst_element_request_pad_simple(webrtc, "sink_%u");
}

static void try_link_payloader_to_webrtc(AppState *app) {
  if (app->payloader_linked || !app->webrtc || !s_queue)
    return;

  GstPad *src = gst_element_get_static_pad(s_queue, "src");
  if (!src)
    return;

  GstPad *sink = request_video_sink_pad(app->webrtc);
  if (!sink) {
    g_printerr("request_pad webrtcbin video sink failed\n");
    gst_object_unref(src);
    return;
  }

  if (gst_pad_link(src, sink) != GST_PAD_LINK_OK) {
    g_printerr("Failed to link H264 RTP to webrtcbin\n");
    gst_object_unref(sink);
    gst_object_unref(src);
    return;
  }

  g_print("Video (H.264) linked to webrtcbin (%s)\n", GST_PAD_NAME(sink));
  gst_object_unref(sink);
  gst_object_unref(src);
  app->payloader_linked = TRUE;
}

static void try_link_audio_to_webrtc(AppState *app) {
  if (!app->enable_audio || app->audio_linked || !app->webrtc || !s_aqueue)
    return;

  GstPad *src = gst_element_get_static_pad(s_aqueue, "src");
  if (!src)
    return;

  GstPad *sink = gst_element_request_pad_simple(app->webrtc, "sink_1");
  if (!sink) {
    g_printerr("request_pad webrtcbin audio sink_1 failed (offer order changed?)\n");
    gst_object_unref(src);
    return;
  }

  if (gst_pad_link(src, sink) != GST_PAD_LINK_OK) {
    g_printerr("Failed to link Opus RTP to webrtcbin\n");
    gst_object_unref(sink);
    gst_object_unref(src);
    return;
  }

  g_print("Audio (Opus) linked to webrtcbin (%s)\n", GST_PAD_NAME(sink));
  apply_opus_payloader_pt();
  my_zlog_info("webrtcpush mic linked (uplink Opus); phone client.html muted by default — tap 🔊 on phone");
  gst_object_unref(sink);
  gst_object_unref(src);
  app->audio_linked = TRUE;
}

static void wait_pipeline_state_ms(GstElement *pipe, GstState target, const gchar *label, gint timeout_ms) {
  GstState state = GST_STATE_VOID_PENDING;
  GstStateChangeReturn ret = gst_element_set_state(pipe, target);
  if (ret == GST_STATE_CHANGE_FAILURE) {
    g_printerr("pipeline failed to reach %s\n", label);
    return;
  }
  gst_element_get_state(pipe, &state, NULL, (GstClockTime)(timeout_ms * GST_MSECOND));
  g_print("pipeline %s: %s\n", label, gst_element_state_get_name(state));
}

static void setup_webrtc_ice(GstElement *webrtc) {
  /* 与 client.js iceServers 对齐；允许 host/srflx/relay，与手机端一致 */
  g_object_set(webrtc,
      "stun-server", "stun://stun.l.google.com:19302",
      "turn-server", "turn://coturn:coturn@8.129.3.163:18084",
      "bundle-policy", GST_WEBRTC_BUNDLE_POLICY_MAX_BUNDLE,
      "ice-transport-policy", GST_WEBRTC_ICE_TRANSPORT_POLICY_ALL,
      NULL);
  {
    gboolean ok = FALSE;
    g_signal_emit_by_name(webrtc, "add-turn-server", "turn://coturn:coturn@8.129.3.163:18084", &ok);
    if (!ok)
      g_printerr("warning: add-turn-server failed\n");
    g_signal_emit_by_name(webrtc, "add-turn-server", "turn://coturn:coturn@8.129.3.163:18084?transport=tcp", &ok);
    g_signal_emit_by_name(webrtc, "add-turn-server", "stun://8.129.3.163:18084", &ok);
    g_signal_emit_by_name(webrtc, "add-turn-server", "stun://stun.cloudflare.com:3478", &ok);
  }
  g_print("ICE: STUN+TURN (all candidates) coturn@8.129.3.163:18084\n");
}

static void request_video_keyframe(void) {
  static guint request_count = 0;
  if (!s_pay)
    return;
  GstPad *sink = gst_element_get_static_pad(s_pay, "sink");
  if (!sink)
    return;
  GstEvent *event = gst_video_event_new_upstream_force_key_unit(
      GST_CLOCK_TIME_NONE, TRUE, ++request_count);
  gboolean ok = gst_pad_send_event(sink, event);
  gst_object_unref(sink);
  if (ok)
    my_zlog_info("webrtcpush: requested H.264 IDR (%u)", request_count);
  else
    my_zlog_warn("webrtcpush: H.264 IDR request was not handled");
}

static void on_new_transceiver(GstElement *webrtc, GstWebRTCRTPTransceiver *transceiver,
    gpointer user_data) {
  (void)webrtc;
  (void)user_data;
  if (!transceiver)
    return;
  /* 让 webrtcbin/rtpbin 按远端 RTX apt 映射缓存包并响应 NACK。 */
  g_object_set(transceiver, "do-nack", TRUE, NULL);
}

static gboolean datachannel_send_mbps(GstWebRTCDataChannel *channel, gdouble mbps) {
  GstWebRTCDataChannelState state;

  if (!channel)
    return FALSE;
  g_object_get(channel, "ready-state", &state, NULL);
  if (state != GST_WEBRTC_DATA_CHANNEL_STATE_OPEN)
    return FALSE;

  gchar *msg = g_strdup_printf("%.2f", mbps);
  gst_webrtc_data_channel_send_string(channel, msg);
  g_free(msg);
  return TRUE;
}

static void on_datachannel_open(GstWebRTCDataChannel *channel, gpointer user_data) {
  (void)user_data;
  gdouble mbps = s_bw_estimate_mbps > 0.0 ? s_bw_estimate_mbps : 2.5;
  if (datachannel_send_mbps(channel, mbps))
    g_print("DataChannel open, sent %.2f Mbps to phone\n", mbps);
}

static void on_incoming_data_channel(GstElement *webrtc, GstWebRTCDataChannel *channel,
    gpointer user_data) {
  (void)webrtc;
  (void)user_data;
  gchar *label = NULL;
  g_object_get(channel, "label", &label, NULL);
  g_print("Phone DataChannel '%s' open (BUNDLE SCTP)\n", label ? label : "?");
  g_free(label);
  /* 手机 createDataChannel('init')；带宽走我方 myDataChannel，勿覆盖 s_datachannel */
  if (s_phone_datachannel)
    gst_object_unref(s_phone_datachannel);
  s_phone_datachannel = channel;
  gst_object_ref(channel);
}

static const char *ice_candidate_typ(const gchar *cand) {
  if (!cand)
    return "?";
  const gchar *t = strstr(cand, " typ ");
  if (!t)
    return "?";
  t += 5;
  if (g_str_has_prefix(t, "host"))
    return "host";
  if (g_str_has_prefix(t, "srflx"))
    return "srflx";
  if (g_str_has_prefix(t, "relay"))
    return "relay";
  return t;
}

static gchar *build_signaling_description_json(const gchar *member_id, const gchar *sdp_type,
    const gchar *sdp_text) {
  /* 与 index.html signal.js 完全一致：{ memberId, description: { type, sdp } } */
  JsonBuilder *b = json_builder_new();
  json_builder_begin_object(b);
  json_builder_set_member_name(b, "memberId");
  json_builder_add_string_value(b, member_id);
  json_builder_set_member_name(b, "description");
  json_builder_begin_object(b);
  json_builder_set_member_name(b, "type");
  json_builder_add_string_value(b, sdp_type);
  json_builder_set_member_name(b, "sdp");
  json_builder_add_string_value(b, sdp_text);
  json_builder_end_object(b);
  json_builder_end_object(b);

  JsonGenerator *gen = json_generator_new();
  JsonNode *root = json_builder_get_root(b);
  json_generator_set_root(gen, root);
  json_node_unref(root);
  g_object_unref(b);
  gchar *json = json_generator_to_data(gen, NULL);
  g_object_unref(gen);
  return json;
}

static void send_sdp_answer(AppState *app, const gchar *signaling_sdp) {
  if (!signaling_sdp || !app->member_id)
    return;

  if (!sdp_video_section_has_h264(signaling_sdp)) {
    g_printerr("error: signaling SDP has no H264; not sending answer\n");
    log_sdp_video_summary("rejected signaling SDP", signaling_sdp);
    return;
  }
  g_print("local SDP answer includes H264\n");
  log_sdp_video_summary("local SDP answer", signaling_sdp);

  gchar *json = build_signaling_description_json(app->member_id, "answer", signaling_sdp);
  if (!json)
    return;

  if (app->verbose)
    g_print("[verbose] tx answer (signal.js format): %s\n", json);

  {
    guint pending = s_ice_pending ? (guint)g_queue_get_length(s_ice_pending) : 0;
    ws_send_text(app, json);
    g_free(json);
    s_sdp_answer_sent = TRUE;
    s_answer_sent_at_us = g_get_monotonic_time();
    s_media_degraded = FALSE;
    s_phone_left_pending = FALSE;
    s_driver_switch_pending = FALSE;
    if (s_leave_no_offer_timer_id) {
      g_source_remove(s_leave_no_offer_timer_id);
      s_leave_no_offer_timer_id = 0;
    }
    gst_webrtc_pipeline_debug_log_state(app, "answer-sent");
    g_print("SDP answer sent; flushing %u queued ICE candidate(s)\n", pending);
    flush_pending_ice(app);
    ws_signaling_service_burst(1);
    start_bitrate_timer(app);
  }
}

typedef struct {
  AppState *app;
  GstWebRTCSessionDescription *answer;
  gchar *signaling_sdp;
} LocalDescCtx;

static void on_set_local_done(GstPromise *promise, gpointer user_data) {
  LocalDescCtx *ctx = user_data;
  if (!promise_replied(promise)) {
    g_printerr("set-local-description failed\n");
    gst_promise_unref(promise);
    gst_webrtc_session_description_free(ctx->answer);
    g_free(ctx->signaling_sdp);
    g_free(ctx);
    return;
  }
  g_print("set-local-description done\n");
  gst_promise_unref(promise);

  ensure_datachannel(ctx->app->webrtc);

  s_remote_desc_applied = TRUE;
  flush_pending_remote_ice(ctx->app);

  if (s_post_renegotiate_keyframe) {
    s_post_renegotiate_keyframe = FALSE;
    request_video_keyframe();
    my_zlog_info("webrtcpush: post-renegotiate IDR requested (Android driver switch)");
  }

  send_sdp_answer(ctx->app, ctx->signaling_sdp);
  g_free(ctx->signaling_sdp);
  gst_webrtc_session_description_free(ctx->answer);
  g_free(ctx);
}

static void on_answer_created(GstPromise *promise, gpointer user_data) {
  AppState *app = user_data;
  const GstStructure *reply = gst_promise_get_reply(promise);
  GstWebRTCSessionDescription *answer = NULL;

  if (!reply || !gst_structure_get(reply, "answer", GST_TYPE_WEBRTC_SESSION_DESCRIPTION, &answer, NULL)) {
    if (reply) {
      gchar *dbg = gst_structure_to_string(reply);
      g_printerr("create-answer: no answer (reply=%s)\n", dbg);
      g_free(dbg);
    } else
      g_printerr("create-answer: null reply\n");
    if (!app->payloader_linked)
      g_printerr("hint: video RTP not linked — no H.264\n");
    if (app->enable_audio && !app->audio_linked)
      g_printerr("hint: audio RTP not linked — no microphone path\n");
    gst_promise_unref(promise);
    return;
  }

  gchar *raw_sdp = gst_sdp_message_as_text(answer->sdp);
  gst_webrtc_session_description_free(answer);

  gchar *final_sdp = finalize_signaling_answer_sdp(app, raw_sdp);
  g_free(raw_sdp);
  if (!final_sdp) {
    gst_promise_unref(promise);
    return;
  }

  GstWebRTCSessionDescription *patched = answer_desc_from_sdp_text(final_sdp);
  if (!patched) {
    g_free(final_sdp);
    gst_promise_unref(promise);
    return;
  }

  LocalDescCtx *ctx = g_new(LocalDescCtx, 1);
  ctx->app = app;
  ctx->answer = patched;
  ctx->signaling_sdp = final_sdp;

  GstPromise *ld = gst_promise_new_with_change_func(on_set_local_done, ctx, NULL);
  g_signal_emit_by_name(app->webrtc, "set-local-description", patched, ld);
  gst_promise_unref(promise);
}

typedef struct {
  AppState *app;
} DeferredAnswer;

typedef struct {
  AppState *app;
  gint waited_ms;
} AnswerWaitCtx;

static void start_create_answer(AppState *app) {
  if (!app || !app->webrtc)
    return;
  apply_h264_payloader_pt();
  apply_opus_payloader_pt();
  ensure_datachannel(app->webrtc);
  if (!s_sprop_ready)
    g_print("warning: creating answer without sprop-parameter-sets\n");
  GstPromise *ans = gst_promise_new_with_change_func(on_answer_created, app, NULL);
  g_signal_emit_by_name(app->webrtc, "create-answer", NULL, ans);
}

static gboolean answer_wait_tick(gpointer user_data) {
  AnswerWaitCtx *ctx = user_data;
  if (s_sprop_ready || ctx->waited_ms >= WRTC_ANSWER_SPROP_WAIT_MS) {
    start_create_answer(ctx->app);
    g_free(ctx);
    return G_SOURCE_REMOVE;
  }
  ctx->waited_ms += 50;
  return G_SOURCE_CONTINUE;
}

/* set-remote 的 Promise 可能在 streaming 线程回调；pad 申请/link 放到 GLib 主线程更稳 */
static gboolean idle_link_and_answer(gpointer user_data) {
  DeferredAnswer *d = user_data;
  AppState *app = d->app;
  g_free(d);

  if (!app->webrtc)
    return G_SOURCE_REMOVE;

  apply_h264_payloader_pt();
  try_link_payloader_to_webrtc(app);
  try_link_audio_to_webrtc(app);

  if (!app->payloader_linked) {
    g_printerr("Cannot create answer: video RTP not linked to webrtcbin\n");
    return G_SOURCE_REMOVE;
  }

  if (s_renegotiating) {
    s_renegotiating = FALSE;
    start_create_answer(app);
    return G_SOURCE_REMOVE;
  }

  attach_rtp_probe();
  attach_audio_rtp_probe();
  attach_enc_probe();
  attach_sprop_probe();

  /* 先出 720p 帧并抓取 SPS/PPS，再 create-answer（手机解码需要 sprop） */
  if (app->pipeline) {
    gst_element_set_state(app->pipeline, GST_STATE_PLAYING);
    hold_alsasrc_capture(app->pipeline);
  }

  AnswerWaitCtx *ctx = g_new(AnswerWaitCtx, 1);
  ctx->app = app;
  ctx->waited_ms = 0;
  g_timeout_add(50, answer_wait_tick, ctx);
  return G_SOURCE_REMOVE;
}

static void on_set_remote_done(GstPromise *promise, gpointer user_data) {
  AppState *app = user_data;
  if (!promise_replied(promise)) {
    g_printerr("set-remote-description failed\n");
    gst_promise_unref(promise);
    return;
  }
  g_print("set-remote-description done\n");
  app->remote_description_set = TRUE;
  gst_promise_unref(promise);

  DeferredAnswer *d = g_new(DeferredAnswer, 1);
  d->app = app;
  g_idle_add(idle_link_and_answer, d);
}

static void on_ice_gathering_state(GstElement *webrtc, GParamSpec *pspec, gpointer user_data) {
  (void)pspec;
  (void)user_data;
  GstWebRTCICEGatheringState state;
  g_object_get(webrtc, "ice-gathering-state", &state, NULL);
  g_print("ICE gathering state: %s\n",
      state == GST_WEBRTC_ICE_GATHERING_STATE_NEW          ? "new"
      : state == GST_WEBRTC_ICE_GATHERING_STATE_GATHERING ? "gathering"
      : state == GST_WEBRTC_ICE_GATHERING_STATE_COMPLETE  ? "complete"
                                                          : "unknown");
}

static gboolean idle_teardown_on_ice_failed(gpointer user_data) {
  (void)user_data;
  /* 不因 ICE failed 主动拆管道：SCTP 抖动会误触发，导致手机重复 Offer 与 webrtcbin 崩溃 */
  g_print("ICE failed: keep pipeline (refresh client.html if media dead)\n");
  return G_SOURCE_REMOVE;
}

static void on_ice_connection_state(GstElement *webrtc, GParamSpec *pspec, gpointer user_data) {
  AppState *app = user_data;
  (void)pspec;
  GstWebRTCICEConnectionState state;
  g_object_get(webrtc, "ice-connection-state", &state, NULL);
  g_print("ICE connection state: %s\n",
      state == GST_WEBRTC_ICE_CONNECTION_STATE_NEW             ? "new"
      : state == GST_WEBRTC_ICE_CONNECTION_STATE_CHECKING      ? "checking"
      : state == GST_WEBRTC_ICE_CONNECTION_STATE_CONNECTED     ? "connected"
      : state == GST_WEBRTC_ICE_CONNECTION_STATE_COMPLETED   ? "completed"
      : state == GST_WEBRTC_ICE_CONNECTION_STATE_FAILED      ? "failed"
      : state == GST_WEBRTC_ICE_CONNECTION_STATE_DISCONNECTED ? "disconnected"
      : state == GST_WEBRTC_ICE_CONNECTION_STATE_CLOSED      ? "closed"
                                                               : "unknown");
  s_ice_media_ok = (state == GST_WEBRTC_ICE_CONNECTION_STATE_CONNECTED ||
                    state == GST_WEBRTC_ICE_CONNECTION_STATE_COMPLETED);
  if (state == GST_WEBRTC_ICE_CONNECTION_STATE_DISCONNECTED ||
      state == GST_WEBRTC_ICE_CONNECTION_STATE_FAILED) {
    gint64 now = g_get_monotonic_time();
    gboolean rtp_recent = s_last_video_rtp_us > 0 &&
                          (now - s_last_video_rtp_us) < WRTC_RTP_ACTIVE_WINDOW_US;
    if (!rtp_recent) {
      mark_media_degraded("ICE disconnected/failed");
      gst_webrtc_pipeline_debug_log_state(app, "ice-bad");
    } else {
      my_zlog_info("webrtcpush: ICE %s with recent RTP (Android reconnect, keep pipeline)",
          state == GST_WEBRTC_ICE_CONNECTION_STATE_FAILED ? "failed" : "disconnected");
    }
  }
  if ((state == GST_WEBRTC_ICE_CONNECTION_STATE_CONNECTED ||
       state == GST_WEBRTC_ICE_CONNECTION_STATE_COMPLETED) && !s_ice_playing_started) {
    s_ice_playing_started = TRUE;
    s_media_degraded = FALSE;
    gst_webrtc_pipeline_debug_log_state(app, "ice-up");
    g_print("ICE media path up — starting H.264 stream\n");
    log_webrtc_video_sink_caps(app);
    reset_video_bitrate_state();
    apply_video_bitrate_bps(WRTC_VIDEO_START_BITRATE_BPS);
    s_applied_video_bps = WRTC_VIDEO_START_BITRATE_BPS;
    open_audio_valve(app);
    request_video_keyframe();
    if (app && app->pipeline)
      gst_element_set_state(app->pipeline, GST_STATE_PLAYING);
  }
  if (state == GST_WEBRTC_ICE_CONNECTION_STATE_FAILED && app) {
    g_printerr("hint: ICE failed — check TURN 8.129.3.163:18084 reachable; refresh phone client.html\n");
    if (app->pipeline)
      g_idle_add(idle_teardown_on_ice_failed, app);
  }
}

static guint mbps_to_target_bps(gdouble mbps) {
  /* 估计带宽的 90% 用于编码；弱网压低，好网上限 3.75 Mbps */
  gdouble usable = mbps * WRTC_BW_UTILIZATION;
  if (usable < 0.45)
    usable = 0.45;
  if (usable > WRTC_LINK_CAP_MBPS)
    usable = WRTC_LINK_CAP_MBPS;
  guint bps = (guint)(usable * 1000000.0);
  if (bps < WRTC_VIDEO_MIN_BITRATE_BPS)
    bps = WRTC_VIDEO_MIN_BITRATE_BPS;
  if (bps > WRTC_VIDEO_MAX_BITRATE_BPS)
    bps = WRTC_VIDEO_MAX_BITRATE_BPS;
  return bps;
}

static void open_audio_valve(AppState *app) {
  if (s_audio_valve) {
    GstElementFactory *f = gst_element_get_factory(s_audio_valve);
    const gchar *name = f ? gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(f)) : NULL;
    if (name && !g_strcmp0(name, "valve")) {
      g_object_set(s_audio_valve, "drop", FALSE, NULL);
      g_print("Audio valve open (capture starts with video)\n");
    }
  }
  if (app && app->pipeline && s_hold_alsasrc) {
    GstElement *asrc = gst_bin_get_by_name(GST_BIN(app->pipeline), "asrc");
    if (asrc) {
      gst_element_set_locked_state(asrc, FALSE);
      gst_element_set_state(asrc, GST_STATE_PLAYING);
      gst_object_unref(asrc);
      g_print("alsasrc -> PLAYING (locked until ICE up)\n");
    }
  }
}

static gdouble read_ice_outgoing_mbps(const GstStructure *st) {
  guint64 br = 0;
  if (gst_structure_get_uint64(st, "availableOutgoingBitrate", &br) ||
      gst_structure_get_uint64(st, "available-outgoing-bitrate", &br)) {
    if (br > 0)
      return (gdouble)br / 1000000.0;
  }
  return 0.0;
}

typedef struct {
  gboolean has_tx;
  gboolean has_packets_sent;
  gboolean has_packets_lost;
  gboolean has_fraction_lost;
  gboolean has_rtt;
  gboolean has_jitter;
  gboolean has_available_bitrate;
  gdouble tx_mbps;
  gdouble loss_fraction;
  gdouble rtt_ms;
  gdouble jitter_ms;
  gdouble available_mbps;
  guint64 packets_sent;
  guint64 packets_lost;
} WrtcNetworkStats;

static gboolean stats_value_as_double(const GstStructure *st, const gchar *key, gdouble *out) {
  const GValue *v = gst_structure_get_value(st, key);
  if (!v)
    return FALSE;
  if (G_VALUE_HOLDS_DOUBLE(v))
    *out = g_value_get_double(v);
  else if (G_VALUE_HOLDS_FLOAT(v))
    *out = g_value_get_float(v);
  else if (G_VALUE_HOLDS_UINT64(v))
    *out = (gdouble)g_value_get_uint64(v);
  else if (G_VALUE_HOLDS_INT64(v))
    *out = (gdouble)g_value_get_int64(v);
  else if (G_VALUE_HOLDS_UINT(v))
    *out = (gdouble)g_value_get_uint(v);
  else if (G_VALUE_HOLDS_INT(v))
    *out = (gdouble)g_value_get_int(v);
  else
    return FALSE;
  return TRUE;
}

static gboolean stats_get_number2(const GstStructure *st, const gchar *a, const gchar *b,
    gdouble *out) {
  return stats_value_as_double(st, a, out) || (b && stats_value_as_double(st, b, out));
}

static gboolean stats_type_is(const gchar *field_name, const GstStructure *st, const gchar *type) {
  const gchar *reported = gst_structure_get_string(st, "type");
  if (reported)
    return !g_strcmp0(reported, type);
  if (!field_name)
    return FALSE;
  if (!g_strcmp0(type, "outbound-rtp"))
    return g_strrstr(field_name, "outbound") && !g_strrstr(field_name, "remote");
  if (!g_strcmp0(type, "remote-inbound-rtp"))
    return g_strrstr(field_name, "remote-inbound") != NULL;
  if (!g_strcmp0(type, "candidate-pair"))
    return g_strrstr(field_name, "candidate-pair") != NULL;
  return FALSE;
}

static gboolean stats_matches_video(const GstStructure *st) {
  gdouble ssrc = 0.0;
  if (s_video_ssrc && stats_value_as_double(st, "ssrc", &ssrc) && (guint32)ssrc != s_video_ssrc)
    return FALSE;
  const gchar *media = gst_structure_get_string(st, "media");
  if (!media)
    media = gst_structure_get_string(st, "kind");
  if (!media)
    media = gst_structure_get_string(st, "media-type");
  return !media || !g_strcmp0(media, "video");
}

static void collect_network_stats(const GstStructure *stats, gint64 now_us,
    WrtcNetworkStats *out) {
  memset(out, 0, sizeof(*out));
  if (!stats)
    return;

  gint n = gst_structure_n_fields(stats);
  for (gint i = 0; i < n; i++) {
    const gchar *fname = gst_structure_nth_field_name(stats, i);
    const GValue *v = gst_structure_get_value(stats, fname);
    if (!v || !GST_VALUE_HOLDS_STRUCTURE(v))
      continue;
    const GstStructure *st = gst_value_get_structure(v);

    if (stats_type_is(fname, st, "outbound-rtp") && stats_matches_video(st)) {
      gdouble bytes = 0.0;
      if (stats_get_number2(st, "bytes-sent", "bytesSent", &bytes) && bytes >= 0.0) {
        guint64 total = (guint64)bytes;
        if (s_prev_rtp_stats_us > 0 && now_us > s_prev_rtp_stats_us && total >= s_prev_rtp_bytes) {
          gdouble secs = (now_us - s_prev_rtp_stats_us) / 1000000.0;
          gdouble mbps = (gdouble)(total - s_prev_rtp_bytes) * 8.0 / secs / 1000000.0;
          if (secs >= 0.4 && mbps >= 0.0 && mbps < 20.0) {
            out->tx_mbps = mbps;
            out->has_tx = TRUE;
          }
        }
        s_prev_rtp_bytes = total;
        s_prev_rtp_stats_us = now_us;
      }
      gdouble packets = 0.0;
      if (stats_get_number2(st, "packets-sent", "packetsSent", &packets) && packets >= 0.0) {
        out->packets_sent = (guint64)packets;
        out->has_packets_sent = TRUE;
      }
      continue;
    }

    if (stats_type_is(fname, st, "remote-inbound-rtp") && stats_matches_video(st)) {
      gdouble value = 0.0;
      if (stats_get_number2(st, "packets-lost", "packetsLost", &value) && value >= 0.0) {
        out->packets_lost = (guint64)value;
        out->has_packets_lost = TRUE;
      }
      if (stats_get_number2(st, "fraction-lost", "fractionLost", &value) && value >= 0.0) {
        if (value > 1.0)
          value /= 256.0;
        out->loss_fraction = CLAMP(value, 0.0, 1.0);
        out->has_fraction_lost = TRUE;
      }
      if (stats_get_number2(st, "round-trip-time", "roundTripTime", &value) && value >= 0.0) {
        out->rtt_ms = value <= 10.0 ? value * 1000.0 : value;
        out->has_rtt = TRUE;
      }
      if (stats_value_as_double(st, "jitter", &value) && value >= 0.0) {
        out->jitter_ms = value <= 10.0 ? value * 1000.0 : value;
        out->has_jitter = TRUE;
      }
      continue;
    }

    if (stats_type_is(fname, st, "candidate-pair")) {
      gboolean nominated = FALSE;
      gboolean selected = gst_structure_get_boolean(st, "nominated", &nominated) && nominated;
      const gchar *state = gst_structure_get_string(st, "state");
      if (!selected && state)
        selected = !g_strcmp0(state, "succeeded");
      if (!selected)
        continue;
      gdouble available = read_ice_outgoing_mbps(st);
      if (available > out->available_mbps) {
        out->available_mbps = available;
        out->has_available_bitrate = available > 0.0;
      }
      gdouble value = 0.0;
      if (!out->has_rtt &&
          stats_get_number2(st, "current-round-trip-time", "round-trip-time", &value) && value >= 0.0) {
        out->rtt_ms = value <= 10.0 ? value * 1000.0 : value;
        out->has_rtt = TRUE;
      }
    }
  }

  if (!out->has_fraction_lost && out->has_packets_sent && out->has_packets_lost &&
      s_prev_packets_sent > 0 && out->packets_sent >= s_prev_packets_sent &&
      out->packets_lost >= s_prev_packets_lost) {
    guint64 sent_delta = out->packets_sent - s_prev_packets_sent;
    guint64 lost_delta = out->packets_lost - s_prev_packets_lost;
    if (sent_delta + lost_delta > 0) {
      out->loss_fraction = (gdouble)lost_delta / (gdouble)(sent_delta + lost_delta);
      out->has_fraction_lost = TRUE;
    }
  }
  if (out->has_packets_sent)
    s_prev_packets_sent = out->packets_sent;
  if (out->has_packets_lost)
    s_prev_packets_lost = out->packets_lost;
}

static void apply_video_bitrate_bps(guint bitrate_bps) {
  if (!s_video_enc || !bitrate_bps)
    return;
  GstElementFactory *f = gst_element_get_factory(s_video_enc);
  const gchar *name = f ? gst_plugin_feature_get_name(GST_PLUGIN_FEATURE(f)) : NULL;
  if (name && g_str_has_prefix(name, "mpph264enc")) {
    guint bmin = (guint)(bitrate_bps * 0.80);
    guint bmax = (guint)(bitrate_bps * 1.08);
    if (bmin < WRTC_VIDEO_MIN_BITRATE_BPS)
      bmin = WRTC_VIDEO_MIN_BITRATE_BPS;
    if (bmax > WRTC_VIDEO_MAX_BITRATE_BPS)
      bmax = WRTC_VIDEO_MAX_BITRATE_BPS;
    g_object_set(s_video_enc, "bps", bitrate_bps, "bps-min", bmin, "bps-max", bmax, NULL);
  } else if (name && g_str_has_prefix(name, "openh264enc")) {
    g_object_set(s_video_enc, "bitrate", bitrate_bps, "max-bitrate", (guint)(bitrate_bps * 1.15), NULL);
  } else if (name && g_str_has_prefix(name, "x264enc")) {
    g_object_set(s_video_enc, "bitrate", bitrate_bps / 1000, NULL);
  }
}

static void ramp_video_bitrate(void) {
  guint cur = s_current_video_bps;
  guint tgt = s_target_video_bps;
  if (!cur || !tgt)
    return;
  if (cur == tgt)
    return;

  guint diff = (cur > tgt) ? (cur - tgt) : (tgt - cur);
  guint step;
  if (cur < tgt) {
    /* 稳定网络慢慢加，避免探测本身造成突发拥塞。 */
    step = diff / 8;
    if (step < 50000)
      step = 50000;
    if (step > 150000)
      step = 150000;
    cur = MIN(cur + step, tgt);
  } else {
    /* 丢包/高 RTT 时快速降码率，优先排空队列恢复实时性。 */
    step = diff / 3;
    if (step < 100000)
      step = 100000;
    if (step > 600000)
      step = 600000;
    cur = MAX(cur - step, tgt);
  }

  s_current_video_bps = cur;
  if (!s_applied_video_bps || (cur > s_applied_video_bps ? cur - s_applied_video_bps
                                                           : s_applied_video_bps - cur) >= 80000) {
    apply_video_bitrate_bps(cur);
    s_applied_video_bps = cur;
    my_zlog_debug("video bitrate ramp: %u kbps (target %u kbps, link ~%.2f Mbps)",
        cur / 1000, tgt / 1000, s_bw_estimate_mbps);
  }
}

static gdouble clamp_link_mbps(gdouble mbps) {
  if (mbps < 0.5)
    return 0.5;
  if (mbps > WRTC_LINK_CAP_MBPS)
    return WRTC_LINK_CAP_MBPS;
  return mbps;
}

static void reset_video_bitrate_state(void) {
  const gdouble start_link_mbps =
      (gdouble)WRTC_VIDEO_START_BITRATE_BPS / WRTC_BW_UTILIZATION / 1000000.0;
  s_bw_estimate_mbps = start_link_mbps;
  s_last_bitrate_mbps = start_link_mbps;
  s_current_video_bps = WRTC_VIDEO_START_BITRATE_BPS;
  s_target_video_bps = WRTC_VIDEO_START_BITRATE_BPS;
  s_applied_video_bps = 0;
  s_prev_rtp_bytes = 0;
  s_prev_rtp_stats_us = 0;
  s_bw_stats_missing_logged = FALSE;
  s_prev_packets_sent = 0;
  s_prev_packets_lost = 0;
  s_network_good_ticks = 0;
  s_network_log_ticks = 0;
  s_loss_ema = 0.0;
  s_rtt_ema_ms = 0.0;
  s_jitter_ema_ms = 0.0;
  s_network_ema_ready = FALSE;
}

static void on_get_stats(GstPromise *promise, gpointer user_data) {
  AppState *app = user_data;
  const GstStructure *stats = gst_promise_get_reply(promise);
  gint64 now_us = g_get_monotonic_time();
  WrtcNetworkStats net;
  collect_network_stats(stats, now_us, &net);

  gboolean has_quality = net.has_fraction_lost || net.has_rtt || net.has_jitter;
  if (!has_quality && !net.has_available_bitrate) {
    s_network_good_ticks = 0;
    if (!s_bw_stats_missing_logged) {
      s_bw_stats_missing_logged = TRUE;
      my_zlog_warn("webrtcpush: RTCP network stats unavailable; hold video at %u kbps",
          s_target_video_bps / 1000);
    }
  } else {
    s_bw_stats_missing_logged = FALSE;

    gdouble loss = net.has_fraction_lost ? net.loss_fraction : 0.0;
    gdouble rtt = net.has_rtt ? net.rtt_ms : 0.0;
    gdouble jitter = net.has_jitter ? net.jitter_ms : 0.0;
    if (!s_network_ema_ready) {
      s_loss_ema = loss;
      s_rtt_ema_ms = rtt;
      s_jitter_ema_ms = jitter;
      s_network_ema_ready = TRUE;
    } else {
      const gdouble alpha = 0.25;
      if (net.has_fraction_lost)
        s_loss_ema = (1.0 - alpha) * s_loss_ema + alpha * loss;
      if (net.has_rtt)
        s_rtt_ema_ms = (1.0 - alpha) * s_rtt_ema_ms + alpha * rtt;
      if (net.has_jitter)
        s_jitter_ema_ms = (1.0 - alpha) * s_jitter_ema_ms + alpha * jitter;
    }

    gboolean severe = (net.has_fraction_lost && s_loss_ema >= 0.08) ||
        (net.has_rtt && s_rtt_ema_ms >= 450.0) ||
        (net.has_jitter && s_jitter_ema_ms >= 100.0);
    gboolean congested = (net.has_fraction_lost && s_loss_ema >= 0.03) ||
        (net.has_rtt && s_rtt_ema_ms >= 250.0) ||
        (net.has_jitter && s_jitter_ema_ms >= 50.0);
    gboolean good = has_quality &&
        (!net.has_fraction_lost || s_loss_ema < 0.015) &&
        (!net.has_rtt || s_rtt_ema_ms < 180.0) &&
        (!net.has_jitter || s_jitter_ema_ms < 35.0);

    guint target = s_target_video_bps ? s_target_video_bps : WRTC_VIDEO_START_BITRATE_BPS;
    if (severe) {
      target = MAX(WRTC_VIDEO_MIN_BITRATE_BPS, (guint)(target * 0.65));
      s_network_good_ticks = 0;
    } else if (congested) {
      target = MAX(WRTC_VIDEO_MIN_BITRATE_BPS, (guint)(target * 0.82));
      s_network_good_ticks = 0;
    } else if (good) {
      s_network_good_ticks++;
      if (s_network_good_ticks >= 3) {
        s_network_good_ticks = 0;
        target = MIN(WRTC_VIDEO_MAX_BITRATE_BPS, target + 100000u);
      }
    } else {
      s_network_good_ticks = 0;
    }

    if (net.has_available_bitrate) {
      guint ice_target = mbps_to_target_bps(clamp_link_mbps(net.available_mbps));
      if (ice_target < target)
        target = ice_target;
    }
    s_target_video_bps = CLAMP(target, WRTC_VIDEO_MIN_BITRATE_BPS, WRTC_VIDEO_MAX_BITRATE_BPS);
    s_bw_estimate_mbps =
        (gdouble)s_target_video_bps / WRTC_BW_UTILIZATION / 1000000.0;

    s_network_log_ticks++;
    if (severe || congested || s_network_log_ticks >= 5) {
      s_network_log_ticks = 0;
      my_zlog_debug(
          "webrtcpush net: loss=%.1f%% rtt=%.0fms jitter=%.0fms tx=%.2fMbps target=%ukbps%s",
          s_loss_ema * 100.0, s_rtt_ema_ms, s_jitter_ema_ms,
          net.has_tx ? net.tx_mbps : 0.0, s_target_video_bps / 1000,
          severe ? " severe" : (congested ? " congested" : ""));
    }
  }

  if (!stats && !s_bw_stats_missing_logged) {
    s_bw_stats_missing_logged = TRUE;
    my_zlog_warn("webrtcpush: get-stats returned no data");
  }

  s_last_bitrate_mbps = s_bw_estimate_mbps;
  ramp_video_bitrate();

  if (s_datachannel && s_ice_media_ok)
    (void)datachannel_send_mbps(s_datachannel, s_bw_estimate_mbps);

  gst_promise_unref(promise);
  (void)app;
}

static gboolean bitrate_tick(gpointer user_data) {
  AppState *app = user_data;
  if (!app->webrtc || !app->pipeline)
    return G_SOURCE_CONTINUE;
  ramp_video_bitrate();
  GstPromise *p = gst_promise_new_with_change_func(on_get_stats, app, NULL);
  g_signal_emit_by_name(app->webrtc, "get-stats", NULL, p);
  return G_SOURCE_CONTINUE;
}

static void start_bitrate_timer(AppState *app) {
  if (s_bitrate_timer_id)
    return;
  s_bitrate_timer_id = g_timeout_add(WRTC_BITRATE_TICK_MS, bitrate_tick, app);
}

static void stop_bitrate_timer(void) {
  if (s_bitrate_timer_id) {
    g_source_remove(s_bitrate_timer_id);
    s_bitrate_timer_id = 0;
  }
}

static void ensure_datachannel(GstElement *webrtc) {
#if WEBRTCPUSH_ENABLE_DATACHANNEL
  if (s_datachannel || !webrtc)
    return;
  g_signal_emit_by_name(webrtc, "create-data-channel", "myDataChannel", NULL, &s_datachannel);
  if (s_datachannel) {
    g_signal_connect(s_datachannel, "on-open", G_CALLBACK(on_datachannel_open), NULL);
    g_print("DataChannel created: myDataChannel (bitrate to phone)\n");
  }
#else
  (void)webrtc;
#endif
}

static void send_sdp_offer(AppState *app, GstWebRTCSessionDescription *offer) {
  GstSDPMessage *sdp_msg = offer->sdp;
  gchar *sdp_text = gst_sdp_message_as_text(sdp_msg);
  if (!sdp_text || !app->member_id) {
    g_free(sdp_text);
    return;
  }

  if (!sdp_video_section_has_h264(sdp_text)) {
    g_printerr("error: local SDP offer has no H264; not sending\n");
    g_free(sdp_text);
    return;
  }

  /* 与 index.html signal.js 一致：{ memberId, description } */
  gchar *json = build_signaling_description_json(app->member_id, "offer", sdp_text);
  g_free(sdp_text);
  if (!json)
    return;

  if (app->verbose)
    g_print("[verbose] tx offer (signal.js format): %s\n", json);

  guint pending = s_ice_pending ? (guint)g_queue_get_length(s_ice_pending) : 0;
  ws_send_text(app, json);
  g_free(json);
  s_sdp_answer_sent = TRUE;
  g_print("SDP offer sent (index.html mode); flushing %u queued ICE\n", pending);
  flush_pending_ice(app);
  ws_signaling_service_burst(1);
  start_bitrate_timer(app);
}

typedef struct {
  AppState *app;
  GstWebRTCSessionDescription *offer;
} LocalOfferCtx;

static void on_set_local_offer_done(GstPromise *promise, gpointer user_data) {
  LocalOfferCtx *ctx = user_data;
  if (!promise_replied(promise)) {
    g_printerr("set-local-description (offer) failed\n");
    gst_promise_unref(promise);
    gst_webrtc_session_description_free(ctx->offer);
    g_free(ctx);
    s_create_offer_busy = FALSE;
    return;
  }
  g_print("set-local-description (offer) done\n");
  gst_promise_unref(promise);
  send_sdp_offer(ctx->app, ctx->offer);
  gst_webrtc_session_description_free(ctx->offer);
  g_free(ctx);
  s_create_offer_busy = FALSE;
}

static void on_offer_created(GstPromise *promise, gpointer user_data) {
  AppState *app = user_data;
  const GstStructure *reply = gst_promise_get_reply(promise);
  GstWebRTCSessionDescription *offer = NULL;

  if (!reply || !gst_structure_get(reply, "offer", GST_TYPE_WEBRTC_SESSION_DESCRIPTION, &offer, NULL)) {
    g_printerr("create-offer failed\n");
    gst_promise_unref(promise);
    s_create_offer_busy = FALSE;
    return;
  }

  offer = patch_session_description(offer, GST_WEBRTC_SDP_TYPE_OFFER, NULL);

  LocalOfferCtx *ctx = g_new(LocalOfferCtx, 1);
  ctx->app = app;
  ctx->offer = offer;
  GstPromise *ld = gst_promise_new_with_change_func(on_set_local_offer_done, ctx, NULL);
  g_signal_emit_by_name(app->webrtc, "set-local-description", offer, ld);
  gst_promise_unref(promise);
}

static gboolean idle_create_offer(gpointer user_data) {
  AppState *app = user_data;
  if (s_create_offer_busy || !app->call_enable || !app->webrtc)
    return G_SOURCE_REMOVE;
  if (app->remote_description_set || s_sdp_answer_sent)
    return G_SOURCE_REMOVE;

  try_link_payloader_to_webrtc(app);
  try_link_audio_to_webrtc(app);
  if (!app->payloader_linked) {
    g_printerr("Cannot create offer: video not linked\n");
    return G_SOURCE_REMOVE;
  }

  s_create_offer_busy = TRUE;
  ensure_datachannel(app->webrtc);
  attach_rtp_probe();
  attach_audio_rtp_probe();
  attach_enc_probe();

  if (app->pipeline)
    wait_pipeline_state_ms(app->pipeline, GST_STATE_PLAYING, "PLAYING before create-offer", 300);

  GstPromise *op = gst_promise_new_with_change_func(on_offer_created, app, NULL);
  g_signal_emit_by_name(app->webrtc, "create-offer", NULL, op);
  return G_SOURCE_REMOVE;
}

void gst_webrtc_pipeline_try_start_offer(AppState *app) {
  if (!app || !app->device_offerer || !app->call_enable || !app->member_id)
    return;
  if (app->remote_description_set || s_create_offer_busy)
    return;
  /* GStreamer webrtcbin 不能在同一实例上重复 create-offer（会 DC SDP 断言崩溃） */
  if (app->pipeline && s_sdp_answer_sent)
    return;

  if (!app->pipeline) {
    if (!gst_webrtc_pipeline_build(app))
      return;
    try_link_payloader_to_webrtc(app);
    try_link_audio_to_webrtc(app);
    if (!app->payloader_linked) {
      g_printerr("Cannot offer: video not linked\n");
      return;
    }
  }

  g_idle_add(idle_create_offer, app);
}

gboolean gst_webrtc_pipeline_offer_in_flight(const AppState *app) {
  return app && app->device_offerer && s_sdp_answer_sent && !app->remote_description_set;
}

gboolean gst_webrtc_pipeline_negotiation_busy(const AppState *app) {
  return app && app->pipeline && !s_sdp_answer_sent;
}

static gboolean leave_no_offer_timeout(gpointer user_data) {
  AppState *app = user_data;

  s_leave_no_offer_timer_id = 0;
  if (!s_phone_left_pending || !app || !app->pipeline)
    return G_SOURCE_REMOVE;
  s_phone_left_pending = FALSE;
  app->remote_description_set = FALSE;
  s_remote_desc_applied = FALSE;
  if (app->payloader_linked || s_sdp_answer_sent) {
    my_zlog_info("webrtcpush: no phone offer after leave, teardown media (device only in room)");
    gst_webrtc_pipeline_teardown(app);
    gst_webrtc_pipeline_prewarm(app);
  } else {
    my_zlog_info("webrtcpush: no phone offer after leave, ready for next join");
  }
  gst_webrtc_pipeline_debug_log_state(app, "leave-timeout");
  return G_SOURCE_REMOVE;
}

static void cancel_room_alone_teardown(void) {
  if (s_room_alone_teardown_id) {
    g_source_remove(s_room_alone_teardown_id);
    s_room_alone_teardown_id = 0;
  }
}

static gboolean room_alone_teardown_fire(gpointer user_data) {
  AppState *app = user_data;

  s_room_alone_teardown_id = 0;
  if (!app || !app->pipeline || app->device_offerer)
    return G_SOURCE_REMOVE;
  if (app->call_enable || s_phone_left_pending || s_driver_switch_pending || s_renegotiating)
    return G_SOURCE_REMOVE;
  if (!app->payloader_linked && !s_sdp_answer_sent)
    return G_SOURCE_REMOVE;
  my_zlog_info("webrtcpush: room device-only (peers=1), teardown media session");
  gst_webrtc_pipeline_teardown(app);
  gst_webrtc_pipeline_prewarm(app);
  return G_SOURCE_REMOVE;
}

static void schedule_room_alone_teardown(AppState *app) {
  cancel_room_alone_teardown();
  if (!app || !app->pipeline)
    return;
  s_room_alone_teardown_id =
      g_timeout_add_seconds(WRTC_ROOM_ALONE_TEARDOWN_SEC, room_alone_teardown_fire, app);
}

void gst_webrtc_pipeline_on_peer_left(AppState *app) {
  if (!app || !app->pipeline)
    return;
  cancel_room_alone_teardown();
  s_phone_left_pending = TRUE;
  if (s_leave_no_offer_timer_id) {
    g_source_remove(s_leave_no_offer_timer_id);
    s_leave_no_offer_timer_id = 0;
  }
  s_leave_no_offer_timer_id =
      g_timeout_add_seconds(WRTC_LEAVE_NO_OFFER_SEC, leave_no_offer_timeout, app);
  my_zlog_info("webrtcpush: peer left, await phone offer (answer even if same ice-ufrag)");
}

void gst_webrtc_pipeline_on_driver_switch(AppState *app) {
  if (!app || !app->pipeline)
    return;
  s_driver_switch_pending = TRUE;
  s_remote_desc_applied = FALSE;
  my_zlog_info("webrtcpush: driver switch (MQTT), next phone offer must be answered");
}

gboolean gst_webrtc_pipeline_is_phone_reconnect_offer(const AppState *app, const gchar *sdp) {
  if (!app || !app->pipeline || !app->payloader_linked || !sdp || !s_sdp_answer_sent)
    return FALSE;
  return remote_ice_ufrag_changed(sdp);
}

gboolean gst_webrtc_pipeline_should_ignore_phone_offer(const AppState *app, const gchar *sdp) {
  if (!app || !app->pipeline)
    return FALSE;
  if (!s_sdp_answer_sent && app->remote_description_set)
    return TRUE;
  /* leave / 换驾驶员后安卓可能重发同 ufrag Offer，必须应答 */
  if (s_phone_left_pending || s_driver_switch_pending)
    return FALSE;
  /*
   * client.js / 安卓 App：开始驾驶会 leave + 新 PeerConnection + 新 ice-ufrag Offer。
   * 新 ufrag 必须应答 Answer；仅忽略「同一会话连发」的重复 Offer。
   */
  if (sdp && !remote_ice_ufrag_changed(sdp) && media_session_healthy(app))
    return TRUE;
  if (sdp && !remote_ice_ufrag_changed(sdp) && s_renegotiating)
    return TRUE;
  return FALSE;
}

gboolean gst_webrtc_pipeline_can_renegotiate(const AppState *app, const gchar *sdp) {
  if (!app || !app->pipeline || !app->webrtc || !app->payloader_linked)
    return FALSE;
  if (!app->remote_description_set && !s_sdp_answer_sent)
    return FALSE;
  /* leave / 换驾驶员后必须重协商（即使 ice-ufrag 未变） */
  if (s_phone_left_pending || s_driver_switch_pending)
    return TRUE;
  /* 安卓重连：新 ice-ufrag → 在原 webrtcbin 上重协商出 Answer，禁止拆管道 */
  if (sdp && remote_ice_ufrag_changed(sdp))
    return TRUE;
  if (s_media_degraded)
    return TRUE;
  return FALSE;
}

void gst_webrtc_pipeline_on_rx_offer_sdp(AppState *app, const gchar *sdp) {
  gchar *incoming_ufrag;

  cancel_room_alone_teardown();
  if (!app || !sdp || !app->pipeline || !s_sdp_answer_sent)
    return;
  incoming_ufrag = sdp_extract_ice_ufrag(sdp);
  if (!incoming_ufrag)
    return;
  if (s_last_remote_ice_ufrag && strcmp(incoming_ufrag, s_last_remote_ice_ufrag) != 0) {
    guint queued = s_remote_ice_pending ? (guint)g_queue_get_length(s_remote_ice_pending) : 0;
    my_zlog_info(
        "webrtcpush: Android reconnect offer ufrag=%s (was %s), queue remote ICE (%u pending)",
        incoming_ufrag, s_last_remote_ice_ufrag, queued);
    g_free(s_pending_offer_ice_ufrag);
    s_pending_offer_ice_ufrag = incoming_ufrag;
    incoming_ufrag = NULL;
    s_remote_desc_applied = FALSE;
  }
  g_free(incoming_ufrag);
}

gboolean gst_webrtc_pipeline_survives_signaling_drop(const AppState *app) {
  if (!app || !app->pipeline)
    return FALSE;
  if (media_session_healthy(app))
    return TRUE;
  return s_sdp_answer_sent || s_ice_media_ok || app->remote_description_set;
}

void gst_webrtc_pipeline_on_peer_count(AppState *app, gint peer_count) {
  if (!app)
    return;
  app->call_enable = (peer_count >= 2);

  /* 手机发 Offer、设备应答（安卓 App / client.html） */
  if (!app->device_offerer) {
    if (peer_count >= 2) {
      cancel_room_alone_teardown();
      if (!app->pipeline)
        gst_webrtc_pipeline_prewarm(app);
    } else if (peer_count < 2 && app->pipeline && (app->payloader_linked || s_sdp_answer_sent)) {
      if (s_phone_left_pending || s_driver_switch_pending) {
        my_zlog_info("webrtcpush: peers=1, phone reconnect pending — keep session briefly");
      } else {
        my_zlog_info("webrtcpush: peers=1 (device only), schedule media teardown in %us",
            WRTC_ROOM_ALONE_TEARDOWN_SEC);
        schedule_room_alone_teardown(app);
      }
    } else if (peer_count >= 1 && !app->pipeline) {
      gst_webrtc_pipeline_prewarm(app);
    }
    return;
  }

  if (!app->call_enable) {
    if (app->pipeline && gst_webrtc_pipeline_survives_signaling_drop(app)) {
      g_print("peers < 2: keep pipeline (negotiation/media active)\n");
      return;
    }
    if (app->pipeline) {
      g_print("peers < 2: tearing down pipeline (wait for phone to rejoin)\n");
      gst_webrtc_pipeline_teardown(app);
    }
    return;
  }

  /* index.html signal.js：peers>=2 时设备主动 createOffer（每秒重试同 startOffer） */
  g_print("peers >= 2: device sending offer (index.html / signal.js mode)\n");
  gst_webrtc_pipeline_start_offer_retry(app);
  gst_webrtc_pipeline_try_start_offer(app);
}

typedef struct {
  AppState *app;
} RemoteAnswerCtx;

static void on_set_remote_answer_done(GstPromise *promise, gpointer user_data) {
  RemoteAnswerCtx *ctx = user_data;
  AppState *app = ctx->app;
  if (!promise_replied(promise)) {
    g_printerr("set-remote-description (answer) failed\n");
    gst_promise_unref(promise);
    g_free(ctx);
    return;
  }
  g_print("set-remote-description (answer) done\n");
  gst_promise_unref(promise);
  g_free(ctx);

  app->remote_description_set = TRUE;
  s_remote_desc_applied = TRUE;
  flush_pending_remote_ice(app);

  if (s_offer_retry_id) {
    g_source_remove(s_offer_retry_id);
    s_offer_retry_id = 0;
  }
}

void gst_webrtc_handle_remote_answer(AppState *app, const gchar *sdp_text) {
  if (!app->webrtc || !sdp_text || app->remote_description_set)
    return;

  store_offer_mids(sdp_text);

  GstSDPMessage *sdp = NULL;
  if (gst_sdp_message_new(&sdp) != GST_SDP_OK)
    return;
  if (gst_sdp_message_parse_buffer((guint8 *)sdp_text, strlen(sdp_text), sdp) != GST_SDP_OK) {
    gst_sdp_message_free(sdp);
    return;
  }

  GstWebRTCSessionDescription *answer =
      gst_webrtc_session_description_new(GST_WEBRTC_SDP_TYPE_ANSWER, sdp);

  RemoteAnswerCtx *ctx = g_new(RemoteAnswerCtx, 1);
  ctx->app = app;
  GstPromise *promise = gst_promise_new_with_change_func(on_set_remote_answer_done, ctx, NULL);
  g_signal_emit_by_name(app->webrtc, "set-remote-description", answer, promise);
  gst_webrtc_session_description_free(answer);
}

static gboolean offer_retry_tick(gpointer user_data) {
  AppState *app = user_data;
  if (!app->device_offerer || !app->call_enable || app->remote_description_set)
    return G_SOURCE_CONTINUE;
  if (s_create_offer_busy)
    return G_SOURCE_CONTINUE;
  /* 已发过 Offer 则等 Answer 或 peer 离开触发 teardown；勿在同一 webrtcbin 再 create-offer */
  if (app->pipeline && s_sdp_answer_sent)
    return G_SOURCE_CONTINUE;
  gst_webrtc_pipeline_try_start_offer(app);
  return G_SOURCE_CONTINUE;
}

void gst_webrtc_pipeline_start_offer_retry(AppState *app) {
  if (!app->device_offerer || s_offer_retry_id)
    return;
  s_offer_retry_id = g_timeout_add_seconds(1, offer_retry_tick, app);
}

void gst_webrtc_pipeline_stop_offer_retry(void) {
  if (s_offer_retry_id) {
    g_source_remove(s_offer_retry_id);
    s_offer_retry_id = 0;
  }
}

static void on_ice_candidate(GstElement *webrtc, guint mline, gchar *candidate, gpointer user_data) {
  AppState *app = user_data;
  (void)webrtc;
  if (!candidate || !app || !app->webrtc)
    return;

  /* 与 signal.js onicecandidate 一致：candidate 为 RTCIceCandidate 对象 */
  JsonBuilder *b = json_builder_new();
  json_builder_begin_object(b);
  if (app->member_id) {
    json_builder_set_member_name(b, "memberId");
    json_builder_add_string_value(b, app->member_id);
  }
  json_builder_set_member_name(b, "candidate");
  json_builder_begin_object(b);
  json_builder_set_member_name(b, "candidate");
  json_builder_add_string_value(b, candidate);
  json_builder_set_member_name(b, "sdpMLineIndex");
  json_builder_add_int_value(b, (gint)mline);
  json_builder_set_member_name(b, "sdpMid");
  json_builder_add_string_value(b, mid_for_mline(mline));
  json_builder_end_object(b);
  json_builder_end_object(b);

  JsonGenerator *gen = json_generator_new();
  JsonNode *root = json_builder_get_root(b);
  json_generator_set_root(gen, root);
  json_node_unref(root);
  g_object_unref(b);
  gchar *json = json_generator_to_data(gen, NULL);
  g_object_unref(gen);

  ensure_ice_queue();
  if (!s_sdp_answer_sent) {
    g_queue_push_tail(s_ice_pending, json);
    g_print("ICE queued (until SDP answer is sent), mline=%u\n", mline);
  } else {
    g_print("ICE tx mline=%u mid=%s typ=%s\n", mline, mid_for_mline(mline), ice_candidate_typ(candidate));
    ws_send_text(app, json);
    g_free(json);
    ws_signaling_service_burst(1);
  }
}

static GstElement *make_h264_encoder(AppState *app, gboolean use_test, gboolean *use_mpp_enc) {
  GstElement *enc = NULL;

  if (app->prefer_mpp && !use_test) {
    enc = gst_element_factory_make("mpph264enc", "enc");
    if (enc) {
      *use_mpp_enc = TRUE;
      g_object_set(enc,
          "gop", WRTC_VIDEO_GOP,
          "rc-mode", 1,       /* CBR：限制运动/IDR 瞬时尖峰，减少网络排队 */
          "bps", WRTC_VIDEO_START_BITRATE_BPS,
          "bps-min", (guint)(WRTC_VIDEO_START_BITRATE_BPS * 0.80),
          "bps-max", (guint)(WRTC_VIDEO_START_BITRATE_BPS * 1.08),
          "header-mode", 1,
          "profile", 66,
          "level", 31,
          "qos", TRUE,
          "zero-copy-pkt", TRUE,
          NULL);
      g_print("Using mpph264enc (Rockchip MPP, %dfps GOP=%d, start=%u kbps, max=%u kbps, CBR adaptive)\n",
          WRTC_VIDEO_FPS, WRTC_VIDEO_GOP,
          WRTC_VIDEO_START_BITRATE_BPS / 1000,
          WRTC_VIDEO_MAX_BITRATE_BPS / 1000);
      return enc;
    }
    g_print("mpph264enc not available\n");
  }

  enc = gst_element_factory_make("x264enc", "enc");
  if (enc) {
    g_object_set(enc,
        "tune", "zerolatency",
        "speed-preset", "ultrafast",
        "bitrate", WRTC_VIDEO_DEFAULT_BITRATE_BPS / 1000,
        "key-int-max", WRTC_VIDEO_GOP,
        "byte-stream", TRUE,
        "profile", "baseline",
        NULL);
    g_print("Using x264enc (software H.264 baseline)\n");
    return enc;
  }
  g_print("x264enc not available\n");

  enc = gst_element_factory_make("openh264enc", "enc");
  if (enc) {
    g_object_set(enc,
        "bitrate", WRTC_VIDEO_DEFAULT_BITRATE_BPS,
        "max-bitrate", WRTC_VIDEO_MAX_BITRATE_BPS,
        "gop-size", WRTC_VIDEO_GOP,
        "complexity", 0,
        "rate-control", 1,
        "enable-frame-skip", TRUE,
        NULL);
    g_print("Using openh264enc (software H.264)\n");
    g_printerr(
        "warning: openh264enc on this board is unreliable; remove --no-mpp to use mpph264enc\n");
    return enc;
  }

  return NULL;
}

gboolean gst_webrtc_pipeline_build(AppState *app) {
  GstElement *pipe, *webrtc, *src, *caps_jpeg = NULL, *jpegdec = NULL, *scale = NULL, *vrate = NULL,
      *conv, *caps1, *enc, *parse, *pay, *q;
  GstElement *asrc = NULL, *avalve = NULL, *aq0 = NULL, *aconv = NULL, *aresample = NULL, *acapsf = NULL, *opus = NULL,
      *apay = NULL, *aq = NULL;
  gboolean use_mjpg = FALSE;

  ensure_ice_queue();
  reset_ice_out_state();
  reset_video_bitrate_state();

  /* webrtcbin 内部依赖 libnice；未装 gstreamer1.0-nice 会出现 _have_nice_elements 失败 */
  {
    GstPlugin *nice_pl = gst_registry_find_plugin(gst_registry_get(), "nice");
    if (!nice_pl) {
      g_printerr(
          "Missing GStreamer libnice plugin (webrtcbin needs ICE).\n"
          "  Debian/Ubuntu: sudo apt install gstreamer1.0-nice\n"
          "Verify: ls /usr/lib/*/gstreamer-1.0/libgstnice.so\n"
          "  or: gst-inspect-1.0 webrtcbin | head -5\n");
      return FALSE;
    }
    gst_object_unref(nice_pl);
  }

  pipe = gst_pipeline_new("webrtc-send-pipe");
  webrtc = gst_element_factory_make("webrtcbin", "webrtc");
  if (!pipe || !webrtc) {
    g_printerr("Failed to create pipeline or webrtcbin\n");
    return FALSE;
  }

  /* ICE 与 jywy.yd-ss.com 网页端一致 */
  setup_webrtc_ice(webrtc);

  gchar *auto_v4l = NULL;
  const gchar *vdev = app->video_device;
  gboolean use_test = (vdev && g_strcmp0(vdev, "test") == 0);
  gboolean use_auto = (!vdev || g_strcmp0(vdev, "auto") == 0);

  if (use_auto && !use_test) {
    auto_v4l = pick_v4l2_capture_device();
    if (auto_v4l) {
      g_print("Auto-selected V4L2 capture: %s\n", auto_v4l);
      vdev = auto_v4l;
    } else {
      g_print("No V4L2 capture device; using videotestsrc\n");
      use_test = TRUE;
    }
  }

  if (use_test) {
    src = gst_element_factory_make("videotestsrc", "vsrc");
    g_object_set(src, "is-live", TRUE, NULL);
  } else {
    src = gst_element_factory_make("v4l2src", "vsrc");
    g_object_set(src, "device", vdev, NULL);
    configure_v4l2_low_latency(src);
    /* USB 摄像头多为 MJPG，直接走 jpegdec 避免 not-negotiated */
    caps_jpeg = gst_element_factory_make("capsfilter", "caps_jpeg");
    jpegdec = gst_element_factory_make("jpegdec", "jpegdec");
    scale = gst_element_factory_make("videoscale", "scale");
    if (caps_jpeg && jpegdec && scale) {
      /* 摄像头 30fps 采集，videorate 丢帧到 24fps 再进硬编（同 signal.js ideal:24） */
      GstCaps *jc = gst_caps_from_string(
          "image/jpeg,width=" G_STRINGIFY(WRTC_VIDEO_WIDTH) ",height=" G_STRINGIFY(WRTC_VIDEO_HEIGHT)
          ",framerate=30/1");
      g_object_set(caps_jpeg, "caps", jc, NULL);
      gst_caps_unref(jc);
      g_object_set(scale, "add-borders", FALSE, "method", 2, NULL);
      use_mjpg = TRUE;
      g_print("V4L2 MJPG %dx%d capture@30 -> videorate@%dfps -> mpph264enc\n",
          WRTC_VIDEO_WIDTH, WRTC_VIDEO_HEIGHT, WRTC_VIDEO_FPS);
    }
  }
  g_free(auto_v4l);

  conv = gst_element_factory_make("videoconvert", "conv");
  vrate = gst_element_factory_make("videorate", "vrate");
  caps1 = gst_element_factory_make("capsfilter", "caps1");
  if (vrate)
    g_object_set(vrate, "max-rate", WRTC_VIDEO_FPS, "drop-only", TRUE, NULL);

  enc = NULL;
  gboolean use_mpp_enc = FALSE;
  enc = make_h264_encoder(app, use_test, &use_mpp_enc);
  if (!enc) {
    g_printerr(
        "No H.264 encoder available (tried mpph264enc, x264enc, openh264enc).\n"
        "  Install one of: gstreamer1.0-plugins-ugly (x264), gstreamer1.0-plugins-bad (openh264)\n");
    gst_object_unref(pipe);
    return FALSE;
  }

  {
    gchar *cap_str = NULL;
    if (use_mpp_enc) {
      cap_str = g_strdup_printf("video/x-raw,format=NV12,width=%d,height=%d,framerate=%d/1",
          WRTC_VIDEO_WIDTH, WRTC_VIDEO_HEIGHT, WRTC_VIDEO_FPS);
    } else if (use_test) {
      cap_str = g_strdup_printf("video/x-raw,format=I420,width=640,height=480,framerate=%d/1",
          WRTC_VIDEO_FPS);
    } else {
      cap_str = g_strdup_printf("video/x-raw,format=I420,width=%d,height=%d,framerate=%d/1",
          WRTC_VIDEO_WIDTH, WRTC_VIDEO_HEIGHT, WRTC_VIDEO_FPS);
    }
    GstCaps *caps = gst_caps_from_string(cap_str);
    g_object_set(caps1, "caps", caps, NULL);
    gst_caps_unref(caps);
    g_print("Video caps: %s (videorate drop-only)\n", cap_str);
    g_free(cap_str);
  }

  if (!vrate) {
    g_printerr("videorate element missing\n");
    gst_object_unref(pipe);
    return FALSE;
  }

  parse = gst_element_factory_make("h264parse", "parse");
  if (parse)
    g_object_set(parse, "config-interval", -1, NULL);

  pay = gst_element_factory_make("rtph264pay", "pay");
  g_object_set(pay, "mtu", 1200, "config-interval", -1, "aggregate-mode", 1, NULL);

  q = gst_element_factory_make("queue", "vq");
  configure_leaky_queue(q);

  s_h264_parse = parse;

  /* 只在编码前丢原始帧；编码后丢 P 帧会破坏 H.264 参考链并卡到下一个 IDR。 */
  if (use_mjpg) {
    gst_bin_add_many(GST_BIN(pipe), webrtc, src, caps_jpeg, jpegdec, scale, conv, vrate, caps1, enc,
        parse, pay, q, NULL);
    if (!gst_element_link_many(src, caps_jpeg, jpegdec, scale, conv, vrate, caps1, q, enc, parse,
            pay, NULL)) {
      g_printerr("Failed to link MJPG video chain\n");
      gst_object_unref(pipe);
      return FALSE;
    }
  } else {
    gst_bin_add_many(GST_BIN(pipe), webrtc, src, conv, vrate, caps1, enc, parse, pay, q, NULL);
    if (!gst_element_link_many(src, conv, vrate, caps1, q, enc, parse, pay, NULL)) {
      g_printerr("Failed to link video chain\n");
      gst_object_unref(pipe);
      return FALSE;
    }
  }

  s_pay = pay;
  /* s_queue 是历史命名；它表示连接 webrtcbin 的 RTP 源元素。 */
  s_queue = pay;
  s_video_enc = enc;
  s_aqueue = NULL;
  s_audio_valve = NULL;
  app->audio_linked = FALSE;

  if (app->enable_audio) {
    gboolean hold_alsa = FALSE;
    gboolean hold_capture = FALSE;
    asrc = make_audio_capture_source(app, &hold_capture);
    if (app->audio_alsa_device && !g_strcmp0(app->audio_alsa_device, "test"))
      avalve = gst_element_factory_make("identity", "avalve");
    else {
      avalve = gst_element_factory_make("valve", "avalve");
      if (avalve)
        g_object_set(avalve, "drop", TRUE, NULL);
    }
    hold_alsa = hold_capture;
    aq0 = gst_element_factory_make("queue", "aq0");
    if (aq0)
      configure_audio_queue(aq0);
    aconv = gst_element_factory_make("audioconvert", "aconv");
    aresample = gst_element_factory_make("audioresample", "aresample");
    acapsf = gst_element_factory_make("capsfilter", "acaps");
    if (acapsf) {
      /* 与手机 Offer opus/48000/2 一致 */
      GstCaps *ac = gst_caps_from_string("audio/x-raw,rate=48000,channels=2");
      g_object_set(acapsf, "caps", ac, NULL);
      gst_caps_unref(ac);
    }
    opus = gst_element_factory_make("opusenc", "opus");
    if (opus)
      g_object_set(opus, "bitrate", 64000, "frame-size", 20, "inband-fec", TRUE, NULL);
    apay = gst_element_factory_make("rtpopuspay", "apay");
    aq = gst_element_factory_make("queue", "aq");
    if (aq)
      configure_audio_queue(aq);

    if (!asrc || !avalve || !aq0 || !aconv || !aresample || !acapsf || !opus || !apay || !aq) {
      g_printerr("Audio chain missing elements (need opusenc/rtpopuspay/alsasrc). Use --no-audio.\n");
      gst_object_unref(pipe);
      return FALSE;
    }

    gst_bin_add_many(GST_BIN(pipe), asrc, avalve, aq0, aconv, aresample, acapsf, opus, apay, aq, NULL);
    if (!gst_element_link_many(asrc, avalve, aq0, aconv, aresample, acapsf, opus, apay, aq, NULL)) {
      g_printerr("Failed to link audio chain\n");
      gst_object_unref(pipe);
      return FALSE;
    }
    s_aqueue = aq;
    s_opus_pay = apay;
    s_audio_valve = avalve;
    s_hold_alsasrc = hold_alsa;
    g_print("Mic uplink: Opus 48kHz stereo (capture only, no playback); valve until ICE up\n");
    if (hold_alsa)
      hold_alsasrc_capture(pipe);
  }

  g_signal_connect(webrtc, "on-ice-candidate", G_CALLBACK(on_ice_candidate), app);
  g_signal_connect(webrtc, "notify::ice-gathering-state", G_CALLBACK(on_ice_gathering_state), app);
  g_signal_connect(webrtc, "notify::ice-connection-state", G_CALLBACK(on_ice_connection_state), app);
  g_signal_connect(webrtc, "on-new-transceiver", G_CALLBACK(on_new_transceiver), app);
  g_signal_connect(webrtc, "on-data-channel", G_CALLBACK(on_incoming_data_channel), app);

  app->pipeline = pipe;
  app->webrtc = webrtc;

  {
    GstBus *bus = gst_pipeline_get_bus(GST_PIPELINE(pipe));
    gst_bus_add_watch(bus, bus_call, app);
    gst_object_unref(bus);
  }

  gst_pipeline_set_latency(GST_PIPELINE(pipe), 0);

  gst_element_set_state(pipe, GST_STATE_READY);
  /* 先进入 PAUSED，便于 webrtcbin 在 set-remote 后创建 request sink pad */
  gst_element_set_state(pipe, GST_STATE_PAUSED);
  return TRUE;
}

void gst_webrtc_pipeline_prewarm(AppState *app) {
  if (!app || app->pipeline || app->device_offerer)
    return;
  if (!gst_webrtc_pipeline_build(app))
    return;
  my_zlog_info("webrtcpush: pipeline prewarmed (await phone offer)");
}

void gst_webrtc_pipeline_teardown(AppState *app) {
  stop_bitrate_timer();
  detach_rtp_probe();
  detach_audio_rtp_probe();
  detach_enc_probe();
  detach_sprop_probe();
  s_hold_alsasrc = FALSE;
  s_media_degraded = FALSE;
  s_answer_sent_at_us = 0;
  s_last_video_rtp_us = 0;
  s_renegotiating = FALSE;
  s_post_renegotiate_keyframe = FALSE;
  s_phone_left_pending = FALSE;
  s_driver_switch_pending = FALSE;
  if (s_leave_no_offer_timer_id) {
    g_source_remove(s_leave_no_offer_timer_id);
    s_leave_no_offer_timer_id = 0;
  }
  cancel_room_alone_teardown();
  g_free(s_last_remote_ice_ufrag);
  s_last_remote_ice_ufrag = NULL;
  g_free(s_pending_offer_ice_ufrag);
  s_pending_offer_ice_ufrag = NULL;
  ice_out_clear_pending_only();
  reset_ice_out_state();
  s_video_enc = NULL;
  s_h264_parse = NULL;
  if (s_datachannel) {
    gst_object_unref(s_datachannel);
    s_datachannel = NULL;
  }
  if (s_phone_datachannel) {
    gst_object_unref(s_phone_datachannel);
    s_phone_datachannel = NULL;
  }
  reset_video_bitrate_state();
  s_ice_playing_started = FALSE;
  s_ice_media_ok = FALSE;
  s_create_offer_busy = FALSE;
  if (app->pipeline) {
    gst_element_set_state(app->pipeline, GST_STATE_NULL);
    gst_object_unref(app->pipeline);
    app->pipeline = NULL;
    app->webrtc = NULL;
  }
  s_pay = NULL;
  s_opus_pay = NULL;
  s_queue = NULL;
  s_aqueue = NULL;
  s_audio_valve = NULL;
  app->payloader_linked = FALSE;
  app->audio_linked = FALSE;
  app->remote_description_set = FALSE;
  /* call_enable 由 num peers 消息维护，勿在 teardown 清零 */
}

void gst_webrtc_pipeline_link_media(AppState *app) {
  if (!app)
    return;
  try_link_payloader_to_webrtc(app);
  try_link_audio_to_webrtc(app);
}

void gst_webrtc_pipeline_apply_remote_offer(AppState *app, const gchar *sdp_text) {
  if (!app || !sdp_text)
    return;
  if (app->pipeline)
    wait_pipeline_state_ms(app->pipeline, GST_STATE_PAUSED, "PAUSED before set-remote", 200);
  log_sdp_video_summary("remote SDP offer", sdp_text);
  log_sdp_audio_summary("remote SDP offer", sdp_text);
  gst_webrtc_handle_remote_offer(app, sdp_text);
}

void gst_webrtc_pipeline_renegotiate_remote_offer(AppState *app, const gchar *sdp_text) {
  if (!app || !sdp_text || !app->pipeline || !app->webrtc)
    return;
  if (remote_ice_ufrag_changed(sdp_text))
    my_zlog_info("webrtcpush: Android reconnect (new ice-ufrag) → renegotiate, keep pipeline");
  else
    my_zlog_info("webrtcpush: media recover → renegotiate (no pipeline rebuild)");
  s_renegotiating = TRUE;
  s_post_renegotiate_keyframe = TRUE;
  cancel_room_alone_teardown();
  s_sdp_answer_sent = FALSE;
  app->remote_description_set = FALSE;
  s_media_degraded = FALSE;
  s_remote_desc_applied = FALSE;
  ice_out_clear_pending_only();
  log_sdp_video_summary("remote SDP re-offer", sdp_text);
  gst_webrtc_handle_remote_offer(app, sdp_text);
}

void gst_webrtc_handle_remote_offer(AppState *app, const gchar *sdp_text) {
  if (!app->webrtc || !sdp_text)
    return;
  if (app->remote_description_set) {
    g_print("Ignoring second offer (already answered)\n");
    return;
  }
  if (!sdp_video_section_has_h264(sdp_text)) {
    g_printerr(
        "Remote offer has no H264 in video m-section; device is configured H264-only, skip this offer.\n");
    return;
  }
  remember_remote_ice_ufrag(sdp_text);
  store_offer_mids(sdp_text);
  s_offer_h264_pt = sdp_find_h264_payload_type(sdp_text);
  s_offer_h264_rtx_pt = sdp_find_rtx_payload_type(sdp_text, s_offer_h264_pt);
  s_offer_opus_pt = sdp_find_opus_payload_type(sdp_text);
  g_print("Offer H264 payload type: %d\n", s_offer_h264_pt);
  g_print("Offer H264 RTX payload type: %d (apt=%d)\n", s_offer_h264_rtx_pt,
      s_offer_h264_pt);
  g_print("Offer Opus payload type: %d\n", s_offer_opus_pt);
  apply_h264_payloader_pt();
  apply_opus_payloader_pt();

  GstSDPMessage *sdp = NULL;
  if (gst_sdp_message_new(&sdp) != GST_SDP_OK) {
    g_printerr("gst_sdp_message_new failed\n");
    return;
  }
  if (gst_sdp_message_parse_buffer((guint8 *)sdp_text, strlen(sdp_text), sdp) != GST_SDP_OK) {
    g_printerr("Failed to parse SDP offer\n");
    gst_sdp_message_free(sdp);
    return;
  }

  GstWebRTCSessionDescription *offer =
      gst_webrtc_session_description_new(GST_WEBRTC_SDP_TYPE_OFFER, sdp);

  GstPromise *promise = gst_promise_new_with_change_func(on_set_remote_done, app, NULL);
  g_signal_emit_by_name(app->webrtc, "set-remote-description", offer, promise);
  gst_webrtc_session_description_free(offer);
}

void gst_webrtc_handle_remote_ice(AppState *app, JsonObject *candidate_obj) {
  if (!app || !candidate_obj)
    return;

  const gchar *cand = json_object_get_string_member(candidate_obj, "candidate");
  if (!cand)
    return;

  gint midx = 0;
  if (json_object_has_member(candidate_obj, "sdpMLineIndex"))
    midx = (gint)json_object_get_int_member(candidate_obj, "sdpMLineIndex");

  if (!app->webrtc || !s_remote_desc_applied) {
    ensure_ice_queue();
    json_object_ref(candidate_obj);
    g_queue_push_tail(s_remote_ice_pending, candidate_obj);
    g_print("ICE rx queued mline=%d typ=%s (%s)\n", midx, ice_candidate_typ(cand),
        !app->webrtc ? "pipeline starting" : "before local answer");
    return;
  }

  {
    const gchar *expect_ufrag =
        s_pending_offer_ice_ufrag ? s_pending_offer_ice_ufrag : s_last_remote_ice_ufrag;
    if (!ice_candidate_matches_ufrag(cand, expect_ufrag)) {
      ensure_ice_queue();
      json_object_ref(candidate_obj);
      g_queue_push_tail(s_remote_ice_pending, candidate_obj);
      g_print("ICE rx queued mline=%d typ=%s (ufrag mismatch, expect %s)\n", midx,
          ice_candidate_typ(cand), expect_ufrag ? expect_ufrag : "?");
      return;
    }
  }

  g_signal_emit_by_name(app->webrtc, "add-ice-candidate", (guint)midx, cand);
  g_print("ICE rx added mline=%d typ=%s\n", midx, ice_candidate_typ(cand));
}
