// main daemon HTTP/WebSocket server

import * as http from 'node:http'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'

const debugLogPath = path.join(os.homedir(), '.one', 'daemon-debug.log')
function debugLog(msg: string) {
  fs.appendFileSync(debugLogPath, `${new Date().toISOString()} ${msg}\n`)
}

// cache app names from config files
const serverAppNames = new Map<string, string>()

// try to get app name from app.json, app.config.ts, or fallback to dirname
async function getAppNameForServer(root: string): Promise<string | null> {
  // check cache first
  const cached = serverAppNames.get(root)
  if (cached) return cached

  try {
    // try app.json first
    const appJsonPath = path.join(root, 'app.json')
    if (fs.existsSync(appJsonPath)) {
      const content = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8'))
      const name = content.expo?.name || content.name
      if (name) {
        serverAppNames.set(root, name)
        return name
      }
    }

    // try app.config.ts / app.config.js - read and regex for name pattern
    for (const ext of ['ts', 'js']) {
      const configPath = path.join(root, `app.config.${ext}`)
      if (fs.existsSync(configPath)) {
        const content = fs.readFileSync(configPath, 'utf-8')
        const nameMatch = content.match(/name:\s*['"]([^'"]+)['"]/)
        if (nameMatch) {
          const name = nameMatch[1]
          serverAppNames.set(root, name)
          return name
        }
      }
    }

    // fallback to directory name
    const dirName = path.basename(root)
    if (dirName) {
      serverAppNames.set(root, dirName)
      return dirName
    }
  } catch (err) {
    debugLog(`Failed to get app name for ${root}: ${err}`)
  }

  return null
}

// unified mapping: tracks what we know about each client identifier
interface ClientInfo {
  serverId: string // the server this client should route to
  simulatorUdid?: string // the simulator (if known)
  matchedBy: 'user-agent' | 'tui' | 'auto' // how we learned this
  lastUsed: number // timestamp for TTL cleanup
}

const clientMappings = new Map<string, ClientInfo>()

// cleanup stale mappings every 30s
const MAPPING_TTL_MS = 3600000 // 1 hour
setInterval(() => {
  const now = Date.now()
  let cleaned = 0
  for (const [key, info] of clientMappings) {
    if (now - info.lastUsed > MAPPING_TTL_MS) {
      clientMappings.delete(key)
      cleaned++
    }
  }
  if (cleaned > 0) {
    debugLog(`Cleaned ${cleaned} stale mappings`)
  }
}, 30000)

// parse one-route-id cookie from request
function getRouteIdFromCookies(req: http.IncomingMessage): string | null {
  const cookieHeader = req.headers.cookie
  if (!cookieHeader) return null
  const match = cookieHeader.match(/one-route-id=([^;]+)/)
  return match ? match[1] : null
}

// pending mappings: when TUI connects sim to server, map next request's identifiers
// key: serverId, value: simulatorUdid
const pendingMappings = new Map<string, string>()

// called by TUI when user manually connects a simulator to a server
export function setPendingMapping(serverId: string, simulatorUdid: string) {
  pendingMappings.set(serverId, simulatorUdid)
  debugLog(
    `Pending mapping: next request to server ${serverId} will map to sim ${simulatorUdid}`
  )
}

// clear ALL mappings for a specific simulator (called when TUI changes a cable)
export function clearMappingsForSimulator(simulatorUdid: string) {
  let count = 0
  for (const [key, info] of clientMappings) {
    if (info.simulatorUdid === simulatorUdid) {
      clientMappings.delete(key)
      count++
    }
  }
  debugLog(`Cleared ${count} mappings for simulator ${simulatorUdid}`)
}

// for backwards compat - clear all mappings (used sparingly)
export function clearAllMappings() {
  const count = clientMappings.size
  clientMappings.clear()
  debugLog(`Cleared all ${count} client mappings`)
}

