// CCGUI 公网中继服务（rendezvous relay + 子域名 HTTP 隧道）
//
// 两条互不干扰的能力：
//   1) 手机镜像撮合（原有）：按房间号把「CCGUI 桌面(host)」与「手机(guest)」撮合、双向透明转发。
//   2) 子域名 HTTP 隧道（新增）：CCGUI 桌面把本机本地 HTTP 端口以 <前缀>.<基域> 形式映射到公网，
//      供临时预览（ngrok/frp 式反向隧道）。桌面主动拨出，本机不开入站端口。
//
// 域名不写死：基域来自环境变量 BASE_DOMAIN（= 本中继对外服务的主域名，须与 CCGUI 客户端填的中继域名 host 一致）。
//   未设 BASE_DOMAIN → 隧道关闭，中继退化为纯镜像（兼容旧部署）。
// 注册需密钥：隧道注册需环境变量 TUNNEL_SECRET，客户端注册时携带、不匹配即拒（防陌生人借本域名匿名挂内容）。
//   注意：这是「注册侧」闸门；「访问侧」不做鉴权——子域名一旦激活，任何人凭 URL 即可访问（用完即关）。
//
// 本方案不做端到端加密，转发内容为明文，中继(VPS 运营者)可见。TLS 交给前置 Caddy（自动 Let's Encrypt，
//   通配子域名走 on-demand 按需签发），本服务只跑明文 http，监听 127.0.0.1。
//
// 运行：npm i ws && BASE_DOMAIN=你的域名 TUNNEL_SECRET=一串随机码 node server.js  （PORT 默认 8080）

const http = require('http')
const fs = require('fs')
const path = require('path')
const { WebSocketServer } = require('ws')

const PORT = process.env.PORT || 8080
// 基域（apex）：本中继对外服务的主域名。子域名隧道即 <前缀>.<BASE_DOMAIN>。未设则隧道禁用。
const BASE_DOMAIN = (process.env.BASE_DOMAIN || '').toLowerCase().trim()
// 隧道注册密钥（运营级闸门）。未设则隧道禁用（fail-closed）。
const TUNNEL_SECRET = process.env.TUNNEL_SECRET || ''
// 桌面隧道控制连接的固定路径（走 apex 主域名的 WebSocket 升级）
const TUNNEL_PATH = '/__ccgui_tunnel__'
const HTML_PATH = path.join(__dirname, 'mobile.html')

// 单连接并发流上限（防 FD 耗尽拖垮整机中继）
const MAX_STREAMS_PER_CONN = 256
// 出站背压高水位：桌面 WS 积压超过此值就暂停从浏览器读取
const HWM = 8 * 1024 * 1024
// on-demand 证书签发防刷（叠加在「仅活隧道放行」之上，对应 Let's Encrypt 限额）
const ASK_PER_DOMAIN_HOUR = parseInt(process.env.ASK_PER_DOMAIN_HOUR || '5', 10) // 单域名每小时最多批准签发次数（对应 LE 每小时每主机名 5 次失败验证）
const ASK_NEW_PER_WEEK = parseInt(process.env.ASK_NEW_PER_WEEK || '40', 10) // 7 天内最多批准的不同新域名数（LE 每周 50/注册域，留余量）
// 保留前缀：避免与中继自身路由/常见基础设施冲突
const RESERVED_SUBS = new Set(['www', 'api', 'healthz', 'internal', 'relay', 'mail', 'ftp', 'ns', 'ns1', 'ns2'])
// 合法 DNS 标签（小写字母/数字/连字符，首尾为字母数字，≤63）
const SUB_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/

const TUNNEL_ENABLED = !!(BASE_DOMAIN && TUNNEL_SECRET)

// 数据面帧类型（二进制帧：[type:1][streamId:u32 大端][payload]）
const F = {
  // 中继 → 桌面
  REQ_HEAD: 0x01, // payload=JSON{sub,method,url,headers,remoteIp}
  REQ_BODY: 0x02, // payload=请求体字节块
  REQ_END: 0x03, // 无 payload，请求体结束
  REQ_ABORT: 0x04, // 无 payload，浏览器中止
  U_OPEN: 0x20, // payload=JSON{sub,method,url,httpVersion,headers,remoteIp}，WebSocket/Upgrade 开始
  PAUSE: 0x30, // 无 payload，响应方向背压：桌面应暂停该流本地读取
  RESUME: 0x31, // 无 payload，恢复
  // 桌面 → 中继
  RES_HEAD: 0x10, // payload=JSON{status,statusText,headers}（桌面已去 hop-by-hop、已解 chunked）
  RES_BODY: 0x11, // payload=响应体字节块
  RES_END: 0x12, // 无 payload，响应结束
  RES_ABORT: 0x13, // 无 payload，本地站点错误
  // 双向（升级连接的不透明字节）
  U_DATA: 0x22, // payload=原始字节（含 head 缓冲与后续帧）
  U_CLOSE: 0x23, // 无 payload，升级连接关闭
}

