<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMeshStore } from '@/stores/mesh'
import { useTransportStore } from '@/stores/transport'
import type { MeshMessage, MeshPeer, SessionStatus } from '@/stores/mesh'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import MeshMap from '@/components/MeshMap.vue'
import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue'
import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue'
import MeshDevicePanel from '@/views/mesh/MeshDevicePanel.vue'
import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue'
import { rpcClient } from '@/api/rpc-client'
import { wsClient } from '@/api/websocket'
import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression'
import '@/views/mesh/mesh-styles.css'

const mesh = useMeshStore()
const transport = useTransportStore()
const route = useRoute()

// Responsive layout breakpoints
const isWideDesktop = ref(window.innerWidth >= 1536)
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
const isMobile = ref(window.innerWidth < 1280)

function handleResize() {
  isWideDesktop.value = window.innerWidth >= 1536
  isVeryWideDesktop.value = window.innerWidth >= 2560 && window.innerHeight >= 1200
  isMobile.value = window.innerWidth < 1280
}

// Active chat: either a peer or a channel
const activeChatPeer = ref<MeshPeer | null>(null)
const activeChatChannel = ref<{ index: number; name: string } | null>(null)
const messageText = ref('')
const sendError = ref('')
const broadcasting = ref(false)
const configuring = ref(false)
const connectingDevice = ref<string | null>(null)
const chatScrollEl = ref<HTMLElement | null>(null)
const mobileShowChat = ref(false)
// Device status panel starts collapsed on mobile (expandable via its header).
const deviceExpanded = ref(false)
let pollInterval: ReturnType<typeof setInterval> | null = null
let wsUnsub: (() => void) | null = null

// The Public channel (always available on Meshcore)
// "Public" maps to meshcore slot 1 — the configured "archipelago" channel
// in mesh-config.json. Slot 0 is the firmware default Public, which uses
// the universal meshcore key and only works between devices sharing keys
// + RF region. Slot 1 is set by archipelago first-boot to a hash derived
// from the channel_name, so all archipelago nodes are guaranteed on the
// same channel regardless of region.
const publicChannel = { index: 0, name: 'Public' }

// Channel contact_id convention: matches backend u32::MAX - channel_index
function channelContactId(channelIndex: number): number {
  return 4294967295 - channelIndex // u32::MAX - index
}

// Archipelago Channel — Tor-based messaging to all federated/peered nodes
const archChannelActive = ref(false)
const archMessages = ref<Array<{ from_pubkey: string; from_name?: string; message: string; timestamp: string; direction?: string }>>([])
const archUnread = ref(0)
let archPollInterval: ReturnType<typeof setInterval> | null = null
// Federation node name cache: pubkey -> node name (legacy, kept for archMessages display)
const fedNodeNames = ref<Record<string, string>>({})

// Federation node enrichment cache, keyed by DID. Used by mergedPeers to
// upgrade radio-discovered mesh peers with the canonical server name and
// nostr identity (npub) reported by federation.list-nodes.
interface FedNodeInfo {
  did: string
  name: string | null
  pubkey: string
  onion: string
  npub: string | null
}
const fedNodesByDid = ref<Map<string, FedNodeInfo>>(new Map())
// Our own onion / DID / mesh advert name — used to filter "self" out of
// the merged peer list. Without these filters every node sees itself as a
// duplicate row (federation lists carry a self-entry, and the meshcore
// radio occasionally surfaces its own outgoing advert as a peer).
const selfTorOnion = ref<string | null>(null)
const selfDid = ref<string | null>(null)

// User-set aliases for peers, keyed by whichever identifier is most stable
// for that peer (DID first, then mesh pubkey_hex, then federation pubkey).
// Loaded from `mesh.contacts-list` on mount and refreshed on every save so
// the rename propagates everywhere display_name is computed.
const contactAliases = ref<Map<string, string>>(new Map())
async function refreshContacts() {
  try {
    const res = await rpcClient.meshContactsList()
    const next = new Map<string, string>()
    for (const c of res.contacts) {
      if (c.alias && c.alias.trim()) next.set(c.pubkey, c.alias.trim())
    }
    contactAliases.value = next
  } catch { /* non-fatal */ }
}
function aliasFor(mp: { did: string | null; primary_pubkey_hex: string | null }): string | null {
  if (mp.did && contactAliases.value.has(mp.did)) return contactAliases.value.get(mp.did) ?? null
  if (mp.primary_pubkey_hex && contactAliases.value.has(mp.primary_pubkey_hex)) {
    return contactAliases.value.get(mp.primary_pubkey_hex) ?? null
  }
  return null
}
async function refreshFederationNodes() {
  try {
    const res = await rpcClient.federationListNodes()
    const next = new Map<string, FedNodeInfo>()
    for (const n of res.nodes) {
      next.set(n.did, {
        did: n.did,
        name: n.name ?? n.last_state?.node_name ?? null,
        pubkey: n.pubkey,
        onion: n.onion,
        npub: n.last_state?.nostr_npub ?? null,
      })
    }
    fedNodesByDid.value = next
  } catch { /* non-fatal */ }
}
async function refreshSelfOnion() {
  try {
    const res = await rpcClient.getTorAddress()
    selfTorOnion.value = (res.tor_address ?? '').replace(/\.onion\/?$/, '').replace(/^https?:\/\//, '')
  } catch { /* non-fatal */ }
}
async function refreshSelfDid() {
  try {
    const res = await rpcClient.getNodeDid()
    selfDid.value = res.did || null
  } catch { /* non-fatal */ }
}

async function openArchChannel() {
  activeChatPeer.value = null
  activeChatChannel.value = null
  archChannelActive.value = true
  archUnread.value = 0
  mobileShowChat.value = true
  // Load federation node names for resolving pubkeys to names
  try {
    const res = await rpcClient.federationListNodes()
    const names: Record<string, string> = {}
    for (const node of res.nodes) {
      if (node.pubkey) names[node.pubkey] = node.name || node.did.slice(0, 12) + '...'
    }
    fedNodeNames.value = names
  } catch { /* non-fatal */ }
  loadArchMessages()
  if (!archPollInterval) {
    archPollInterval = setInterval(loadArchMessages, 15000)
  }
}

async function loadArchMessages() {
  try {
    const res = await rpcClient.getReceivedMessages()
    const newMessages = res.messages || []
    // Track unread: count new received messages since last load
    if (archMessages.value.length > 0 && !archChannelActive.value) {
      const newReceived = newMessages.filter(
        m => m.direction !== 'sent' && m.from_pubkey !== 'me'
          && !archMessages.value.some(existing =>
            existing.from_pubkey === m.from_pubkey && existing.timestamp === m.timestamp
          )
      )
      archUnread.value += newReceived.length
    }
    archMessages.value = newMessages
  } catch { /* silent */ }
}

const sendingArch = ref(false)

async function sendArchMessage() {
  if (!messageText.value.trim()) return
  sendError.value = ''
  sendingArch.value = true
  try {
    const nodes = await rpcClient.federationListNodes()
    // Get our own onion address to skip sending to self
    let selfOnion: string | null = null
    try {
      const tor = await rpcClient.getTorAddress()
      selfOnion = tor.tor_address
    } catch { /* non-fatal */ }
    const msg = messageText.value.trim()
    const targets = nodes.nodes
      .map((node) => node.onion || node.did)
      // Skip sending to ourselves (would create a duplicate received message).
      .filter(
        (nodeOnion) =>
          !(selfOnion &&
            (nodeOnion === selfOnion ||
              nodeOnion === selfOnion.replace('.onion', '') ||
              selfOnion === nodeOnion + '.onion'))
      )
    // Send to all peers CONCURRENTLY so the spinner clears after the slowest
    // single delivery (one Tor round-trip) rather than the sum of all of them —
    // previously a slow or offline node kept the "sending" spinner up long after
    // the online peers had already received the message.
    const results = await Promise.allSettled(
      targets.map((nodeOnion) => rpcClient.sendMessageToPeer(nodeOnion, msg))
    )
    const sent = results.filter((r) => r.status === 'fulfilled').length
    try {
      await rpcClient.call({ method: 'node-store-sent', params: { message: msg } })
    } catch { /* non-fatal */ }
    messageText.value = ''
    if (sent === 0 && nodes.nodes.length <= 1) sendError.value = 'No other peers in federation — add nodes first'
    await loadArchMessages()
  } catch (e) {
    sendError.value = e instanceof Error ? e.message : 'Send failed'
  } finally {
    sendingArch.value = false
  }
}

const togglingOffGrid = ref(false)
const peerSessionInfo = ref<SessionStatus | null>(null)
const rotatingPrekeys = ref(false)
const outboxCount = ref(0)
async function handleRotatePrekeys() {
  if (rotatingPrekeys.value) return
  rotatingPrekeys.value = true
  try {
    await mesh.rotatePrekeys()
    if (activeChatPeer.value) {
      peerSessionInfo.value = await mesh.getSessionStatus(activeChatPeer.value.contact_id)
    }
  } catch (e) {
    sendError.value = e instanceof Error ? e.message : 'rotate failed'
  } finally {
    rotatingPrekeys.value = false
  }
}
async function refreshOutboxCount() {
  try {
    const resp = await mesh.getOutbox()
    outboxCount.value = resp?.count ?? 0
  } catch {
    outboxCount.value = 0
  }
}

async function clearAllMesh() {
  if (!window.confirm('Clear all mesh peers, messages, and chat history? This cannot be undone.')) return
  try {
    await rpcClient.call({ method: 'mesh.clear-all' })
    await mesh.refreshAll()
    activeChatPeer.value = null
  } catch (e) {
    console.error('Failed to clear mesh:', e)
  }
}

// Phase 4: Off-grid Bitcoin + Dead Man's Switch
const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device'>('chat')

// Tools tab for 3rd column on wide desktop and mobile below-chat
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device'>('bitcoin')

// Mobile: a single set of floating tabs drives the whole pane (Chat + tools).
// 'chat' shows the peers list / active conversation; the rest swap the pane to
// that tool. Selecting a tool leaves any open conversation.
const mobileTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device'>('chat')
function selectMobileTab(tab: 'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device') {
  mobileTab.value = tab
  if (tab !== 'chat') mobileShowChat.value = false
}

// Panel visibility computeds
const showChatPanel = computed(() =>
  activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
)
const showBitcoinPanel = computed(() => {
  if (isVeryWideDesktop.value) return true
  if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
  if (isMobile.value) return mobileTab.value === 'bitcoin'
  return activeTab.value === 'bitcoin'
})
const showDeadmanPanel = computed(() => {
  if (isVeryWideDesktop.value) return true
  if (isWideDesktop.value) return toolsTab.value === 'deadman'
  if (isMobile.value) return mobileTab.value === 'deadman'
  return activeTab.value === 'deadman'
})
const showAssistantPanel = computed(() => {
  if (isVeryWideDesktop.value) return true
  if (isWideDesktop.value) return toolsTab.value === 'assistant'
  if (isMobile.value) return mobileTab.value === 'assistant'
  return activeTab.value === 'assistant'
})
const showMapPanel = computed(() => {
  if (isVeryWideDesktop.value) return true
  if (isWideDesktop.value) return toolsTab.value === 'map'
  if (isMobile.value) return mobileTab.value === 'map'
  return activeTab.value === 'map'
})
const showDevicePanel = computed(() => {
  if (isVeryWideDesktop.value) return true
  if (isWideDesktop.value) return toolsTab.value === 'device'
  if (isMobile.value) return mobileTab.value === 'device'
  return activeTab.value === 'device'
})
// Mobile: tool pane shows whenever a non-chat tab is active.
const showMobileTools = computed(() => isMobile.value && mobileTab.value !== 'chat')
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)

// Fetch session status when active peer changes
watch(() => activeChatPeer.value, async (peer) => {
  if (peer) {
    try {
      peerSessionInfo.value = await mesh.getSessionStatus(peer.contact_id)
    } catch {
      peerSessionInfo.value = null
    }
    scheduleReadReceipt()
  } else {
    peerSessionInfo.value = null
  }
})

async function handleToggleOffGrid() {
  togglingOffGrid.value = true
  try {
    await transport.setMeshOnly(!transport.meshOnly)
  } finally { togglingOffGrid.value = false }
}