// get all simulator -> server mappings (for TUI visualization)
export function getSimulatorMappings(): Map<string, string> {
  const result = new Map<string, string>()
  for (const [_key, info] of clientMappings) {
    if (info.simulatorUdid) {
      result.set(info.simulatorUdid, info.serverId)
    }
  }
  return result
}

// set a simulator -> server mapping directly (called by TUI when cable connects)
export function setSimulatorMapping(simulatorUdid: string, serverId: string) {
  // use a synthetic key for TUI-set mappings
  const key = `tui:${simulatorUdid}`
  clientMappings.set(key, {
    serverId,
    simulatorUdid,
    matchedBy: 'tui',
    lastUsed: Date.now(),
  })
  debugLog(`TUI set mapping: sim=${simulatorUdid} -> server=${serverId}`)
}

// try to match user-agent app name to a registered server
async function matchUserAgentToServer(
  headers: http.IncomingHttpHeaders,
  servers: ServerRegistration[]
): Promise<ServerRegistration | null> {
  const userAgent = headers['user-agent']
  if (!userAgent || typeof userAgent !== 'string') return null

  // extract app name from user-agent (first part before /)
  const uaAppName = userAgent.split('/')[0]
  if (!uaAppName) return null

  debugLog(`Trying to match user-agent app "${uaAppName}" to servers`)

  for (const server of servers) {
    const appName = await getAppNameForServer(server.root)
    if (!appName) continue

    // normalize names for comparison (remove spaces, lowercase)
    const normalizedUa = uaAppName.toLowerCase().replace(/\s+/g, '')
    const normalizedApp = appName.toLowerCase().replace(/\s+/g, '')

    debugLog(`  Comparing "${normalizedUa}" to server app "${normalizedApp}"`)

    // check if they match (exact or contains)
    if (
      normalizedUa === normalizedApp ||
      normalizedUa.includes(normalizedApp) ||
      normalizedApp.includes(normalizedUa)
    ) {
      debugLog(`  Matched! ${uaAppName} -> ${server.root}`)
      return server
    }
  }

  return null
}

// check if user-agent is generic expo go (can't identify the app)
function isGenericExpoAgent(ua: string): boolean {
  return ua.startsWith('Expo/') || ua.startsWith('Exponent/')
}

// extract app name from user-agent: "TakeoutDev/1 CFNetwork/..." -> "TakeoutDev"
function extractAppNameFromUA(ua: string): string | null {
  const firstPart = ua.split(' ')[0] || ''
  const appName = firstPart.split('/')[0]
  return appName || null
}

// track recent HTTP connections: remotePort -> serverId
// WebSocket from same port likely belongs to same app
const recentConnections = new Map<number, { serverId: string; timestamp: number }>()
const CONNECTION_MEMORY_MS = 5000 // remember connections for 5 seconds

// get the best identifier key for this request
function getPrimaryIdentifier(headers: http.IncomingHttpHeaders): string | null {
  const userAgent = headers['user-agent'] || ''

  // built apps have unique user-agent like "TakeoutDev/1"
  if (!isGenericExpoAgent(userAgent)) {
    const appName = extractAppNameFromUA(userAgent)
    if (appName) {
      return `app:${appName}`
    }
  }

  // expo go - use eas-client-id if available (highest confidence)
  const easClientId = headers['eas-client-id']
  if (easClientId && typeof easClientId === 'string') {
    return `eas:${easClientId}`
  }

  // fallback: use full user-agent as identifier (even for Expo Go)
  if (userAgent) {
    return `ua:${userAgent}`
  }

  return null
}

// result of looking up client info
interface ClientLookup {
  info: ClientInfo | null
  identifier: string | null
}

// look up what we know about this client
function lookupClient(headers: http.IncomingHttpHeaders): ClientLookup {
  const identifier = getPrimaryIdentifier(headers)
  if (!identifier) {
    return { info: null, identifier: null }
  }

  const info = clientMappings.get(identifier)
  if (info) {
    // update lastUsed on cache hit
    info.lastUsed = Date.now()
  }
  return { info: info || null, identifier }
}