// 逐跳头（hop-by-hop）：反代时不应透传，由各段自行决定
const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade'])

function loadHtml() {
  try {
    return fs.readFileSync(HTML_PATH)
  } catch {
    return Buffer.from('<!doctype html><meta charset="utf-8"><h1>CCGUI Relay</h1><p>把 mobile.html 放到中继目录即可。</p>')
  }
}

function hostOf(req) {
  return String(req.headers.host || '').split(':')[0].toLowerCase()
}
function isLoopbackHost(h) {
  return h === '' || h === 'localhost' || h === '127.0.0.1' || h.startsWith('127.') || h === '::1'
}
function isApex(h) {
  return !!BASE_DOMAIN && h === BASE_DOMAIN
}
function validSub(s) {
  return SUB_RE.test(s) && !RESERVED_SUBS.has(s)
}
// 从 Host 提取隧道前缀；非本基域下的单级子域名返回 null
function subOf(h) {
  if (!BASE_DOMAIN || h === BASE_DOMAIN) return null
  const suffix = '.' + BASE_DOMAIN
  if (!h.endsWith(suffix)) return null
  const p = h.slice(0, h.length - suffix.length)
  return validSub(p) ? p : null
}
// 客户端真实 IP：优先 Caddy 注入的 X-Forwarded-For（经反代 remoteAddress 恒为 127.0.0.1）
function clientIp(req) {
  const xff = req.headers['x-forwarded-for']
  if (xff) return String(xff).split(',')[0].trim()
  return (req.socket && req.socket.remoteAddress) || ''
}

// 构造二进制数据帧
function frame(type, id, payload) {
  const h = Buffer.allocUnsafe(5)
  h[0] = type
  h.writeUInt32BE(id >>> 0, 1)
  return payload && payload.length ? Buffer.concat([h, payload]) : h
}
function send(ws, type, id, payload) {
  if (ws && ws.readyState === 1) ws.send(frame(type, id, payload), { binary: true })
}
function jbuf(o) {
  return Buffer.from(JSON.stringify(o))
}
function writeResHead(res, h) {
  const out = {}
  const src = (h && h.headers) || {}
  for (const k in src) {
    if (!HOP.has(k.toLowerCase())) out[k] = src[k]
  }
  try {
    res.writeHead(h.status || 200, h.statusText || undefined, out)
  } catch {
    try {
      res.writeHead(h.status || 502)
    } catch {}
  }
}

// ---------- on-demand 证书签发防刷计数 ----------
const HOUR_MS = 3600 * 1000
const WEEK_MS = 7 * 24 * 3600 * 1000
const askByDomain = new Map() // domain -> 近 1 小时的批准时间戳数组
const askWeek = new Map() // domain -> 最近批准时间戳（近 7 天，用于统计不同新域名数）

// 是否批准该域名此刻签发证书（在「活隧道」判定通过后再调）
function askAllowed(domain) {
  const now = Date.now()
  // 单域名每小时上限：反复被问=签发在失败打转，退避以免打光 LE 失败验证额度
  const arr = (askByDomain.get(domain) || []).filter((t) => now - t < HOUR_MS)
  if (arr.length >= ASK_PER_DOMAIN_HOUR) {
    askByDomain.set(domain, arr)
    return false
  }
  // 全局 7 天不同新域名上限：护住 LE 每周 50/注册域
  for (const [d, t] of askWeek) {
    if (now - t >= WEEK_MS) askWeek.delete(d)
  }
  if (!askWeek.has(domain) && askWeek.size >= ASK_NEW_PER_WEEK) return false
  arr.push(now)
  askByDomain.set(domain, arr)
  askWeek.set(domain, now)
  return true
}