// Track the on-screen keyboard height (mobile) so the conversation pane + back
// button can sit just above it — fixed elements ignore the keyboard otherwise
// and the input ends up hidden behind it. Publishes --keyboard-inset on <html>.
function updateKeyboardInset() {
  const vv = window.visualViewport
  if (!vv) return
  const inset = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))
  document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`)
}

onMounted(async () => {
  window.addEventListener('resize', handleResize)
  document.addEventListener('click', handleDocClickForMenu)
  window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
  if (window.visualViewport) {
    window.visualViewport.addEventListener('resize', updateKeyboardInset)
    window.visualViewport.addEventListener('scroll', updateKeyboardInset)
    updateKeyboardInset()
  }
  loadPendingFromSession()
  await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
  refreshOutboxCount()
  // Deep-link from a message toast: open the sender's conversation if we can
  // match it; otherwise just land on the mesh page (graceful fallback).
  const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : ''
  if (targetPeer) {
    const match = mesh.peers.find(
      (p) => p.pubkey_hex === targetPeer || p.did === targetPeer
    )
    if (match) openChat(match)
  }
  // Start background polling for Archipelago (Tor) messages so unread count works
  loadArchMessages()
  if (!archPollInterval) {
    archPollInterval = setInterval(loadArchMessages, 15000)
  }
  pollInterval = setInterval(() => {
    mesh.fetchStatus()
    mesh.fetchPeers()
    mesh.fetchMessages()
    mesh.fetchDeadmanStatus()
    mesh.fetchBlockHeaders()
  }, 5000)

  // Instant peer updates (#48): the backend nudges the data-model revision when
  // it discovers/updates a mesh peer, so refetch peers on the WS push rather
  // than waiting for the next 5s poll tick. The poll above stays as a backstop
  // (e.g. for peers going offline, which isn't pushed).
  wsUnsub = wsClient.subscribe(() => {
    mesh.fetchPeers()
  })
})

onUnmounted(() => {
  window.removeEventListener('resize', handleResize)
  document.removeEventListener('click', handleDocClickForMenu)
  window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
  if (window.visualViewport) {
    window.visualViewport.removeEventListener('resize', updateKeyboardInset)
    window.visualViewport.removeEventListener('scroll', updateKeyboardInset)
  }
  document.documentElement.style.removeProperty('--keyboard-inset')
  if (pollInterval) clearInterval(pollInterval)
  if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
  if (wsUnsub) { wsUnsub(); wsUnsub = null }
})

// Active chat name for the header
const activeChatName = computed(() => {
  if (archChannelActive.value) return 'Archipelago'
  if (activeChatChannel.value) return activeChatChannel.value.name
  if (activeMergedPeer.value) return activeMergedPeer.value.display_name
  if (activeChatPeer.value) return activeChatPeer.value.advert_name
  return ''
})

const activeChatSub = computed(() => {
  if (archChannelActive.value) return 'All nodes over Tor'
  if (activeChatChannel.value) return 'Mesh radio'
  const merged = activeMergedPeer.value
  if (merged) {
    const parts: string[] = []
    if (merged.short_did) parts.push(merged.short_did)
    else if (merged.primary_pubkey_hex) parts.push(truncatePubkey(merged.primary_pubkey_hex))
    if (merged.npub) parts.push(`${merged.npub.slice(0, 12)}…${merged.npub.slice(-6)}`)
    return parts.join(' · ')
  }
  if (activeChatPeer.value) return truncatePubkey(activeChatPeer.value.pubkey_hex)
  return ''
})

const hasActiveChat = computed(() => !!activeChatPeer.value || !!activeChatChannel.value || archChannelActive.value)

// Messages filtered to the active chat
const chatMessages = computed(() => {
  if (archChannelActive.value) {
    return archMessages.value.map((m, i) => {
      const isSent = m.direction === 'sent' || m.from_pubkey === 'me'
      let peerName = 'Unknown'
      if (isSent) {
        peerName = 'You'
      } else if (m.from_name) {
        peerName = m.from_name
      } else if (fedNodeNames.value[m.from_pubkey]) {
        peerName = fedNodeNames.value[m.from_pubkey]!
      } else {
        peerName = m.from_pubkey.slice(0, 12) + '...'
      }
      const mm: MeshMessage = {
        id: i,
        peer_contact_id: -99,
        peer_name: peerName,
        direction: (isSent ? 'sent' : 'received') as 'sent' | 'received',
        plaintext: m.message,
        timestamp: m.timestamp,
        delivered: true,
        encrypted: false,
        message_type: undefined,
        typed_payload: undefined,
        sender_pubkey: null,
        sender_seq: null,
      }
      return mm
    })
  }
  // Hide control envelopes that aren't supposed to show as their own
  // bubbles: reactions (rendered as chips under the target), read receipts
  // (drive the ✓ ticks on outgoing bubbles), edits (mutate the target in
  // place), presence heartbeats, and any other auxiliary metadata. Without
  // this filter every receipt and edit appears as a stray bubble in the
  // chat history. Defense in depth — backend now also drops these on send.
  const HIDDEN_TYPES = new Set(['reaction', 'read_receipt', 'edit', 'presence', 'channel_invite', 'contact_card'])
  const hideReactions = (m: MeshMessage) => !m.message_type || !HIDDEN_TYPES.has(m.message_type)
  if (activeChatChannel.value) {
    const chanId = channelContactId(activeChatChannel.value.index)
    return mesh.messages.filter(m => m.peer_contact_id === chanId && hideReactions(m))
  }
  if (activeChatPeer.value) {
    // Pull from every underlying contact_id in the merged group so radio
    // and federation-routed messages from the same node land in one thread.
    const merged = activeMergedPeer.value
    const cids = merged ? new Set(merged.contact_ids) : new Set([activeChatPeer.value.contact_id])
    return mesh.messages
      .filter(m => cids.has(m.peer_contact_id) && hideReactions(m))
      .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
  }
  return []
})

// Fire a read receipt whenever a new received message for the active peer lands.
// Declared after chatMessages so the watch getter doesn't hit a TDZ on registration.
watch(
  () => chatMessages.value.length,
  () => { scheduleReadReceipt() },
)

function isArchyNode(peer: MeshPeer): boolean {
  return peer.advert_name.startsWith('Archy-')
}

/// Build a contact_id → latest-message-timestamp map once per render so
/// we can sort the chat list by recency (freshest thread at the top,
/// Telegram-style). Empty threads keep their lexicographic fallback.
const lastMessageAt = computed<Map<number, number>>(() => {
  const out = new Map<number, number>()
  for (const m of mesh.messages) {
    const ts = Date.parse(m.timestamp)
    if (Number.isNaN(ts)) continue
    const prev = out.get(m.peer_contact_id)
    if (prev === undefined || ts > prev) out.set(m.peer_contact_id, ts)
  }
  return out
})

const sortedPeers = computed(() => {
  return [...mesh.peers].sort((a, b) => {
    const aTs = lastMessageAt.value.get(a.contact_id) ?? 0
    const bTs = lastMessageAt.value.get(b.contact_id) ?? 0
    if (aTs !== bTs) return bTs - aTs
    const aArchy = isArchyNode(a) ? 0 : 1
    const bArchy = isArchyNode(b) ? 0 : 1
    if (aArchy !== bArchy) return aArchy - bArchy
    return a.advert_name.localeCompare(b.advert_name)
  })
})

// Telegram-style unification: a single archipelago node may be discovered
// twice — once as a LoRa contact (firmware-issued mesh pubkey, no DID) and
// again as a synthetic federation peer (archipelago pubkey + DID) created
// the first time it routes a typed message over Tor. Group them into one
// row so the user sees their attachments and radio chat in the same thread.
//
// Merge key resolution, in priority order:
//   1. peer.did (federation-injected peers always carry it)
//   2. federation node whose pubkey matches peer.pubkey_hex
//   3. federation node whose name (case-folded) matches peer.advert_name
//   4. fall back to the peer's own pubkey_hex / advert_name (no merge)
interface MergedPeer {
  key: string
  did: string | null
  display_name: string
  short_did: string | null
  npub: string | null
  contact_ids: number[]
  primary_contact_id: number
  primary_pubkey_hex: string | null
  primary_rssi: number | null
  primary_snr: number | null
  is_archy: boolean
  reachable: boolean
  // The original active-chat marker uses contact_id equality, so keep a
  // representative MeshPeer for the rest of the codepaths that still want
  // a single object (peer header rssi, prekey rotation, etc).
  primary: MeshPeer
}

// Extract the did:key suffix prefix that meshcore embeds in archipelago
// adverts. Meshcore firmware names archipelago nodes "Archy-{first 8 chars
// of the node's did:key z-suffix}", e.g. "Archy-z6Mkn9RY". This lets us
// link a LoRa-discovered radio peer (no DID, no archipelago pubkey) back
// to its federation entry by matching that 8-char prefix against any
// federation node whose DID starts the same way.
function archyAdvertDidPrefix(advertName: string): string | null {
  if (!advertName.startsWith('Archy-')) return null
  const suffix = advertName.slice(6)
  if (suffix.length < 6) return null
  return suffix
}

function fedDidKeySuffix(did: string): string | null {
  // did:key:z6Mk... → z6Mk...
  const idx = did.indexOf(':key:')
  return idx >= 0 ? did.slice(idx + 5) : null
}

function mergeKeyForPeer(peer: MeshPeer): { key: string; matchedFed: FedNodeInfo | null } {
  // Strongest signal: the verified archipelago identity key. The backend binds
  // it onto BOTH twins (federation peer natively, radio twin via
  // `bind_federation_twins`), so grouping by it collapses the cross-transport
  // duplicate regardless of advert name — this is what survives the Meshtastic
  // device rename, which broke the `Archy-z6Mk…` did-prefix match below. Prefer
  // the matching federation node's `did:` key so this stays consistent with the
  // federation-only placeholder pass (which dedups on `did:<did>`); fall back to
  // an `arch:` key only when no federation entry is known for the identity.
  if (peer.arch_pubkey_hex) {
    for (const fed of fedNodesByDid.value.values()) {
      if (fed.pubkey === peer.arch_pubkey_hex) return { key: `did:${fed.did}`, matchedFed: fed }
    }
    return { key: `arch:${peer.arch_pubkey_hex.toLowerCase()}`, matchedFed: null }
  }
  if (peer.did) return { key: `did:${peer.did}`, matchedFed: fedNodesByDid.value.get(peer.did) ?? null }
  // pubkey cross-ref: a federation node may share the archipelago pubkey
  // with this radio peer if it's the same physical node (rare today, since
  // mesh and federation use different ed25519 keys, but kept for robustness)
  if (peer.pubkey_hex) {
    for (const fed of fedNodesByDid.value.values()) {
      if (fed.pubkey === peer.pubkey_hex) return { key: `did:${fed.did}`, matchedFed: fed }
    }
  }
  // did:key prefix cross-ref: meshcore "Archy-z6MkXXXX" → federation
  // node whose did:key suffix starts with the same chars. This is the
  // hot path for archipelago nodes — radio peers carry no DID/pubkey
  // we can use, but the firmware bakes the prefix into the advert.
  const advertPrefix = archyAdvertDidPrefix(peer.advert_name)
  if (advertPrefix) {
    for (const fed of fedNodesByDid.value.values()) {
      const fedSuffix = fedDidKeySuffix(fed.did)
      if (fedSuffix && fedSuffix.startsWith(advertPrefix)) {
        return { key: `did:${fed.did}`, matchedFed: fed }
      }
    }
  }
  // name cross-ref: a federation node whose name (case-folded) matches
  // the LoRa advert name. Last-resort match for non-archipelago meshcore
  // devices configured to advertise their server name verbatim.
  const norm = peer.advert_name.trim().toLowerCase()
  if (norm) {
    for (const fed of fedNodesByDid.value.values()) {
      const fedName = (fed.name ?? '').trim().toLowerCase()
      if (fedName && fedName === norm) return { key: `did:${fed.did}`, matchedFed: fed }
    }
  }
  return { key: `mesh:${peer.pubkey_hex || peer.advert_name || peer.contact_id}`, matchedFed: null }
}

function shortDid(did: string): string {
  // did:archy:<hex64> → did:archy:abcd…wxyz. Other DID methods get truncated
  // generically.
  const idx = did.lastIndexOf(':')
  if (idx > 0 && did.length - idx > 14) {
    const prefix = did.slice(0, idx + 1)
    const id = did.slice(idx + 1)
    return `${prefix}${id.slice(0, 6)}…${id.slice(-4)}`
  }
  return did.length > 24 ? `${did.slice(0, 12)}…${did.slice(-6)}` : did
}

function isSelfRadioPeer(peer: MeshPeer): boolean {
  const selfName = mesh.status?.self_advert_name
  if (selfName && peer.advert_name === selfName) return true
  // Primary fallback: derive the expected advert prefix from our own DID
  // and match it against the radio peer's "Archy-XXXXXXXX" prefix. This
  // works even before mesh.status.self_advert_name is populated and even
  // when there's no federation self-entry to cross-reference.
  const advertPrefix = archyAdvertDidPrefix(peer.advert_name)
  if (advertPrefix && selfDid.value) {
    const ourSuffix = fedDidKeySuffix(selfDid.value)
    if (ourSuffix?.startsWith(advertPrefix)) return true
  }
  return false
}

const mergedPeers = computed<MergedPeer[]>(() => {
  const groups = new Map<string, MergedPeer>()
  for (const peer of sortedPeers.value) {
    if (isSelfRadioPeer(peer)) continue
    const { key, matchedFed } = mergeKeyForPeer(peer)
    const existing = groups.get(key)
    if (existing) {
      existing.contact_ids.push(peer.contact_id)
      // Prefer a federation-enriched display name even if the second peer
      // is the radio one — keeps the row stable across discovery order.
      if (matchedFed?.name && existing.display_name === existing.primary.advert_name) {
        existing.display_name = matchedFed.name
      }
      if (matchedFed?.npub && !existing.npub) existing.npub = matchedFed.npub
      if (matchedFed?.did && !existing.did) {
        existing.did = matchedFed.did
        existing.short_did = shortDid(matchedFed.did)
      }
    } else {
      const did = peer.did ?? matchedFed?.did ?? null
      const stub = { did, primary_pubkey_hex: peer.pubkey_hex }
      const alias = aliasFor(stub)
      groups.set(key, {
        key,
        did,
        display_name: alias || matchedFed?.name || peer.advert_name || `Node #${peer.contact_id}`,
        short_did: did ? shortDid(did) : null,
        npub: matchedFed?.npub ?? null,
        contact_ids: [peer.contact_id],
        primary_contact_id: peer.contact_id,
        primary_pubkey_hex: peer.pubkey_hex,
        primary_rssi: peer.rssi,
        primary_snr: peer.snr,
        is_archy: isArchyNode(peer) || !!matchedFed,
        reachable: peer.reachable ?? true,
        primary: peer,
      })
    }
  }
  // Surface every federation node as its own row even if no radio peer has
  // been matched against it yet. The user always sees the canonical server
  // names ("Arch Dev", "ArchISO") in the list, and clicking one starts a
  // chat that routes over Tor until a radio link is also discovered, at
  // which point the rows transparently merge under the same DID key.
  for (const fed of fedNodesByDid.value.values()) {
    // Skip our own federation self-entry — every node accidentally has one
    // because the federation list isn't filtered server-side. Match by
    // onion instead of DID so it works even when names are missing.
    if (selfTorOnion.value) {
      const ours = selfTorOnion.value.replace(/\.onion$/, '')
      const theirs = fed.onion.replace(/\.onion$/, '')
      if (ours === theirs) continue
    }
    const key = `did:${fed.did}`
    if (groups.has(key)) continue
    // Synthesise a placeholder MeshPeer so openChat() and the existing
    // rssi/avatar template paths don't need a separate code path for
    // "federation-only" rows. The contact_id MUST match the backend's
    // federation-synthetic id (high bit set, derived from the pubkey) so
    // `mesh.send` accepts it (it parses contact_id as u64 — a negative
    // placeholder fails with "Missing contact_id") and so federation-routed
    // messages stored under that id land in this thread.
    const synthCid = federationContactId(fed.pubkey)
    const placeholder: MeshPeer = {
      contact_id: synthCid,
      advert_name: fed.name || fed.did,
      pubkey_hex: fed.pubkey,
      did: fed.did,
      rssi: null,
      hops: null,
      last_heard: null,
    } as unknown as MeshPeer
    const alias = aliasFor({ did: fed.did, primary_pubkey_hex: fed.pubkey })
    groups.set(key, {
      key,
      did: fed.did,
      display_name: alias || fed.name || shortDid(fed.did),
      short_did: shortDid(fed.did),
      npub: fed.npub,
      contact_ids: [synthCid],
      primary_contact_id: synthCid,
      primary_pubkey_hex: fed.pubkey,
      primary_rssi: null,
      primary_snr: null,
      is_archy: true,
      reachable: true,
      primary: placeholder,
    })
  }
  return Array.from(groups.values())
})