// save client info
function saveClientMapping(
  identifier: string,
  serverId: string,
  simulatorUdid: string | undefined,
  matchedBy: ClientInfo['matchedBy']
) {
  clientMappings.set(identifier, {
    serverId,
    simulatorUdid,
    matchedBy,
    lastUsed: Date.now(),
  })
  debugLog(
    `Saved mapping: ${identifier} -> server=${serverId}, sim=${simulatorUdid || 'unknown'}, via=${matchedBy}`
  )
}
import type { DaemonState, ServerRegistration } from './types'
import {
  createRegistry,
  findServersByBundleId,
  findServerById,
  getAllServers,
  getRoute,
  setRoute,
  clearRoute,
  touchServer,
  pruneDeadServers,
  checkServerAlive,
  registerServer,
} from './registry'
import { createIPCServer, getSocketPath, cleanupSocket, readServerFiles } from './ipc'
import { proxyHttpRequest, proxyWebSocket } from './proxy'
import { pickServer, getBootedSimulators, resolvePendingPicker } from './picker'
import colors from 'picocolors'

const DEFAULT_PORT = 8081

interface DaemonOptions {
  port?: number
  host?: string
  quiet?: boolean
}

// allow TUI to override route mode
let routeModeOverride: 'most-recent' | 'ask' | null = null

export function setRouteMode(mode: 'most-recent' | 'ask' | null) {
  routeModeOverride = mode
}

// track which daemon state is active for marking servers
let activeDaemonState: DaemonState | null = null

// infer simulator from unmapped sims
async function inferSimulator(
  clientInfo: ClientInfo | null
): Promise<string | undefined> {
  if (clientInfo?.simulatorUdid) return clientInfo.simulatorUdid
  const simulators = await getBootedSimulators()
  const existingMappings = getSimulatorMappings()
  const unmappedSims = simulators.filter((s) => !existingMappings.has(s.udid))
  return unmappedSims[0]?.udid || simulators[0]?.udid
}

// core routing logic - returns the server to route to
async function resolveServer(
  state: DaemonState,
  headers: http.IncomingHttpHeaders,
  servers: ServerRegistration[],
  bundleId: string | null
): Promise<{ server: ServerRegistration; learned: boolean }> {
  const { info: clientInfo, identifier } = lookupClient(headers)
  debugLog(
    `resolveServer: identifier=${identifier}, clientInfo=${JSON.stringify(clientInfo)}`
  )

  // helper to learn mapping
  const learnMapping = async (
    server: ServerRegistration,
    matchedBy: ClientInfo['matchedBy']
  ) => {
    if (identifier && !clientInfo?.simulatorUdid) {
      const simUdid = await inferSimulator(clientInfo)
      if (simUdid) {
        saveClientMapping(identifier, server.id, simUdid, matchedBy)
        return true
      }
    }
    return false
  }

  // single server - always use it, but learn mapping
  if (servers.length === 1) {
    const server = servers[0]
    const learned = await learnMapping(server, 'auto')
    debugLog(`Single server: ${server.root}`)
    return { server, learned }
  }

  // PRIORITY 0: pending TUI mapping
  if (pendingMappings.size > 0 && identifier) {
    for (const [serverId, simUdid] of pendingMappings) {
      const server = findServerById(state, serverId)
      if (server && servers.some((s) => s.id === serverId)) {
        debugLog(`TUI pending mapping: ${server.root}, sim=${simUdid}`)
        saveClientMapping(identifier, serverId, simUdid, 'tui')
        pendingMappings.delete(serverId)
        return { server, learned: true }
      }
    }
  }

  // PRIORITY 1: TUI cable route (if we know the simulator)
  if (clientInfo?.simulatorUdid) {
    const simRoute = getRoute(state, `sim:${clientInfo.simulatorUdid}`)
    if (simRoute) {
      const server = findServerById(state, simRoute.serverId)
      if (server) {
        debugLog(`TUI cable route: sim=${clientInfo.simulatorUdid} -> ${server.root}`)
        return { server, learned: false }
      }
    }
  }

  // PRIORITY 2: cached client->server mapping
  if (clientInfo?.serverId) {
    const server = findServerById(state, clientInfo.serverId)
    if (server) {
      debugLog(`Cached mapping: ${identifier} -> ${server.root}`)
      return { server, learned: false }
    }
  }

  // PRIORITY 3: user-agent app name matching
  const userAgent = headers['user-agent'] || ''
  if (!isGenericExpoAgent(userAgent)) {
    const matchedServer = await matchUserAgentToServer(headers, servers)
    if (matchedServer) {
      debugLog(`UA match: ${extractAppNameFromUA(userAgent)} -> ${matchedServer.root}`)
      await learnMapping(matchedServer, 'user-agent')
      return { server: matchedServer, learned: true }
    }
  }

  // PRIORITY 4: fallback route
  const routeKey = bundleId || 'default'
  const fallbackRoute = getRoute(state, bundleId || '') || getRoute(state, 'default')
  if (fallbackRoute) {
    const server = findServerById(state, fallbackRoute.serverId)
    if (server) {
      debugLog(`Fallback route: ${server.root}`)
      await learnMapping(server, 'auto')
      return { server, learned: true }
    }
  }

  // PRIORITY 5: most recent server
  const mostRecent = [...servers].sort((a, b) => b.registeredAt - a.registeredAt)[0]
  debugLog(`Most recent fallback: ${mostRecent.root}`)
  setRoute(state, routeKey, mostRecent.id)
  await learnMapping(mostRecent, 'auto')
  return { server: mostRecent, learned: true }
}