// ---------- 隧道注册表：前缀(subdomain) -> 桌面隧道连接(conn) ----------
// conn = { ws, subs:Set<前缀>, streams:Map<id,{kind,res|socket}>, paused:Set<可暂停源>, _id, room, token }
const subdomains = new Map()

// 该前缀是否有「在线且已启用」的隧道
function activeConn(prefix) {
  const c = subdomains.get(prefix)
  if (!c || c.ws.readyState !== 1 || !c.subs.has(prefix)) return null
  return c
}

// 桌面上报全量「前缀 -> 启用」快照，据此增删本 conn 拥有的前缀
function applyRegister(conn, msg) {
  // 注册闸门：必须配置且密钥匹配（fail-closed）
  if (!TUNNEL_ENABLED) {
    try {
      conn.ws.send(JSON.stringify({ type: 'error', message: '中继未启用隧道（需配置 BASE_DOMAIN 与 TUNNEL_SECRET）' }))
    } catch {}
    conn.ws.close()
    return
  }
  if (String(msg.secret || '') !== TUNNEL_SECRET) {
    try {
      conn.ws.send(JSON.stringify({ type: 'error', message: '隧道注册密钥不匹配' }))
    } catch {}
    conn.ws.close()
    return
  }
  conn.room = msg.room || conn.room
  conn.token = msg.token || conn.token
  const want = new Set()
  for (const t of msg.tunnels || []) {
    const sub = String((t && t.sub) || '').toLowerCase()
    if (t && t.enabled && validSub(sub)) want.add(sub)
  }
  const active = []
  const conflicts = []
  // 移除本 conn 不再需要的前缀
  for (const s of Array.from(conn.subs)) {
    if (!want.has(s)) {
      if (subdomains.get(s) === conn) subdomains.delete(s)
      conn.subs.delete(s)
    }
  }
  // 认领（先到先得；被别的在线连接占用则进 conflicts，让桌面提示换前缀）
  for (const s of want) {
    const o = subdomains.get(s)
    if (o && o !== conn && o.ws.readyState === 1) {
      conflicts.push(s)
      continue
    }
    subdomains.set(s, conn)
    conn.subs.add(s)
    active.push(s)
  }
  try {
    conn.ws.send(JSON.stringify({ type: 'tunnel-registered', active, conflicts }))
  } catch {}
}

function cleanupConn(conn) {
  for (const s of conn.subs) {
    if (subdomains.get(s) === conn) subdomains.delete(s)
  }
  conn.subs.clear()
  for (const [, st] of conn.streams) {
    try {
      if (st.kind === 'http') st.res.destroy()
      else st.socket.destroy()
    } catch {}
  }
  conn.streams.clear()
  conn.paused.clear()
}

// ---------- HTTP 服务：tls-check / apex / 子域名隧道 ----------
const server = http.createServer((req, res) => {
  const host = hostOf(req)
  const u = new URL(req.url, 'http://x')

  // on-demand TLS 询问端点：仅接受 loopback Host（Caddy ask 从 127.0.0.1 发起），
  // 防止公网经子域名访问该路径以枚举活隧道 / 遮蔽应用同名路径。
  if (u.pathname === '/internal/tls-check' && isLoopbackHost(host)) {
    const d = (u.searchParams.get('domain') || '').toLowerCase()
    let ok = false
    if (d === BASE_DOMAIN && BASE_DOMAIN) ok = true
    else if (BASE_DOMAIN && d.endsWith('.' + BASE_DOMAIN)) {
      const p = d.slice(0, d.length - BASE_DOMAIN.length - 1)
      // 活隧道判定通过后，再过签发防刷计数闸
      ok = validSub(p) && !!activeConn(p) && askAllowed(d)
    }
    res.writeHead(ok ? 200 : 403, { 'Content-Type': 'text/plain' })
    res.end(ok ? 'ok' : 'no')
    return
  }

  // 未配置基域：隧道禁用，一切按 apex 处理（兼容旧中继）
  if (!BASE_DOMAIN || isApex(host)) {
    if (u.pathname === '/healthz') {
      res.writeHead(200, { 'Content-Type': 'text/plain' })
      res.end('ok')
      return
    }
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
    res.end(loadHtml())
    return
  }

  // 子域名 → 隧道 HTTP（流式）
  const prefix = subOf(host)
  if (!prefix) {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
    res.end('not found')
    return
  }
  const conn = activeConn(prefix)
  if (!conn) {
    res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
    res.end('隧道离线或该子域名未启用')
    return
  }
  if (conn.streams.size >= MAX_STREAMS_PER_CONN) {
    res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' })
    res.end('隧道繁忙')
    return
  }
  const id = conn.nextId()
  conn.streams.set(id, { kind: 'http', res })
  send(conn.ws, F.REQ_HEAD, id, jbuf({ sub: prefix, method: req.method, url: req.url, headers: req.headers, remoteIp: clientIp(req) }))
  req.on('data', (c) => {
    send(conn.ws, F.REQ_BODY, id, c)
    if (conn.ws.bufferedAmount > HWM) {
      req.pause()
      conn.paused.add(req)
    }
  })
  req.on('end', () => send(conn.ws, F.REQ_END, id))
  req.on('aborted', () => {
    if (conn.streams.delete(id)) send(conn.ws, F.REQ_ABORT, id)
  })
  res.on('close', () => {
    if (conn.streams.delete(id)) send(conn.ws, F.REQ_ABORT, id)
  })
  // 响应方向背压：res 可写后通知桌面恢复本地读取
  res.on('drain', () => send(conn.ws, F.RESUME, id))
})