// Contact search — filters the Peers list by name, DID, npub, or pubkey.
const peerSearch = ref('')
const displayedPeers = computed<MergedPeer[]>(() => {
  const q = peerSearch.value.trim().toLowerCase()
  if (!q) return mergedPeers.value
  return mergedPeers.value.filter((mp) =>
    mp.display_name.toLowerCase().includes(q) ||
    (mp.short_did?.toLowerCase().includes(q) ?? false) ||
    (mp.did?.toLowerCase().includes(q) ?? false) ||
    (mp.npub?.toLowerCase().includes(q) ?? false) ||
    (mp.primary_pubkey_hex?.toLowerCase().includes(q) ?? false),
  )
})

// Mirror of the backend's `federation_peer_contact_id` (mesh/mod.rs): take the
// first 4 bytes of the archipelago pubkey as a little-endian u32, clear the top
// bit, then set it as the federation marker. Producing the SAME id here means a
// federation-only row addresses the exact contact the backend routes/stores
// under, instead of an unsendable negative placeholder.
function federationContactId(pubkeyHex: string): number {
  const bytes = pubkeyHex.match(/../g)?.map(h => parseInt(h, 16)) ?? []
  if (bytes.length < 4) return 0x80000001
  const [b0, b1, b2, b3] = bytes as [number, number, number, number]
  const low = (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0
  return ((0x80000000 | (low & 0x7fffffff)) >>> 0)
}

// activeChatPeer is a single MeshPeer (the row the user clicked). To unify
// the chat we need the merged group it belongs to so chatMessages can pull
// from every underlying contact_id, not just the one that was clicked.
const activeMergedPeer = computed<MergedPeer | null>(() => {
  const peer = activeChatPeer.value
  if (!peer) return null
  return mergedPeers.value.find(mp => mp.contact_ids.includes(peer.contact_id)) ?? null
})

function mergedUnreadCount(mp: MergedPeer): number {
  let total = 0
  for (const cid of mp.contact_ids) total += mesh.unreadCounts[cid] || 0
  return total
}

// Live contact_id -> friendly display name, rebuilt from the current peer
// table. Received messages snapshot a `peer_name` at receive time, so a text
// that arrived before its sender's NodeInfo is stuck showing a synthetic
// "Meshtastic !xxxx" id forever. Resolving the name live here means the bubble
// always shows the current archy identity (e.g. "Arch Optiplex") the moment it
// is learned — in every view, without rewriting stored rows.
const nameByContactId = computed<Map<number, string>>(() => {
  const out = new Map<number, string>()
  for (const mp of mergedPeers.value) {
    for (const cid of mp.contact_ids) out.set(cid, mp.display_name)
  }
  return out
})

// A generic, non-identifying placeholder the backend stamps before the real
// sender is known. We never want these to win over a live archy name.
function isPlaceholderName(name: string | null | undefined): boolean {
  if (!name) return true
  return /^Meshtastic !?[0-9a-f]{1,8}$/i.test(name)
    || /^Channel \d+$/.test(name)
    || /^Node #/.test(name)
    || name === 'dm-via-channel'
    || name === 'Unknown'
}

// Sender label shown above a RECEIVED bubble. Only meaningful in multi-sender
// views (mesh channels + the Archipelago channel) where the header alone can't
// attribute each message; 1:1 DM threads already name the peer in the header.
// Prefers the live archy identity over the snapshotted name.
function senderLabelFor(msg: MeshMessage): string | null {
  if (msg.direction !== 'received') return null
  if (!activeChatChannel.value && !archChannelActive.value) return null
  const live = nameByContactId.value.get(msg.peer_contact_id)
  if (live && !isPlaceholderName(live)) return live
  // The sender name snapshotted on the row. For a stock public-channel device a
  // synthetic "Meshtastic !xxxx" id IS the best identity we have, so keep it —
  // only suppress the genuinely contentless group/placeholder labels.
  const snap = msg.peer_name
  if (snap && !/^Channel \d+$/.test(snap) && snap !== 'dm-via-channel' && snap !== 'Unknown') {
    return snap
  }
  return live || 'Unknown sender'
}

// Inline contact rename in the chat header. The pencil button toggles an
// input bound to renameDraft; commit fires mesh.contacts-save keyed by
// DID (or pubkey hex as fallback) so the alias propagates everywhere
// `aliasFor` is consulted in the merged peer list.
const renamingActive = ref(false)
const renameDraft = ref('')
const renameInputEl = ref<HTMLInputElement | null>(null)
function startRename() {
  const mp = activeMergedPeer.value
  if (!mp) return
  renameDraft.value = mp.display_name
  renamingActive.value = true
  nextTick(() => renameInputEl.value?.focus())
}
function cancelRename() {
  renamingActive.value = false
  renameDraft.value = ''
}
async function commitRename() {
  if (!renamingActive.value) return
  const mp = activeMergedPeer.value
  if (!mp) { cancelRename(); return }
  const next = renameDraft.value.trim()
  renamingActive.value = false
  // Empty string → clear the alias and fall back to derived name.
  const key = mp.did || mp.primary_pubkey_hex
  if (!key) return
  // Optimistic local update so the header changes immediately.
  if (next) contactAliases.value.set(key, next)
  else contactAliases.value.delete(key)
  try {
    await rpcClient.meshContactsSave(key, next || null)
    await refreshContacts()
  } catch (e) {
    sendError.value = e instanceof Error ? e.message : 'Rename failed'
  }
}

function openChat(peer: MeshPeer) {
  activeChatPeer.value = peer
  activeChatChannel.value = null
  archChannelActive.value = false
  sendError.value = ''
  messageText.value = ''
  activeTab.value = 'chat'
  mobileShowChat.value = true
  mesh.markChatRead(peer.contact_id)
  nextTick(() => scrollChatToBottom())
}

function openChannelChat(channel: { index: number; name: string }) {
  activeChatChannel.value = channel
  activeChatPeer.value = null
  archChannelActive.value = false
  sendError.value = ''
  messageText.value = ''
  activeTab.value = 'chat'
  mobileShowChat.value = true
  mesh.markChatRead(channelContactId(channel.index))
  nextTick(() => scrollChatToBottom())
}

function closeChat() {
  activeChatPeer.value = null
  activeChatChannel.value = null
  archChannelActive.value = false
  if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
  mobileShowChat.value = false
  mesh.clearViewingChat()
}

async function handleSendMessage() {
  // Single-flight guard: the input's `@keydown.enter` fires per keydown, so a
  // repeating/held Enter or a rapid Enter→click before the button's disabled
  // state flips queues a second mesh.send against the same text. That's how
  // every bubble was showing up twice — sender transmitted the same envelope
  // twice, receiver stored both. Bail if a send is already in flight.
  if (mesh.sending || sendingArch.value) return
  if (archChannelActive.value) {
    await sendArchMessage()
    nextTick(() => scrollChatToBottom())
    return
  }
  // Pending reply: Send flushes as mesh.send-reply targeting the stashed
  // MessageKey. Takes precedence over a pending attachment — we don't try
  // to express "attach-as-reply" in one go.
  // Pending edit: Send flushes as mesh.edit-message against the stashed seq.
  if (pendingEdit.value && activeChatPeer.value) {
    if (!messageText.value.trim()) return
    sendError.value = ''
    try {
      await mesh.editMessage(
        activeChatPeer.value.contact_id,
        pendingEdit.value.target_seq,
        messageText.value.trim(),
      )
      messageText.value = ''
      pendingEdit.value = null
      nextTick(() => scrollChatToBottom())
    } catch (err: unknown) {
      sendError.value = err instanceof Error ? err.message : 'Edit failed'
    }
    return
  }
  if (pendingReply.value && activeChatPeer.value) {
    if (!messageText.value.trim()) return
    sendError.value = ''
    try {
      await mesh.sendReply(
        activeChatPeer.value.contact_id,
        pendingReply.value.target_pubkey,
        pendingReply.value.target_seq,
        messageText.value.trim(),
      )
      messageText.value = ''
      pendingReply.value = null
      nextTick(() => scrollChatToBottom())
    } catch (err: unknown) {
      sendError.value = err instanceof Error ? err.message : 'Reply failed'
    }
    return
  }
  // Pending share-to-mesh attachment: Send flushes the CID as a ContentRef
  // rather than a plain text message. Any text in the input becomes the
  // caption. Only valid for direct peers (channel broadcast of content_ref
  // isn't in scope for Phase 3c).
  if (pendingAttachment.value && activeChatPeer.value) {
    sendError.value = ''
    try {
      const caption = messageText.value.trim() || undefined
      await mesh.sendContent(activeChatPeer.value.contact_id, pendingAttachment.value.cid, caption)
      messageText.value = ''
      pendingAttachment.value = null
      nextTick(() => scrollChatToBottom())
    } catch (err: unknown) {
      sendError.value = err instanceof Error ? err.message : 'Share failed'
    }
    return
  }
  if (!messageText.value.trim()) return
  sendError.value = ''
  try {
    if (activeChatChannel.value) {
      await mesh.sendChannelMessage(activeChatChannel.value.index, messageText.value)
      messageText.value = ''
      nextTick(() => scrollChatToBottom())
    } else if (activeChatPeer.value) {
      await mesh.sendMessage(activeChatPeer.value.contact_id, messageText.value)
      messageText.value = ''
      nextTick(() => scrollChatToBottom())
    }
  } catch (err: unknown) {
    sendError.value = err instanceof Error ? err.message : 'Send failed'
  }
}

function scrollChatToBottom() {
  if (chatScrollEl.value) {
    chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight
  }
}

// Wheel over the chat must scroll ONLY the chat — never leak to the contacts
// list or the page. Bound with `@wheel.stop.prevent`: `.stop` keeps the event
// from reaching the global controller-nav wheel handler (which would otherwise
// also scroll whatever container is focused, e.g. the peer list after a click),
// and `.prevent` stops the native page scroll. We then apply the delta to the
// chat container directly.
function onChatWheel(e: WheelEvent) {
  const el = chatScrollEl.value
  if (!el) return
  el.scrollTop += e.deltaY
}

async function handleBroadcast() {
  broadcasting.value = true
  try { await mesh.broadcastIdentity() } finally { broadcasting.value = false }
}

async function handleToggleEnabled() {
  configuring.value = true
  try {
    const newEnabled = !(mesh.status?.enabled ?? false)
    await mesh.configure({ enabled: newEnabled })
  } finally { configuring.value = false }
}

async function handleConnectDevice(devicePath: string) {
  connectingDevice.value = devicePath
  try {
    await mesh.configure({ enabled: true, device_path: devicePath } as Partial<import('@/stores/mesh').MeshStatus>)
  } finally {
    connectingDevice.value = null
  }
}

function signalBars(rssi: number | null): number {
  if (rssi === null) return 0
  if (rssi > -60) return 4
  if (rssi > -75) return 3
  if (rssi > -90) return 2
  return 1
}

function signalTitle(rssi: number | null, snr: number | null): string {
  if (rssi === null && snr === null) return 'No signal data yet'
  const parts: string[] = []
  if (rssi !== null) parts.push(`${rssi} dBm`)
  if (snr !== null) parts.push(`SNR ${snr.toFixed(1)} dB`)
  return parts.join(' · ')
}

function timeAgo(iso: string): string {
  const diff = Date.now() - new Date(iso).getTime()
  const secs = Math.floor(diff / 1000)
  if (secs < 60) return `${secs}s ago`
  const mins = Math.floor(secs / 60)
  if (mins < 60) return `${mins}m ago`
  const hours = Math.floor(mins / 60)
  return `${hours}h ago`
}

function truncatePubkey(hex: string | null): string {
  if (!hex) return ''
  return hex.slice(0, 8) + '...' + hex.slice(-6)
}

// ── Reply + Reaction (Phase 2a) ───────────────────────────────────────────
// Pending reply state: when the user picks "Reply" on a bubble, we stash its
// MessageKey here; next Send uses mesh.send-reply instead of mesh.send.
interface PendingReply {
  target_pubkey: string
  target_seq: number
  preview: string
}
const pendingReply = ref<PendingReply | null>(null)
const actionMenuForId = ref<number | null>(null)
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']

function openActionMenu(msgId: number, ev?: Event) {
  ev?.stopPropagation()
  actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId
}
function closeActionMenu() {
  actionMenuForId.value = null
}
function handleDocClickForMenu(ev: MouseEvent) {
  if (actionMenuForId.value === null) return
  const target = ev.target as HTMLElement | null
  if (!target) return
  if (target.closest('.mesh-chat-action-menu')) return
  if (target.closest('.mesh-chat-action-trigger')) return
  actionMenuForId.value = null
}
function messageKeyFor(msg: { sender_pubkey?: string | null; sender_seq?: number | null }): { pubkey: string; seq: number } | null {
  if (!msg.sender_pubkey || msg.sender_seq == null) return null
  return { pubkey: msg.sender_pubkey, seq: msg.sender_seq }
}
function startReplyTo(msg: MeshMessage) {
  const key = messageKeyFor(msg)
  if (!key) return
  pendingReply.value = {
    target_pubkey: key.pubkey,
    target_seq: key.seq,
    preview: summarizeForPreview(msg),
  }
  closeActionMenu()
}
function clearPendingReply() {
  pendingReply.value = null
}

// ── Edit / Delete / Forward (Phase 2b) ───────────────────────────────────
// Pending edit: when the user picks "Edit" on an own message, we stash its
// sender_seq and prefill the composer. Next Send calls mesh.edit-message
// instead of mesh.send.
interface PendingEdit { target_seq: number; original_text: string }
const pendingEdit = ref<PendingEdit | null>(null)
function startEditOf(msg: MeshMessage) {
  if (msg.direction !== 'sent' || msg.sender_seq == null) return
  pendingEdit.value = { target_seq: msg.sender_seq, original_text: msg.plaintext }
  messageText.value = msg.plaintext
  closeActionMenu()
}
function clearPendingEdit() {
  pendingEdit.value = null
  messageText.value = ''
}
async function deleteOwnMessage(msg: MeshMessage) {
  if (msg.direction !== 'sent' || msg.sender_seq == null || !activeChatPeer.value) return
  if (!window.confirm('Delete this message? Peers already received it — this only marks it as deleted.')) return
  try {
    await mesh.deleteMessage(activeChatPeer.value.contact_id, msg.sender_seq)
  } catch (e) {
    sendError.value = e instanceof Error ? e.message : 'delete failed'
  } finally {
    closeActionMenu()
  }
}
async function forwardToCurrent(msg: MeshMessage) {
  if (!activeChatPeer.value) return
  try {
    await mesh.forwardMessage(activeChatPeer.value.contact_id, msg.id)
  } catch (e) {
    sendError.value = e instanceof Error ? e.message : 'forward failed'
  } finally {
    closeActionMenu()
  }
}
function isEditedMessage(msg: MeshMessage): number | null {
  const ts = msg.typed_payload?.edited_at
  return typeof ts === 'number' ? ts : null
}
function isDeletedMessage(msg: MeshMessage): boolean {
  return msg.message_type === 'delete' || msg.typed_payload?.deleted === true
}
/// Short label for the per-message transport pill (Meshtastic / Meshcore /
/// Reticulum / FIPS / Tor), or null when the transport isn't known.
function transportLabel(msg: MeshMessage): string | null {
  switch (msg.transport) {
    case 'meshtastic': return 'Meshtastic'
    case 'meshcore': return 'Meshcore'
    case 'reticulum': return 'Reticulum'
    case 'lora': return 'LoRa'
    case 'fips': return 'FIPS'
    case 'tor': return 'Tor'
    default: return null
  }
}

// Read-receipt: after render, if the bottom message is from the peer (direction='received')
// and has a MessageKey, fire mesh.send-read-receipt up to that seq. Debounced so scroll
// doesn't spam the wire.
const lastReceiptSentForSeq = ref<Map<number, number>>(new Map()) // contactId → last acked seq
let receiptDebounce: ReturnType<typeof setTimeout> | null = null
function scheduleReadReceipt() {
  if (receiptDebounce) clearTimeout(receiptDebounce)
  receiptDebounce = setTimeout(() => {
    const peer = activeChatPeer.value
    if (!peer) return
    const received = chatMessages.value.filter((m) => m.direction === 'received' && m.sender_seq != null)
    if (received.length === 0) return
    const latest = received[received.length - 1]
    if (!latest) return
    const latestSeq = latest.sender_seq as number
    const already = lastReceiptSentForSeq.value.get(peer.contact_id) ?? -1
    if (latestSeq <= already) return
    const pubkey = latest.sender_pubkey
    if (!pubkey) return
    lastReceiptSentForSeq.value.set(peer.contact_id, latestSeq)
    void mesh.sendReadReceipt(peer.contact_id, pubkey, latestSeq)
  }, 400)
}

const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running
async function reactTo(msg: MeshMessage, emoji: string) {
  const key = messageKeyFor(msg)
  if (!key || !activeChatPeer.value) return
  const marker = `${msg.id}:${emoji}`
  reactionInFlight.value = marker
  try {
    await mesh.sendReaction(activeChatPeer.value.contact_id, key.pubkey, key.seq, emoji)
    closeActionMenu()
  } catch (e) {
    sendError.value = e instanceof Error ? e.message : 'reaction failed'
  } finally {
    if (reactionInFlight.value === marker) reactionInFlight.value = null
  }
}

/// Build a map from target MessageKey (pubkey:seq) → emoji strings seen in
/// the current message window. Iterates in timestamp order so the freshest
/// reaction per (reactor, target) wins; an empty emoji clears the reactor.
interface ReactionChip { emoji: string; count: number; by_self: boolean }
const reactionIndex = computed<Map<string, ReactionChip[]>>(() => {
  const perTarget = new Map<string, Map<string, string>>() // target → (reactor → emoji)
  for (const m of mesh.messages) {
    if (m.message_type !== 'reaction' || !m.typed_payload) continue
    const target = m.typed_payload.target as { sender_pubkey?: string; sender_seq?: number } | undefined
    if (!target?.sender_pubkey || target.sender_seq == null) continue
    const key = `${target.sender_pubkey}:${target.sender_seq}`
    const reactor = m.direction === 'sent' ? '__self__' : (m.sender_pubkey ?? `peer:${m.peer_contact_id}`)
    let slot = perTarget.get(key)
    if (!slot) { slot = new Map(); perTarget.set(key, slot) }
    const emoji = String(m.typed_payload.emoji ?? '')
    if (emoji === '') {
      slot.delete(reactor)
    } else {
      slot.set(reactor, emoji)
    }
  }
  const out = new Map<string, ReactionChip[]>()
  for (const [key, slot] of perTarget) {
    const counts = new Map<string, { count: number; by_self: boolean }>()
    for (const [reactor, emoji] of slot) {
      const cur = counts.get(emoji) ?? { count: 0, by_self: false }
      cur.count++
      if (reactor === '__self__') cur.by_self = true
      counts.set(emoji, cur)
    }
    out.set(key, Array.from(counts, ([emoji, v]) => ({ emoji, count: v.count, by_self: v.by_self })))
  }
  return out
})
function reactionsFor(msg: { sender_pubkey?: string | null; sender_seq?: number | null }): ReactionChip[] {
  const key = messageKeyFor(msg)
  if (!key) return []
  return reactionIndex.value.get(`${key.pubkey}:${key.seq}`) ?? []
}
/// Lookup the target of a reply bubble so we can show a mini-quote above it.
function replyTargetPreview(msg: MeshMessage): string | null {
  if (msg.message_type !== 'reply' || !msg.typed_payload) return null
  const target = msg.typed_payload.target as { sender_pubkey?: string; sender_seq?: number } | undefined
  if (!target?.sender_pubkey || target.sender_seq == null) return null
  const match = mesh.messages.find(
    (m) => m.sender_pubkey === target.sender_pubkey && m.sender_seq === target.sender_seq,
  )
  if (!match) return `→ ${String(target.sender_pubkey).slice(0, 8)}…#${target.sender_seq}`
  return summarizeForPreview(match)
}
function summarizeForPreview(m: MeshMessage): string {
  const text = m.plaintext?.trim()
  if (text) return text.slice(0, 80)
  switch (m.message_type) {
    case 'content_ref': return `📎 ${m.typed_payload?.filename || m.typed_payload?.mime || 'attachment'}`
    case 'invoice': return `⚡ ${(m.typed_payload?.amount_sats || 0).toLocaleString()} sats`
    case 'coordinate': return '📍 Location'
    case 'alert': return `🚨 ${m.typed_payload?.message || 'Alert'}`.slice(0, 80)
    default: return `(${m.message_type || 'message'})`
  }
}

// ── share-to-mesh iframe intent (Phase 3c) ────────────────────────────────
// Marketplace app iframes POST a file to `/api/share-to-mesh` then call
// `window.parent.postMessage({type:'share-to-mesh', cid, ...})`. We park the
// CID as a pending attachment; next time the user picks a peer and hits Send
// (with optional caption text), we call mesh.send-content on that CID.
interface PendingAttachment {
  cid: string
  size: number
  mime: string
  filename: string | null
  self_url?: string
}
const pendingAttachment = ref<PendingAttachment | null>(null)

function loadPendingFromSession() {
  try {
    const raw = sessionStorage.getItem('archipelago_share_to_mesh')
    if (!raw) return
    sessionStorage.removeItem('archipelago_share_to_mesh')
    const data = JSON.parse(raw) as { cid?: string; size?: number; mime?: string; filename?: string | null; self_url?: string }
    if (!data.cid) return
    pendingAttachment.value = {
      cid: data.cid,
      size: data.size ?? 0,
      mime: data.mime ?? 'application/octet-stream',
      filename: data.filename ?? null,
      self_url: data.self_url,
    }
  } catch {
    /* ignore */
  }
}

function clearPendingAttachment() {
  pendingAttachment.value = null
}

// ── ContentRef attach + fetch (Phase 3b) ──────────────────────────────────
const attaching = ref(false)
// Compose row's "+" attach menu — a single toggle button that pops open a
// small vertical stack (attach file / voice message) instead of cramming
// each option in as its own always-visible button (was overflowing).
const showAttachMenu = ref(false)
function closeAttachMenuOnOutsideClick(ev: MouseEvent) {
  if (!showAttachMenu.value) return
  const target = ev.target as HTMLElement
  if (!target.closest('.mesh-attach-menu-anchor')) showAttachMenu.value = false
}
onMounted(() => document.addEventListener('click', closeAttachMenuOnOutsideClick))
onUnmounted(() => document.removeEventListener('click', closeAttachMenuOnOutsideClick))
const attachError = ref<string | null>(null)
const fetchingCids = ref<Set<string>>(new Set())
const fetchedUrls = ref<Map<string, string>>(new Map())

// Auto-render attachments whose bytes are already local — an inline
// (mesh.send-content-inline) ContentRef has its bytes written to our
// BlobStore the moment it's sent/received (dispatch.rs), so there's no real
// fetch to wait on; skip the explicit "Download" click for those. Runs
// `immediate: true` so already-loaded history gets the same treatment as
// newly-arriving messages.
const autoFetchedCids = new Set<string>()
watch(
  () => chatMessages.value.length,
  () => {
    for (const msg of chatMessages.value) {
      const payload = msg.typed_payload as { cid?: string; inline?: boolean } | undefined
      if (
        msg.message_type === 'content_ref' &&
        payload?.inline &&
        payload.cid &&
        !fetchedUrls.value.has(payload.cid) &&
        !fetchingCids.value.has(payload.cid) &&
        !autoFetchedCids.has(payload.cid)
      ) {
        autoFetchedCids.add(payload.cid)
        void handleFetchContent(msg.typed_payload as any)
      }
    }
  },
  { immediate: true },
)

// Transport chooser modal state — populated when advice comes back as
// "choose" (size fits both inline-over-mesh AND Tor). User picks a path;
// `transportChoiceResolve` finishes the promise started by handleAttachFile.
interface PendingTransportChoice {
  file: File
  size: number
  est_seconds: number
  has_tor: boolean
}
const transportChoice = ref<PendingTransportChoice | null>(null)
let transportChoiceResolve: ((choice: 'mesh' | 'tor' | 'cancel') => void) | null = null

function pickTransport(choice: 'mesh' | 'tor' | 'cancel') {
  if (transportChoiceResolve) {
    transportChoiceResolve(choice)
    transportChoiceResolve = null
  }
  transportChoice.value = null
}

// Image quality-picker modal — shown before sending an image attachment.
// Presets skip 'original'-specific compression (handled in compressImage)
// but still show a transfer-time estimate for it, fetched the same way as
// every other preset via the existing mesh.transport-advice RPC.
const imageQualityChoice = ref<{ file: File } | null>(null)
const imageQualityEstimates = ref<Map<string, string>>(new Map())
let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null

function formatEstSeconds(seconds: number): string {
  if (seconds < 60) return `~${seconds}s`
  return `~${Math.round(seconds / 60)}m`
}

async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> {
  imageQualityChoice.value = { file }
  imageQualityEstimates.value = new Map()
  // Fire off estimates for all presets in parallel — each preset's nominal
  // target size (or the real file size for 'original') against the SAME
  // mesh.transport-advice RPC the non-image attach flow already uses.
  void Promise.all(
    IMAGE_COMPRESSION_PRESETS.map(async (preset) => {
      const size = preset.key === 'original' ? file.size : Math.min(preset.targetBytes, file.size)
      try {
        const advice = await mesh.transportAdvice(peerContactId, size)
        const label =
          advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds)
        imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label)
      } catch {
        imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?')
      }
    }),
  )
  return new Promise<ImageCompressionPreset | null>((resolve) => {
    imageQualityResolve = resolve
  })
}