function proxyAndTouch(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  server: ServerRegistration
) {
  // check for pending mapping - when TUI connected a sim to this server
  const pendingSimId = pendingMappings.get(server.id)
  if (pendingSimId) {
    const identifier = getPrimaryIdentifier(req.headers)
    if (identifier) {
      // KEY: learn that this client identifier belongs to this simulator AND server
      saveClientMapping(identifier, server.id, pendingSimId, 'tui')
      pendingMappings.delete(server.id)
    }
  }

  // mark this server as recently active
  if (activeDaemonState) {
    touchServer(activeDaemonState, server.id)
  }
  proxyHttpRequest(req, res, server)
}

export async function startDaemon(options: DaemonOptions = {}) {
  const port = options.port || DEFAULT_PORT
  const host = options.host || '0.0.0.0'
  const quiet = options.quiet || false

  const log = quiet ? (..._args: any[]) => {} : console.log

  const state = createRegistry()
  activeDaemonState = state

  // recover servers from disk (written by dev servers)
  const persistedServers = readServerFiles()
  for (const ps of persistedServers) {
    // verify server is actually still running
    const alive = await checkServerAlive({ port: ps.port } as ServerRegistration)
    if (alive) {
      registerServer(state, {
        port: ps.port,
        bundleId: ps.bundleId,
        root: ps.root,
      })
      log(colors.cyan(`[daemon] Recovered server: ${ps.bundleId} → :${ps.port}`))
    }
  }

  // start IPC server for CLI communication
  const ipcServer = createIPCServer(
    state,
    (id) => {
      const server = findServerById(state, id)
      if (server) {
        const shortRoot = server.root.replace(process.env.HOME || '', '~')
        log(
          colors.green(
            `[daemon] Server registered: ${server.bundleId} → :${server.port} (${shortRoot})`
          )
        )
      }
    },
    (id) => {
      log(colors.yellow(`[daemon] Server unregistered: ${id}`))
    }
  )

  // create HTTP server
  const httpServer = http.createServer(async (req, res) => {
    debugLog(`${req.method} ${req.url}`)

    // daemon management endpoints
    if (req.url?.startsWith('/__daemon')) {
      await handleDaemonEndpoint(req, res, state)
      return
    }

    // parse app from query string
    const url = new URL(req.url || '/', `http://${req.headers.host}`)
    const bundleId = url.searchParams.get('app')

    // get available servers
    const servers = bundleId
      ? findServersByBundleId(state, bundleId)
      : getAllServers(state)

    if (servers.length === 0) {
      res.writeHead(404)
      res.end(bundleId ? `No server for app: ${bundleId}` : 'No servers registered')
      return
    }

    // resolve which server to use (shared logic)
    const { server } = await resolveServer(state, req.headers, servers, bundleId)

    // set cookie for WebSocket correlation - this is the key fix!
    // WebSocket upgrades will include this cookie, allowing us to route them correctly
    res.setHeader('Set-Cookie', `one-route-id=${server.id}; Path=/; Max-Age=3600`)

    // remember this connection for WebSocket matching (fallback)
    const remotePort = req.socket?.remotePort
    if (remotePort) {
      recentConnections.set(remotePort, { serverId: server.id, timestamp: Date.now() })
      debugLog(`HTTP: port ${remotePort} -> ${server.root}`)
    }

    proxyAndTouch(req, res, server)
  })

  // handle WebSocket upgrades (HMR, etc)
  httpServer.on('upgrade', async (req, rawSocket, head) => {
    const socket = rawSocket as import('node:net').Socket
    const url = new URL(req.url || '/', `http://${req.headers.host}`)
    const bundleId = url.searchParams.get('app')

    const servers = bundleId
      ? findServersByBundleId(state, bundleId)
      : getAllServers(state)

    if (servers.length === 0) {
      socket.end('HTTP/1.1 404 Not Found\r\n\r\n')
      return
    }

    let server: ServerRegistration | undefined

    // PRIORITY 1: cookie-based routing (most reliable)
    // HTTP requests set a cookie with the server ID, WebSocket upgrades include it
    const routeIdFromCookie = getRouteIdFromCookies(req)
    if (routeIdFromCookie) {
      server = findServerById(state, routeIdFromCookie)
      if (server && servers.some((s) => s.id === server!.id)) {
        debugLog(`WebSocket: cookie route -> ${server.root}`)
      } else {
        server = undefined // cookie pointed to invalid server
      }
    }

    // PRIORITY 2: try to match by recent connection from same port (fallback)
    if (!server) {
      const remotePort = req.socket?.remotePort
      if (remotePort) {
        const recent = recentConnections.get(remotePort)
        if (recent && Date.now() - recent.timestamp < CONNECTION_MEMORY_MS) {
          server = findServerById(state, recent.serverId)
          if (server) {
            debugLog(`WebSocket: port ${remotePort} matched to ${server.root}`)
          }
        }
      }
    }

    // PRIORITY 3: fallback to regular resolution
    if (!server) {
      const result = await resolveServer(state, req.headers, servers, bundleId)
      server = result.server
      debugLog(`WebSocket: fallback -> ${server.root}`)
    }

    touchServer(state, server.id)
    proxyWebSocket(req, socket, head, server)
  })

  // start listening
  httpServer.listen(port, host, () => {
    log(colors.cyan('\n═══════════════════════════════════════════════════'))
    log(colors.cyan('  one daemon'))
    log(colors.cyan('═══════════════════════════════════════════════════'))
    log(
      `\n  Listening on ${colors.green(`http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`)}`
    )
    log(`  IPC socket:  ${colors.dim(getSocketPath())}`)
    log('')
    log(colors.dim('  Waiting for dev servers to register...'))
    log(colors.dim("  Run 'one dev' in your project directories"))
    log('')
  })

  // start health check polling to prune dead servers
  const HEALTH_CHECK_INTERVAL = 5000 // 5 seconds
  const healthCheckInterval = setInterval(async () => {
    const prunedCount = await pruneDeadServers(state, (server) => {
      log(
        colors.yellow(
          `[daemon] Pruned dead server: ${server.bundleId} (port ${server.port})`
        )
      )
    })
    if (prunedCount > 0) {
      log(colors.dim(`[daemon] Pruned ${prunedCount} dead server(s)`))
    }
  }, HEALTH_CHECK_INTERVAL)

  // graceful shutdown
  const shutdown = () => {
    log(colors.yellow('\n[daemon] Shutting down...'))
    clearInterval(healthCheckInterval)
    httpServer.close()
    ipcServer.close()
    cleanupSocket()
    process.exit(0)
  }

  process.on('SIGINT', shutdown)
  process.on('SIGTERM', shutdown)

  return {
    httpServer,
    ipcServer,
    state,
    shutdown,
    healthCheckInterval,
  }
}