// ---------- 两个 noServer WSS：手机撮合 + 桌面隧道控制 ----------
const mirrorWss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024 })
const tunnelWss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024 })

server.on('upgrade', (req, socket, head) => {
  const host = hostOf(req)
  const pathname = (req.url || '/').split('?')[0]

  // apex（或未配基域）：按路径分流到隧道控制连接 / 手机镜像
  if (!BASE_DOMAIN || isApex(host)) {
    if (pathname === TUNNEL_PATH) {
      tunnelWss.handleUpgrade(req, socket, head, (ws) => tunnelWss.emit('connection', ws, req))
    } else {
      mirrorWss.handleUpgrade(req, socket, head, (ws) => mirrorWss.emit('connection', ws, req))
    }
    return
  }

  // 子域名 WebSocket/Upgrade → 不透明字节管道（含 dev server 的 HMR）
  const prefix = subOf(host)
  const conn = prefix ? activeConn(prefix) : null
  if (!conn) {
    try {
      socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n')
    } catch {}
    socket.destroy()
    return
  }
  if (conn.streams.size >= MAX_STREAMS_PER_CONN) {
    try {
      socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n')
    } catch {}
    socket.destroy()
    return
  }
  const id = conn.nextId()
  conn.streams.set(id, { kind: 'raw', socket })
  send(conn.ws, F.U_OPEN, id, jbuf({ sub: prefix, method: req.method, url: req.url, httpVersion: req.httpVersion, headers: req.headers, remoteIp: clientIp(req) }))
  if (head && head.length) send(conn.ws, F.U_DATA, id, head)
  socket.on('data', (c) => {
    send(conn.ws, F.U_DATA, id, c)
    if (conn.ws.bufferedAmount > HWM) {
      socket.pause()
      conn.paused.add(socket)
    }
  })
  // 桌面→浏览器方向背压：浏览器 socket 可写后通知桌面恢复
  socket.on('drain', () => send(conn.ws, F.RESUME, id))
  socket.on('close', () => {
    if (conn.streams.delete(id)) send(conn.ws, F.U_CLOSE, id)
  })
  socket.on('error', () => {})
})