function pickImageQuality(preset: ImageCompressionPreset | null) {
  if (imageQualityResolve) {
    imageQualityResolve(preset)
    imageQualityResolve = null
  }
  imageQualityChoice.value = null
}

async function resolveFederationOnion(peerName: string): Promise<string | undefined> {
  try {
    const fed = await rpcClient.federationListNodes()
    const hit = fed.nodes.find(
      (n: { name?: string; onion?: string }) =>
        (n.name ?? '').toLowerCase() === peerName.toLowerCase() ||
        (n.name ?? '').toLowerCase().includes(peerName.toLowerCase()) ||
        peerName.toLowerCase().includes((n.name ?? '').toLowerCase()),
    )
    return hit?.onion ?? undefined
  } catch {
    return undefined
  }
}

async function sendViaMeshInline(file: File, peerContactId: number) {
  const buf = await file.arrayBuffer()
  const bytes = new Uint8Array(buf)
  await mesh.sendContentInline(
    peerContactId,
    file.type || 'application/octet-stream',
    bytes,
    file.name,
    messageText.value.trim() || undefined,
  )
}

async function sendViaTorContentRef(file: File, peerContactId: number, peerName: string) {
  const buf = await file.arrayBuffer()
  const headers: Record<string, string> = {
    'X-Blob-Mime': file.type || 'application/octet-stream',
    'X-Blob-Filename': file.name,
    'Content-Type': 'application/octet-stream',
  }
  // Tiny thumbnail so the receiver sees a preview immediately instead of
  // waiting on an explicit fetch — see the content_ref render branch below.
  if (file.type.startsWith('image/')) {
    try {
      const thumb = await makeThumbnail(file)
      headers['X-Blob-Thumb'] = btoa(String.fromCharCode(...thumb))
    } catch {
      // Best-effort — a missing thumbnail just means no preview, not a failed send.
    }
  }
  const up = await fetch('/api/blob', {
    method: 'POST',
    headers,
    credentials: 'include',
    body: buf,
  })
  if (!up.ok) throw new Error(`upload failed: ${up.status}`)
  const { cid } = (await up.json()) as { cid: string }
  const peerOnion = await resolveFederationOnion(peerName)
  await mesh.sendContent(peerContactId, cid, messageText.value.trim() || undefined, peerOnion)
}