async function handleDaemonEndpoint(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  state: DaemonState
) {
  const url = new URL(req.url || '/', `http://${req.headers.host}`)

  // GET /__daemon/status
  if (url.pathname === '/__daemon/status') {
    const servers = getAllServers(state)
    const simulators = await getBootedSimulators()
    const simMappings = getSimulatorMappings()
    const simulatorRoutes: Record<string, string> = {}
    for (const [udid, serverId] of simMappings) {
      simulatorRoutes[udid] = serverId
    }

    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(
      JSON.stringify(
        {
          servers: servers.map((s) => ({
            id: s.id,
            port: s.port,
            bundleId: s.bundleId,
            root: s.root,
          })),
          simulators,
          simulatorRoutes,
          routeMode: routeModeOverride || 'most-recent',
        },
        null,
        2
      )
    )
    return
  }

  // POST /__daemon/route?bundleId=...&serverId=...
  if (url.pathname === '/__daemon/route' && req.method === 'POST') {
    const bundleId = url.searchParams.get('bundleId')
    const serverId = url.searchParams.get('serverId')

    if (!bundleId || !serverId) {
      res.writeHead(400)
      res.end('Missing bundleId or serverId')
      return
    }

    const server = findServerById(state, serverId)
    if (!server) {
      res.writeHead(404)
      res.end('Server not found')
      return
    }

    setRoute(state, bundleId, serverId)

    // also resolve any pending picker
    resolvePendingPicker(bundleId, serverId)

    res.writeHead(200)
    res.end('Route set')
    return
  }

  // POST /__daemon/simulator-route?simulatorUdid=...&serverId=...
  // used by tray app to set simulator -> server mappings
  if (url.pathname === '/__daemon/simulator-route' && req.method === 'POST') {
    const simulatorUdid = url.searchParams.get('simulatorUdid')
    const serverId = url.searchParams.get('serverId')

    if (!simulatorUdid || !serverId) {
      res.writeHead(400)
      res.end('Missing simulatorUdid or serverId')
      return
    }

    const server = findServerById(state, serverId)
    if (!server) {
      res.writeHead(404)
      res.end('Server not found')
      return
    }

    // set the mapping (same as TUI cable connect)
    setSimulatorMapping(simulatorUdid, serverId)
    setPendingMapping(serverId, simulatorUdid)
    setRoute(state, `sim:${simulatorUdid}`, serverId)

    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify({ ok: true }))
    return
  }

  // DELETE /__daemon/simulator-route?simulatorUdid=...
  // used by tray app to clear simulator -> server mappings
  if (url.pathname === '/__daemon/simulator-route' && req.method === 'DELETE') {
    const simulatorUdid = url.searchParams.get('simulatorUdid')

    if (!simulatorUdid) {
      res.writeHead(400)
      res.end('Missing simulatorUdid')
      return
    }

    clearMappingsForSimulator(simulatorUdid)
    clearRoute(state, `sim:${simulatorUdid}`)

    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify({ ok: true }))
    return
  }

  res.writeHead(404)
  res.end('Not found')
}