// ---------- 桌面隧道控制连接 ----------
tunnelWss.on('connection', (ws) => {
  const conn = { ws, subs: new Set(), streams: new Map(), paused: new Set(), _id: 1, room: null, token: null }
  conn.nextId = () => {
    const n = conn._id++
    if (conn._id > 0xffffffff) conn._id = 1
    return n
  }
  // 出站 WS 积压排空后，恢复此前被暂停的浏览器读取源
  if (ws._socket) {
    ws._socket.on('drain', () => {
      for (const src of conn.paused) {
        try {
          src.resume()
        } catch {}
      }
      conn.paused.clear()
    })
  }
  ws.on('message', (data, isBinary) => {
    if (!isBinary) {
      // 控制帧（JSON 文本）
      let m
      try {
        m = JSON.parse(data.toString())
      } catch {
        return
      }
      if (m.type === 'tunnel-register') applyRegister(conn, m)
      return
    }
    // 数据帧：[type:1][streamId:u32 大端][payload]
    if (data.length < 5) return
    const type = data[0]
    const id = data.readUInt32BE(1)
    const payload = data.subarray(5)
    const st = conn.streams.get(id)
    if (!st) return
    switch (type) {
      case F.RES_HEAD:
        if (st.kind === 'http') {
          try {
            writeResHead(st.res, JSON.parse(payload.toString()))
          } catch {}
        }
        break
      case F.RES_BODY:
        if (st.kind === 'http') {
          if (!st.res.write(payload)) send(ws, F.PAUSE, id)
        }
        break
      case F.RES_END:
        if (st.kind === 'http') {
          try {
            st.res.end()
          } catch {}
        }
        conn.streams.delete(id)
        break
      case F.RES_ABORT:
        if (st.kind === 'http') {
          try {
            st.res.destroy()
          } catch {}
        }
        conn.streams.delete(id)
        break
      case F.U_DATA:
        if (st.kind === 'raw') {
          try {
            if (!st.socket.write(payload)) send(ws, F.PAUSE, id)
          } catch {}
        }
        break
      case F.U_CLOSE:
        if (st.kind === 'raw') {
          try {
            st.socket.end()
          } catch {}
        }
        conn.streams.delete(id)
        break
    }
  })
  ws.on('close', () => cleanupConn(conn))
  ws.on('error', () => {})
})

// ---------- 手机镜像撮合（原逻辑，从原 wss 迁到 mirrorWss，未改动语义）----------
// roomId -> { host?: ws, guest?: ws }
const rooms = new Map()
function peerOf(room, role) {
  const r = rooms.get(room)
  if (!r) return null
  return role === 'host' ? r.guest : r.host
}
mirrorWss.on('connection', (ws) => {
  let room = null
  let role = null
  ws.on('message', (data, isBinary) => {
    if (!room) {
      let msg
      try {
        msg = JSON.parse(data.toString())
      } catch {
        ws.close()
        return
      }
      if (msg.type !== 'register' || !msg.room || (msg.role !== 'host' && msg.role !== 'guest')) {
        ws.close()
        return
      }
      room = String(msg.room).slice(0, 128)
      role = msg.role
      const token = String(msg.token || '')
      const r = rooms.get(room) || {}
      if (r[role] && r[role] !== ws) {
        try {
          r[role].close()
        } catch {}
      }
      r[role] = ws
      r[role + 'Token'] = token
      rooms.set(room, r)
      ws.send(JSON.stringify({ type: 'registered', role }))
      const peer = peerOf(room, role)
      if (peer && peer.readyState === 1) {
        if (r.hostToken !== undefined && r.hostToken === r.guestToken) {
          peer.send(JSON.stringify({ type: 'peer-online' }))
          ws.send(JSON.stringify({ type: 'peer-online' }))
        } else if (role === 'host') {
          try {
            peer.send(JSON.stringify({ type: 'error', message: '配对令牌不匹配' }))
            peer.close()
          } catch {}
          delete r.guest
          delete r.guestToken
        } else {
          ws.send(JSON.stringify({ type: 'error', message: '配对令牌不匹配' }))
          ws.close()
          return
        }
      }
      return
    }
    const r = rooms.get(room)
    if (r && r.hostToken !== undefined && r.hostToken === r.guestToken) {
      const peer = peerOf(room, role)
      if (peer && peer.readyState === 1) peer.send(data, { binary: isBinary })
    }
  })
  ws.on('close', () => {
    if (!room) return
    const r = rooms.get(room)
    if (!r || r[role] !== ws) return
    delete r[role]
    delete r[role + 'Token']
    const peer = role === 'host' ? r.guest : r.host
    if (peer && peer.readyState === 1) peer.send(JSON.stringify({ type: 'peer-offline' }))
    if (!r.host && !r.guest) rooms.delete(room)
  })
  ws.on('error', () => {})
})

server.listen(PORT, '127.0.0.1', () => {
  console.log(`CCGUI relay listening on 127.0.0.1:${PORT}`)
  console.log(`  基域(BASE_DOMAIN): ${BASE_DOMAIN || '(未设置，隧道禁用)'}`)
  console.log(`  隧道功能: ${TUNNEL_ENABLED ? '已启用' : '未启用（需同时设置 BASE_DOMAIN 与 TUNNEL_SECRET）'}`)
})