/** Resolve the best transport for `file` via mesh.transport-advice (prompting
 * with the transport-chooser modal for the ambiguous "choose" tier) and send
 * it. Shared by the file-attach flow and voice messages — both just need
 * "given a File and a peer, get it there". Returns false if the user
 * cancelled or the send was rejected as impossible (caller already informed
 * via attachError); true on success. */
async function sendFileViaBestTransport(file: File, peer: MeshPeer): Promise<boolean> {
  const advice = await mesh.transportAdvice(peer.contact_id, file.size)
  let transport: 'mesh' | 'tor' | 'cancel'
  if (advice.tier === 'auto-mesh' || advice.tier === 'resource-mesh') {
    // 'resource-mesh' (Reticulum-only, large-over-LoRa via RNS Resource) is
    // routed by the SAME mesh.send-content-inline call as 'auto-mesh' — the
    // backend decides internally whether to use the small inline-chunk path
    // or a Resource transfer based on size + active device type.
    transport = 'mesh'
  } else if (advice.tier === 'tor-only') {
    transport = 'tor'
  } else if (advice.tier === 'impossible') {
    attachError.value = `Cannot send: ${advice.reason} (${(file.size / 1024).toFixed(1)} KB)`
    return false
  } else {
    // "choose" — open modal and wait for user to pick
    transport = await new Promise<'mesh' | 'tor' | 'cancel'>((resolve) => {
      transportChoiceResolve = resolve
      transportChoice.value = {
        file,
        size: file.size,
        est_seconds: advice.est_seconds,
        has_tor: advice.has_tor,
      }
    })
    if (transport === 'cancel') return false
  }
  if (transport === 'mesh') {
    await sendViaMeshInline(file, peer.contact_id)
  } else {
    await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
  }
  return true
}

async function handleAttachFile(ev: Event) {
  const input = ev.target as HTMLInputElement
  let file = input.files?.[0]
  if (!file) return
  if (!activeChatPeer.value) {
    attachError.value = 'Pick a peer first'
    if (input) input.value = ''
    return
  }
  const peer = activeChatPeer.value
  attaching.value = true
  attachError.value = null
  try {
    if (file.type.startsWith('image/')) {
      const preset = await openImageQualityDialog(file, peer.contact_id)
      if (!preset) return // user cancelled
      file = await compressImage(file, preset)
    }
    if (!(await sendFileViaBestTransport(file, peer))) return
    messageText.value = ''
    nextTick(() => scrollChatToBottom())
  } catch (e) {
    attachError.value = e instanceof Error ? e.message : 'attach failed'
  } finally {
    attaching.value = false
    if (input) input.value = ''
  }
}

// Voice messages — async/store-and-forward (a recorded clip sent as a normal
// attachment), NOT a live call; reuses sendFileViaBestTransport exactly like
// any other file. Hold-to-record: press the mic button, release to send.
const isRecordingVoice = ref(false)
let voiceRecorder: MediaRecorder | null = null
let voiceRecorderStream: MediaStream | null = null
let voiceChunks: Blob[] = []

async function startVoiceRecording() {
  if (isRecordingVoice.value || attaching.value || !activeChatPeer.value) return
  // navigator.mediaDevices is only exposed by the browser in a secure context
  // (HTTPS, or localhost) — undefined entirely on plain-HTTP LAN nodes like
  // .116, which throws a confusing "Cannot read properties of undefined"
  // instead of the permission-style error handled below.
  if (!navigator.mediaDevices?.getUserMedia) {
    attachError.value = 'Voice messages need HTTPS (or localhost) — the browser blocks microphone access on plain HTTP'
    return
  }
  try {
    voiceRecorderStream = await navigator.mediaDevices.getUserMedia({ audio: true })
  } catch (e) {
    attachError.value = e instanceof Error ? e.message : 'Microphone access denied'
    return
  }
  voiceChunks = []
  voiceRecorder = new MediaRecorder(voiceRecorderStream, { mimeType: 'audio/webm;codecs=opus' })
  voiceRecorder.ondataavailable = (e) => {
    if (e.data.size > 0) voiceChunks.push(e.data)
  }
  voiceRecorder.start()
  isRecordingVoice.value = true
}

async function stopVoiceRecording() {
  if (!isRecordingVoice.value || !voiceRecorder) return
  const recorder = voiceRecorder
  const stream = voiceRecorderStream
  isRecordingVoice.value = false
  voiceRecorder = null
  voiceRecorderStream = null
  const blob = await new Promise<Blob>((resolve) => {
    recorder.onstop = () => resolve(new Blob(voiceChunks, { type: 'audio/webm' }))
    recorder.stop()
  })
  stream?.getTracks().forEach((t) => t.stop())
  if (blob.size === 0 || !activeChatPeer.value) return

  const peer = activeChatPeer.value
  const file = new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' })
  attaching.value = true
  attachError.value = null
  try {
    if (await sendFileViaBestTransport(file, peer)) {
      nextTick(() => scrollChatToBottom())
    }
  } catch (e) {
    attachError.value = e instanceof Error ? e.message : 'Failed to send voice message'
  } finally {
    attaching.value = false
  }
}

/** pointerleave while still holding (e.g. dragged off the button) — stop and
 * send rather than silently discarding the in-progress recording. */
function stopVoiceRecordingIfActive() {
  if (isRecordingVoice.value) void stopVoiceRecording()
}

async function handleFetchContent(payload: {
  cid: string
  sender_onion: string
  cap_token: string
  cap_exp: number
  mime?: string
  filename?: string | null
}) {
  if (fetchingCids.value.has(payload.cid)) return
  fetchingCids.value.add(payload.cid)
  try {
    const res = await mesh.fetchContent({
      cid: payload.cid,
      sender_onion: payload.sender_onion,
      cap_token: payload.cap_token,
      cap_exp: payload.cap_exp,
      mime: payload.mime,
      filename: payload.filename ?? undefined,
    })
    const r = res as { local_url?: string }
    if (r.local_url) {
      fetchedUrls.value.set(payload.cid, r.local_url)
      fetchedUrls.value = new Map(fetchedUrls.value)
    }
  } catch (e) {
    console.error('fetch-content failed', e)
  } finally {
    fetchingCids.value.delete(payload.cid)
  }
}

function isImageMime(mime?: string): boolean {
  return !!mime && mime.startsWith('image/')
}

</script>

<template>
  <div class="mesh-view">
    <!-- Header (hidden on mobile) -->
    <div class="mesh-header hidden md:flex">
      <div class="mesh-header-left">
        <h1 class="mesh-title">Mesh Network</h1>
        <p class="mesh-subtitle">
          {{ mesh.status?.peer_count ?? 0 }} peer{{ (mesh.status?.peer_count ?? 0) !== 1 ? 's' : '' }}
          <span v-if="mesh.status?.device_connected" class="mesh-subtitle-badge">Live</span>
        </p>
      </div>
      <a
        href="https://flasher.meshcore.co.uk/"
        target="_blank"
        rel="noopener noreferrer"
        class="glass-button mesh-flasher-btn"
      >
        Flash Meshcore <span class="mesh-flasher-sep">|</span> Choose Companion USB
      </a>
    </div>

    <!-- Error banner -->
    <div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div>

    <!-- Responsive column layout -->
    <div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop, 'mesh-columns-very-wide': isVeryWideDesktop }">
      <!-- LEFT COLUMN: Status + Peers -->
      <div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat || mobileTab !== 'chat' }">
        <!-- Device Status -->
        <div data-controller-container tabindex="0" class="glass-card mesh-status-card" :class="{ 'mesh-status-collapsed': !deviceExpanded }">
          <div class="mesh-status-header" role="button" tabindex="0" @click="deviceExpanded = !deviceExpanded" @keydown.enter.prevent="deviceExpanded = !deviceExpanded" @keydown.space.prevent="deviceExpanded = !deviceExpanded">
            <div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
            <h2 class="mesh-section-title">Device</h2>
            <svg class="mesh-status-chevron" :aria-expanded="deviceExpanded" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
            </svg>
          </div>

          <div v-if="mesh.loading && !mesh.status" class="mesh-loading">Loading...</div>

          <div v-else-if="mesh.status" class="mesh-status-grid">
            <div class="mesh-stat">
              <span class="mesh-stat-label">Status</span>
              <span class="mesh-stat-value" :class="mesh.status.device_connected ? 'text-green' : mesh.status.enabled ? 'text-orange' : 'text-muted'">
                {{ mesh.status.device_connected ? 'Broadcasting' : mesh.status.enabled ? 'Connecting...' : 'Disabled' }}
              </span>
            </div>
            <div class="mesh-stat">
              <span class="mesh-stat-label">Type</span>
              <span class="mesh-stat-value">{{ mesh.status.device_type === 'unknown' ? '—' : mesh.status.device_type }}</span>
            </div>
            <div class="mesh-stat">
              <span class="mesh-stat-label">Port</span>
              <span class="mesh-stat-value">{{ mesh.status.device_path ?? 'Auto' }}</span>
            </div>
            <div class="mesh-stat">
              <span class="mesh-stat-label">Sent</span>
              <span class="mesh-stat-value">{{ mesh.status.messages_sent }}</span>
            </div>
            <div class="mesh-stat">
              <span class="mesh-stat-label">Recv</span>
              <span class="mesh-stat-value">{{ mesh.status.messages_received }}</span>
            </div>
            <div class="mesh-stat">
              <span class="mesh-stat-label">Channel</span>
              <span class="mesh-stat-value">{{ mesh.status.channel_name }}</span>
            </div>
          </div>

          <!-- Detected USB devices -->
          <div v-if="mesh.status?.detected_devices?.length" class="mesh-detected-devices">
            <div v-for="dev in mesh.status.detected_devices" :key="dev" class="mesh-device-row">
              <div class="mesh-device-indicator" />
              <span class="mesh-device-path">{{ dev }}</span>
              <button
                v-if="!mesh.status?.device_connected"
                class="glass-button mesh-connect-btn"
                :disabled="connectingDevice !== null"
                @click="handleConnectDevice(dev)"
              >
                {{ connectingDevice === dev ? 'Connecting...' : 'Connect' }}
              </button>
            </div>
          </div>
        </div>

        <!-- Off-grid mode banner -->
        <div v-if="transport.meshOnly" class="mesh-offgrid-banner">
          <svg class="w-4 h-4 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 11-12.728 0M12 9v4m0 4h.01" />
          </svg>
          <span class="text-sm font-medium text-orange-300">OFF-GRID</span>
          <span class="text-xs text-white/50">Tor/FIPS disabled - LoRa only</span>
        </div>

        <!-- Actions row -->
        <div class="mesh-actions" data-controller-container tabindex="0">
          <button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
            {{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
          </button>
          <button class="glass-button mesh-action-btn" :disabled="!mesh.status?.device_connected || broadcasting" @click="handleBroadcast">
            {{ broadcasting ? 'Sending...' : 'Broadcast' }}
          </button>
          <button
            class="glass-button mesh-action-btn"
            :class="transport.meshOnly ? 'mesh-offgrid-active' : ''"
            :disabled="togglingOffGrid"
            @click="handleToggleOffGrid"
          >
            {{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
          </button>
          <button class="glass-button mesh-action-btn" @click="mesh.refreshAll()">Refresh</button>
        </div>

        <!-- Peers list -->
        <div data-controller-container tabindex="0" class="glass-card mesh-peers-card">
          <div class="flex items-center justify-between">
            <h2 class="mesh-section-title">Peers <span class="mesh-peer-count">{{ mesh.peers.length }}</span></h2>
            <button class="text-xs text-white/40 hover:text-red-400 transition-colors px-2 py-1" @click="clearAllMesh" title="Clear all peers, messages, and chat history">Clear All</button>
          </div>

          <!-- Contact search: filters the list below by name / DID / npub / pubkey -->
          <div class="mesh-peer-search-wrap">
            <input
              v-model="peerSearch"
              type="text"
              class="mesh-peer-search"
              placeholder="Search contacts…"
              aria-label="Search contacts"
            />
            <button
              v-if="peerSearch"
              type="button"
              class="mesh-peer-search-clear"
              aria-label="Clear search"
              title="Clear search"
              @click="peerSearch = ''"
            >&times;</button>
          </div>

          <div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
            No peers discovered yet.
          </div>

          <div v-else class="mesh-peer-list">
            <!-- Archipelago Channel -->
            <div
              class="mesh-peer-row is-channel"
              :class="{ active: archChannelActive }"
              tabindex="0"
              role="button"
              @click="openArchChannel"
              @keydown.enter="openArchChannel"
            >
              <div class="mesh-peer-avatar channel" style="background: rgba(251,146,60,0.2); color: #fb923c;">A</div>
              <div class="mesh-peer-info">
                <div class="mesh-peer-name">Archipelago</div>
                <div class="mesh-peer-sub">All nodes over Tor</div>
              </div>
              <span v-if="archUnread > 0" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ archUnread }}</span>
            </div>
            <!-- Public channel -->
            <div
              class="mesh-peer-row is-channel"
              :class="{ active: activeChatChannel?.index === 0 }"
              tabindex="0"
              role="button"
              @click="openChannelChat(publicChannel)"
              @keydown.enter="openChannelChat(publicChannel)"
            >
              <div class="mesh-peer-avatar channel">#</div>
              <div class="mesh-peer-info">
                <div class="mesh-peer-name">Public</div>
                <div class="mesh-peer-sub">Mesh radio</div>
              </div>
              <span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
            </div>
            <div v-if="displayedPeers.length === 0 && peerSearch.trim()" class="mesh-empty">
              No contacts match “{{ peerSearch.trim() }}”.
            </div>
            <div
              v-for="mp in displayedPeers" :key="mp.key"
              class="mesh-peer-row"
              :class="{ active: mp.contact_ids.includes(activeChatPeer?.contact_id ?? -1), 'is-archy': mp.is_archy }"
              tabindex="0"
              role="button"
              @click="openChat(mp.primary)"
              @keydown.enter="openChat(mp.primary)"
            >
              <div class="mesh-peer-avatar" :class="{ archy: mp.is_archy }">
                <AnimatedLogo v-if="mp.is_archy" size="sm" />
                <template v-else>{{ mp.display_name.charAt(0).toUpperCase() }}</template>
                <span class="mesh-peer-reach" :class="mp.reachable ? 'is-reachable' : 'is-unreachable'" :title="mp.reachable ? 'Reachable' : 'Not currently reachable'"></span>
              </div>
              <div class="mesh-peer-info">
                <div class="mesh-peer-name">
                  {{ mp.display_name }}
                  <span v-if="mp.is_archy" class="mesh-peer-archy-badge">Archy</span>
                </div>
                <div class="mesh-peer-sub">
                  <template v-if="mp.short_did">{{ mp.short_did }}</template>
                  <template v-else>{{ truncatePubkey(mp.primary_pubkey_hex) }}</template>
                </div>
                <div v-if="mp.npub" class="mesh-peer-sub mesh-peer-npub">{{ mp.npub.slice(0, 12) }}…{{ mp.npub.slice(-6) }}</div>
              </div>
              <span v-if="mergedUnreadCount(mp)" class="mesh-unread-badge">
                {{ mergedUnreadCount(mp) }}
              </span>
              <div class="mesh-peer-signal" :title="signalTitle(mp.primary_rssi, mp.primary_snr)">
                <div class="mesh-signal-bars">
                  <div v-for="i in 4" :key="i" class="mesh-signal-bar" :class="{ active: i <= signalBars(mp.primary_rssi) }" />
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>

      <!-- RIGHT COLUMN: Tabbed panels -->
      <div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat || mobileTab !== 'chat' }">
        <!-- Tab bar (medium desktop only) -->
        <div v-if="showTabBar" class="mesh-tab-bar">
          <button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
          <button class="mesh-tab" :class="{ active: activeTab === 'bitcoin' }" @click="activeTab = 'bitcoin'">Bitcoin</button>
          <button class="mesh-tab" :class="{ active: activeTab === 'deadman' }" @click="activeTab = 'deadman'">
            Dead Man
            <span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
          </button>
          <button class="mesh-tab" :class="{ active: activeTab === 'assistant' }" @click="activeTab = 'assistant'">
            AI
          </button>
          <button class="mesh-tab" :class="{ active: activeTab === 'map' }" @click="activeTab = 'map'">
            Map
          </button>
          <button class="mesh-tab" :class="{ active: activeTab === 'device' }" @click="activeTab = 'device'" title="Device settings">
            ⚙️
          </button>
        </div>

        <!-- Chat Panel -->
        <div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card" :class="{ 'mesh-chat-card-active': hasActiveChat }">
          <div v-if="!hasActiveChat" class="mesh-chat-empty">
            <div class="mesh-chat-empty-icon">&#x1F4E1;</div>
            <p>Select a peer or channel to chat</p>
            <p class="mesh-chat-empty-sub">Messages are sent over LoRa mesh radio</p>
          </div>
          <template v-else>
            <!-- Mobile: floating back button (shared glass pill style), pinned
                 above the tab bar — replaces the in-header arrow so the back
                 control is no longer crammed inside the chat container. -->
            <Teleport to="body">
              <button
                type="button"
                class="mesh-chat-mobile-back mobile-back-btn back-button-glass px-6 py-3 rounded-xl font-medium items-center justify-center gap-2"
                @click="closeChat"
              >
                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
                </svg>
                <span>Back</span>
              </button>
            </Teleport>
            <div class="mesh-chat-header">
              <div class="mesh-chat-header-info">
                <div class="mesh-chat-header-name">
                  <template v-if="renamingActive">
                    <input
                      ref="renameInputEl"
                      v-model="renameDraft"
                      class="mesh-chat-header-rename-input"
                      :placeholder="activeChatName"
                      @keyup.enter="commitRename"
                      @keyup.esc="cancelRename"
                      @blur="commitRename"
                    />
                  </template>
                  <template v-else>
                    {{ activeChatName }}
                    <button
                      v-if="activeMergedPeer"
                      class="mesh-chat-header-rename"
                      title="Rename this contact"
                      @click="startRename"
                    >✎</button>
                  </template>
                  <span v-if="activeChatPeer && isArchyNode(activeChatPeer)" class="mesh-peer-archy-badge">Archy</span>
                  <span v-if="activeChatChannel" class="mesh-peer-channel-badge">Channel</span>
                </div>
                <div class="mesh-chat-header-sub">{{ activeChatSub }}</div>
              </div>
              <div class="mesh-chat-header-status">
                <span v-if="activeChatPeer && peerSessionInfo" class="mesh-session-badge" :class="peerSessionInfo.forward_secrecy ? 'session-ratchet' : peerSessionInfo.has_session ? 'session-static' : 'session-none'" :title="peerSessionInfo.forward_secrecy ? 'Double Ratchet (forward secrecy)' : peerSessionInfo.has_session ? 'Static encryption' : 'No encryption'">&#x1F6E1;</span>
                <button v-if="activeChatPeer && peerSessionInfo" class="mesh-session-rotate" :disabled="rotatingPrekeys" :title="'Rotate prekeys'" @click="handleRotatePrekeys">{{ rotatingPrekeys ? '…' : '⟲' }}</button>
                <span v-if="outboxCount > 0" class="mesh-outbox-badge" :title="outboxCount + ' queued messages waiting for delivery'">📤 {{ outboxCount }}</span>
                <span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
              </div>
            </div>
            <div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
              <div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
                No messages yet. Say hello!
              </div>
              <div
                v-for="msg in chatMessages" :key="msg.id"
                class="mesh-chat-bubble-wrapper"
                :class="msg.direction"
              >
                <div class="mesh-chat-bubble" :class="[msg.direction, msg.message_type ? 'typed-' + msg.message_type : '', { 'menu-open': actionMenuForId === msg.id }]">
                  <div v-if="senderLabelFor(msg)" class="mesh-chat-bubble-sender">{{ senderLabelFor(msg) }}</div>
                  <div v-if="replyTargetPreview(msg)" class="mesh-chat-reply-quote">
                    ↳ {{ replyTargetPreview(msg) }}
                  </div>
                  <!-- Invoice card -->
                  <div v-if="msg.message_type === 'invoice' && msg.typed_payload" class="mesh-typed-invoice">
                    <div class="mesh-typed-invoice-header">
                      <span class="mesh-typed-icon">&#x26A1;</span>
                      <span class="mesh-typed-label">Lightning Invoice</span>
                      <span v-if="msg.typed_payload.paid" class="mesh-typed-paid">Paid</span>
                    </div>
                    <div class="mesh-typed-invoice-amount">{{ (msg.typed_payload.amount_sats || 0).toLocaleString() }} sats</div>
                    <div v-if="msg.typed_payload.memo" class="mesh-typed-invoice-memo">{{ msg.typed_payload.memo }}</div>
                    <div class="mesh-typed-invoice-bolt11">{{ (msg.typed_payload.bolt11 || '').substring(0, 40) }}...</div>
                  </div>
                  <!-- Alert card -->
                  <div v-else-if="msg.message_type === 'alert' && msg.typed_payload" class="mesh-typed-alert" :class="'alert-' + (msg.typed_payload.alert_type || 'status')">
                    <div class="mesh-typed-alert-header">
                      <span class="mesh-typed-icon">{{ msg.typed_payload.alert_type === 'emergency' ? '&#x1F6A8;' : msg.typed_payload.alert_type === 'dead_man' ? '&#x2620;' : '&#x2139;' }}</span>
                      <span class="mesh-typed-label">{{ msg.typed_payload.alert_type === 'emergency' ? 'EMERGENCY' : msg.typed_payload.alert_type === 'dead_man' ? 'DEAD MAN' : 'Status' }}</span>
                      <span v-if="msg.typed_payload.signed" class="mesh-typed-signed">Signed</span>
                    </div>
                    <div class="mesh-typed-alert-message">{{ msg.typed_payload.message }}</div>
                    <a v-if="msg.typed_payload.coordinate" class="mesh-typed-alert-location" :href="'https://www.openstreetmap.org/?mlat=' + (msg.typed_payload.coordinate.lat / 1000000) + '&mlon=' + (msg.typed_payload.coordinate.lng / 1000000) + '&zoom=14'" target="_blank" rel="noopener">
                      &#x1F4CD; {{ msg.typed_payload.coordinate.label || 'View location' }}
                    </a>
                  </div>
                  <!-- Coordinate card -->
                  <div v-else-if="msg.message_type === 'coordinate' && msg.typed_payload" class="mesh-typed-coordinate">
                    <div class="mesh-typed-coordinate-header">
                      <span class="mesh-typed-icon">&#x1F4CD;</span>
                      <span class="mesh-typed-label">Location</span>
                    </div>
                    <div class="mesh-typed-coordinate-value">{{ (msg.typed_payload.lat / 1000000).toFixed(4) }}, {{ (msg.typed_payload.lng / 1000000).toFixed(4) }}</div>
                    <div v-if="msg.typed_payload.label" class="mesh-typed-coordinate-label">{{ msg.typed_payload.label }}</div>
                    <a class="mesh-typed-coordinate-link" :href="'https://www.openstreetmap.org/?mlat=' + (msg.typed_payload.lat / 1000000) + '&mlon=' + (msg.typed_payload.lng / 1000000) + '&zoom=14'" target="_blank" rel="noopener">Open Map</a>
                  </div>
                  <!-- Block header -->
                  <div v-else-if="msg.message_type === 'block_header' && msg.typed_payload" class="mesh-typed-block">
                    <span class="mesh-typed-icon">&#x26D3;</span>
                    <span class="mesh-typed-label">{{ msg.typed_payload.message || msg.plaintext }}</span>
                  </div>
                  <!-- TX relay request -->
                  <div v-else-if="msg.message_type === 'tx_relay' && msg.typed_payload" class="mesh-typed-block">
                    <span class="mesh-typed-icon">&#x21AA;</span>
                    <span class="mesh-typed-label">TX relay #{{ msg.typed_payload.request_id }} ({{ (msg.typed_payload.tx_hex || '').length }} hex chars)</span>
                  </div>
                  <!-- TX relay response -->
                  <div v-else-if="msg.message_type === 'tx_relay_response' && msg.typed_payload" class="mesh-typed-block">
                    <span class="mesh-typed-icon">{{ msg.typed_payload.txid ? '&#x2705;' : '&#x274C;' }}</span>
                    <span class="mesh-typed-label">
                      <template v-if="msg.typed_payload.txid">Broadcast #{{ msg.typed_payload.request_id }}: {{ String(msg.typed_payload.txid).substring(0, 12) }}…</template>
                      <template v-else>TX relay failed #{{ msg.typed_payload.request_id }}: {{ msg.typed_payload.error || 'unknown' }}</template>
                    </span>
                  </div>
                  <!-- TX confirmation -->
                  <div v-else-if="msg.message_type === 'tx_confirmation' && msg.typed_payload" class="mesh-typed-block">
                    <span class="mesh-typed-icon">&#x26D3;</span>
                    <span class="mesh-typed-label">{{ msg.typed_payload.confirmations }} conf @ block {{ msg.typed_payload.block_height }} — {{ String(msg.typed_payload.txid || '').substring(0, 12) }}…</span>
                  </div>
                  <!-- Lightning relay request -->
                  <div v-else-if="msg.message_type === 'lightning_relay' && msg.typed_payload" class="mesh-typed-invoice">
                    <div class="mesh-typed-invoice-header">
                      <span class="mesh-typed-icon">&#x26A1;</span>
                      <span class="mesh-typed-label">Lightning Relay Request</span>
                    </div>
                    <div class="mesh-typed-invoice-amount">{{ (msg.typed_payload.amount_sats || 0).toLocaleString() }} sats</div>
                    <div class="mesh-typed-invoice-bolt11">{{ (msg.typed_payload.bolt11 || '').substring(0, 40) }}…</div>
                  </div>
                  <!-- Lightning relay response -->
                  <div v-else-if="msg.message_type === 'lightning_relay_response' && msg.typed_payload" class="mesh-typed-invoice">
                    <div class="mesh-typed-invoice-header">
                      <span class="mesh-typed-icon">{{ msg.typed_payload.preimage ? '&#x2705;' : '&#x274C;' }}</span>
                      <span class="mesh-typed-label">
                        <template v-if="msg.typed_payload.preimage">Lightning Paid</template>
                        <template v-else>Lightning Failed</template>
                      </span>
                    </div>
                    <div v-if="msg.typed_payload.payment_hash" class="mesh-typed-invoice-bolt11">hash: {{ String(msg.typed_payload.payment_hash).substring(0, 20) }}…</div>
                    <div v-if="msg.typed_payload.preimage" class="mesh-typed-invoice-bolt11">preimage: {{ String(msg.typed_payload.preimage).substring(0, 20) }}…</div>
                    <div v-if="msg.typed_payload.error" class="mesh-typed-invoice-memo">{{ msg.typed_payload.error }}</div>
                  </div>
                  <div v-else-if="msg.message_type === 'content_ref' && msg.typed_payload" class="mesh-typed-content">
                    <div class="mesh-typed-content-meta">
                      <span class="mesh-typed-icon">📎</span>
                      <span class="mesh-typed-label">{{ msg.typed_payload.filename || msg.typed_payload.mime }}</span>
                      <span class="mesh-typed-content-size">{{ msg.typed_payload.size }} B</span>
                    </div>
                    <div v-if="msg.typed_payload.caption" class="mesh-typed-content-caption">{{ msg.typed_payload.caption }}</div>
                    <template v-if="fetchedUrls.get(msg.typed_payload.cid)">
                      <img
                        v-if="isImageMime(msg.typed_payload.mime)"
                        :src="fetchedUrls.get(msg.typed_payload.cid)"
                        class="mesh-typed-content-preview"
                        alt="attachment"
                      />
                      <audio
                        v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
                        :src="fetchedUrls.get(msg.typed_payload.cid)"
                        controls
                        class="mesh-typed-content-audio"
                      />
                      <a v-else :href="fetchedUrls.get(msg.typed_payload.cid)" target="_blank" class="btn">Open</a>
                    </template>
                    <template v-else-if="msg.direction === 'sent'">
                      <span class="mesh-typed-content-hint">(shared from this node)</span>
                    </template>
                    <template v-else>
                      <img
                        v-if="msg.typed_payload.thumb_bytes && isImageMime(msg.typed_payload.mime)"
                        :src="`data:${msg.typed_payload.mime};base64,${msg.typed_payload.thumb_bytes}`"
                        class="mesh-typed-content-preview mesh-typed-content-thumb"
                        alt="thumbnail preview"
                      />
                      <button
                        class="btn"
                        :disabled="fetchingCids.has(msg.typed_payload.cid)"
                        @click="handleFetchContent(msg.typed_payload as any)"
                      >
                        {{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }}
                      </button>
                    </template>
                  </div>
                  <!-- Forwarded message -->
                  <div v-else-if="msg.message_type === 'forward' && msg.typed_payload" class="mesh-chat-bubble-text">
                    <div class="mesh-chat-forward-header">↪ Forwarded from {{ msg.typed_payload.orig_name || 'unknown' }}</div>
                    <div class="mesh-chat-forward-body">{{ msg.plaintext }}</div>
                  </div>
                  <!-- Deleted tombstone -->
                  <div v-else-if="isDeletedMessage(msg)" class="mesh-chat-bubble-text mesh-chat-deleted">🗑 message deleted</div>
                  <!-- Default: plain text -->
                  <div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
                  <div class="mesh-chat-bubble-meta">
                    <span v-if="transportLabel(msg)" class="mesh-chat-transport" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg)">{{ transportLabel(msg) }}</span>
                    <span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
                    <span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
                    <span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>
                    <span class="mesh-chat-bubble-time">{{ timeAgo(msg.timestamp) }}</span>
                    <button
                      v-if="messageKeyFor(msg) && msg.message_type !== 'reaction'"
                      class="mesh-chat-action-trigger"
                      :class="{ active: actionMenuForId === msg.id }"
                      :title="actionMenuForId === msg.id ? 'Close' : 'React / Reply'"
                      @click.stop="openActionMenu(msg.id, $event)"
                    >&#x22EF;</button>
                  </div>
                  <div v-if="reactionsFor(msg).length > 0" class="mesh-chat-reactions">
                    <span
                      v-for="chip in reactionsFor(msg)"
                      :key="chip.emoji"
                      class="mesh-chat-reaction-chip"
                      :class="{ 'by-self': chip.by_self }"
                    >{{ chip.emoji }}<span v-if="chip.count > 1" class="mesh-chat-reaction-count">{{ chip.count }}</span></span>
                  </div>
                  <div
                    v-if="actionMenuForId === msg.id && messageKeyFor(msg) && msg.message_type !== 'reaction' && activeChatPeer"
                    class="mesh-chat-action-menu"
                    @click.stop
                  >
                    <button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startReplyTo(msg)">Reply</button>
                    <button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="forwardToCurrent(msg)">Forward</button>
                    <button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
                    <button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
                    <button
                      v-for="emoji in QUICK_REACTIONS"
                      :key="emoji"
                      class="mesh-chat-reaction-btn"
                      :class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
                      :disabled="reactionInFlight !== null"
                      @click="reactTo(msg, emoji)"
                    >
                      <span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
                      <span v-else>{{ emoji }}</span>
                    </button>
                    <button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu">✕</button>
                  </div>
                </div>
              </div>
            </div>
            <div class="mesh-chat-compose">
              <div v-if="sendError" class="mesh-chat-send-error">{{ sendError }}</div>
              <div v-if="attachError" class="mesh-chat-send-error">{{ attachError }}</div>
              <div v-if="pendingReply" class="mesh-chat-pending-reply">
                <span class="mesh-typed-icon">↳</span>
                <span class="mesh-chat-pending-name">Replying to: {{ pendingReply.preview }}</span>
                <button class="mesh-chat-pending-clear" @click="clearPendingReply" title="Cancel reply">✕</button>
              </div>
              <div v-if="pendingEdit" class="mesh-chat-pending-reply">
                <span class="mesh-typed-icon">✎</span>
                <span class="mesh-chat-pending-name">Editing: {{ pendingEdit.original_text }}</span>
                <button class="mesh-chat-pending-clear" @click="clearPendingEdit" title="Cancel edit">✕</button>
              </div>
              <div v-if="pendingAttachment" class="mesh-chat-pending-attachment">
                <span class="mesh-typed-icon">📎</span>
                <span class="mesh-chat-pending-name">{{ pendingAttachment.filename || pendingAttachment.mime }}</span>
                <span class="mesh-chat-pending-size">{{ pendingAttachment.size }} B</span>
                <button class="mesh-chat-pending-clear" @click="clearPendingAttachment" title="Discard attachment">✕</button>
              </div>
              <div class="mesh-chat-compose-row">
                <div v-if="activeChatPeer" class="mesh-attach-menu-anchor">
                  <Transition name="mesh-attach-stack">
                    <div v-if="showAttachMenu" class="mesh-attach-stack">
                      <label
                        class="glass-button mesh-chat-attach-btn"
                        :class="{ 'is-busy': attaching }"
                        :title="attaching ? 'uploading…' : 'Attach file'"
                      >
                        <input
                          type="file"
                          @change="(e) => { showAttachMenu = false; handleAttachFile(e) }"
                          style="display:none;"
                          :disabled="attaching"
                        />
                        <span v-if="attaching" class="mesh-spinner" aria-hidden="true"></span>
                        <span v-else>📎</span>
                      </label>
                      <button
                        type="button"
                        class="glass-button mesh-chat-record-btn"
                        :class="{ 'is-recording': isRecordingVoice }"
                        :disabled="attaching"
                        :title="isRecordingVoice ? 'Release to send' : 'Hold to record a voice message'"
                        @pointerdown.prevent="startVoiceRecording"
                        @pointerup.prevent="() => { stopVoiceRecording(); showAttachMenu = false }"
                        @pointerleave="() => { stopVoiceRecordingIfActive(); showAttachMenu = false }"
                      >
                        <span v-if="isRecordingVoice" class="mesh-spinner" aria-hidden="true"></span>
                        <span v-else>🎤</span>
                      </button>
                    </div>
                  </Transition>
                  <button
                    type="button"
                    class="glass-button mesh-chat-plus-btn"
                    :class="{ 'is-open': showAttachMenu }"
                    title="Attach"
                    @click="showAttachMenu = !showAttachMenu"
                  >
                    +
                  </button>
                </div>
                <input
                  v-model="messageText"
                  class="mesh-chat-input"
                  :placeholder="activeChatPeer ? 'Type a message or pick a file…' : 'Type a message...'"
                  maxlength="160"
                  @keydown.enter.exact.prevent="handleSendMessage"
                />
                <button
                  class="glass-button mesh-chat-send-btn"
                  :disabled="(!messageText.trim() && !pendingAttachment) || mesh.sending || sendingArch"
                  @click="handleSendMessage"
                >
                  <span v-if="mesh.sending || sendingArch" class="mesh-send-spinner" aria-label="Sending"></span>
                  <template v-else>{{ pendingReply ? 'Reply' : (pendingAttachment ? 'Share' : 'Send') }}</template>
                </button>
              </div>
            </div>
          </template>
        </div>

        <!-- Tools panels (3rd column on wide screens) -->
        <div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
          <div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
            <button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
            <button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
              Dead Man
              <span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
            </button>
            <button class="mesh-tab" :class="{ active: toolsTab === 'assistant' }" @click="toolsTab = 'assistant'">
              AI
            </button>
            <button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
            <button class="mesh-tab" :class="{ active: toolsTab === 'device' }" @click="toolsTab = 'device'" title="Device settings">⚙️</button>
          </div>
          <MeshBitcoinPanel v-if="showBitcoinPanel" />
          <MeshDeadmanPanel v-if="showDeadmanPanel" />
          <MeshAssistantPanel v-if="showAssistantPanel" />
          <div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
          <MeshDevicePanel v-if="showDevicePanel" />
        </div>
      </div>

      <!-- Mobile tools: show under peers list on first view -->
      <div v-if="showMobileTools" class="mesh-mobile-tools">
        <div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
        <MeshBitcoinPanel v-if="showBitcoinPanel" />
        <MeshDeadmanPanel v-if="showDeadmanPanel" />
        <MeshAssistantPanel v-if="showAssistantPanel" />
        <MeshDevicePanel v-if="showDevicePanel" />
      </div>
    </div>

    <!-- Mobile: floating tab strip pinned above the global tab bar (same
         placement as the mobile back button). Switches the whole pane between
         the chat and each tool. Hidden while an individual conversation is open
         (the back button takes over there). -->
    <Teleport to="body">
      <div v-show="!mobileShowChat" class="mesh-mobile-tabbar">
        <button class="mesh-mtab" :class="{ active: mobileTab === 'chat' }" @click="selectMobileTab('chat')">Chat</button>
        <button class="mesh-mtab" :class="{ active: mobileTab === 'bitcoin' }" @click="selectMobileTab('bitcoin')">BTC</button>
        <button class="mesh-mtab" :class="{ active: mobileTab === 'deadman' }" @click="selectMobileTab('deadman')">
          Dead Man
          <span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
        </button>
        <button class="mesh-mtab" :class="{ active: mobileTab === 'assistant' }" @click="selectMobileTab('assistant')">AI</button>
        <button class="mesh-mtab" :class="{ active: mobileTab === 'map' }" @click="selectMobileTab('map')">Map</button>
        <button class="mesh-mtab" :class="{ active: mobileTab === 'device' }" @click="selectMobileTab('device')" title="Device settings">⚙️</button>
      </div>
    </Teleport>

    <!-- Transport chooser modal: shown when attachment size fits both mesh
         (inline-chunked) and Tor. User picks which path to send it over. -->
    <div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
      <div class="glass-card mesh-transport-modal">
        <h3 class="mesh-transport-title">📎 How should I send this?</h3>
        <p class="mesh-transport-sub">
          <strong>{{ transportChoice.file.name }}</strong>
          · {{ (transportChoice.size / 1024).toFixed(1) }} KB
        </p>
        <div class="mesh-transport-options">
          <button class="mesh-transport-option" @click="pickTransport('mesh')">
            <span class="mesh-transport-icon">📡</span>
            <span class="mesh-transport-label">Over mesh</span>
            <span class="mesh-transport-meta">~{{ transportChoice.est_seconds }}s · works offline</span>
          </button>
          <button
            class="mesh-transport-option"
            :disabled="!transportChoice.has_tor"
            @click="pickTransport('tor')"
          >
            <span class="mesh-transport-icon">🧅</span>
            <span class="mesh-transport-label">Over Tor</span>
            <span class="mesh-transport-meta">
              {{ transportChoice.has_tor ? 'instant · needs onion' : 'no Tor path to peer' }}
            </span>
          </button>
        </div>
        <button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
      </div>
    </div>

    <!-- Image quality-picker modal: shown before sending an image attachment.
         Each preset shows its nominal size target + a transfer-time estimate
         from the same mesh.transport-advice RPC the file-attach flow uses. -->
    <div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
      <div class="glass-card mesh-transport-modal">
        <h3 class="mesh-transport-title">🖼️ Choose Image Quality</h3>
        <p class="mesh-transport-sub">
          <strong>{{ imageQualityChoice.file.name }}</strong>
          · {{ (imageQualityChoice.file.size / 1024).toFixed(1) }} KB original
        </p>
        <div class="mesh-transport-options">
          <button
            v-for="preset in IMAGE_COMPRESSION_PRESETS"
            :key="preset.key"
            class="mesh-transport-option"
            @click="pickImageQuality(preset)"
          >
            <span class="mesh-transport-icon">🖼️</span>
            <span class="mesh-transport-label">{{ preset.displayName }} — {{ preset.description }}</span>
            <span class="mesh-transport-meta">{{ imageQualityEstimates.get(preset.key) ?? '…' }}</span>
          </button>
        </div>
        <button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
      </div>
    </div>

  </div>
</template>

<!-- Styles extracted to mesh/mesh-styles.css -->
