/**
 * Click-to-dial call session: originate on Asterisk, then follow the call
 * DETACHED from the HTTP request until it ends, and record the outcome as a
 * C_ContactActivity on the called user (phoneActivity.ts).
 *
 * Flow (classic click-to-dial):
 *   1. AMI Originate  Channel=PJSIP/<ext> → rings the agent's device directly.
 *      NOT Local/<ext>@from-internal: FreePBX Follow-Me's announcement answers
 *      a Local leg before anyone picks up, so Asterisk would report "answered"
 *      and dial the target while the agent's phone is still ringing (verified).
 *   2. agent picks up → Asterisk runs Exten=<number>@from-internal → dials out
 *   3. events on the channels that inherited __LOGSHIP_CALL_ID tell us what
 *      happened (manager.conf `channelvars=LOGSHIP_CALL_ID` exposes the var on
 *      every channel event as `ChanVariable: LOGSHIP_CALL_ID=<id>`, and on the
 *      destination side of DialBegin/DialEnd as `DestChanVariable: …`)
 *
 * State machine
 *   WAIT_AGENT      → Originate accepted; agent's phone rings
 *   AGENT_ANSWERED  → OriginateResponse Success (Reason 4) or DialEnd ANSWER on the agent device
 *   AGENT_FAILED    → OriginateResponse Failure (no answer / busy / …)      → finalize
 *   TARGET_RINGING  → DialBegin from a channel of ours to a non-agent device
 *   TALKING         → DialEnd ANSWER on that destination
 *   TARGET_FAILED   → DialEnd BUSY/NOANSWER/CANCEL/CONGESTION/CHANUNAVAIL (trunk failover may dial again)
 *   FINALIZED       → every tracked channel hung up (+2 s grace) | socket lost | hard cap 3 h
 *
 * The HTTP promise resolves as soon as the Originate is ACCEPTED. Everything
 * afterwards is fire-and-forget with its own error handling and unref'd timers,
 * one TCP connection per call (nothing shared, nothing to reconnect, nothing
 * that can wedge the process). PM2 runs 2 instances: each instance only ever
 * tracks the calls it originated — foreign events are dropped by the uuid filter.
 */
import { randomUUID } from 'node:crypto'
import { connectAmi, amiValue, amiChanVariable, type AmiClient, type AmiPacket } from './amiClient'
import { createPhoneCallActivity, type PhoneCallContext, type PhoneCallOutcome, type TargetStatus } from './phoneActivity'
import { listPbxExtensions } from './extensions'

const HARD_CAP_MS = 3 * 60 * 60 * 1000
const HANGUP_GRACE_MS = 2000
const ORIGINATE_ACK_TIMEOUT_MS = 10000
const MAX_ACTIVE_CALLS = 20
/** After the originate is queued, wait this long for an EARLY failure of the
 *  agent leg (device unreachable / rejected) so the click gets a real error
 *  instead of a silent "ringing" — the agent's phone answers far later anyway. */
const EARLY_FAILURE_WINDOW_MS = 1500

type State = 'WAIT_AGENT' | 'AGENT_ANSWERED' | 'AGENT_FAILED' | 'TARGET_RINGING' | 'TALKING' | 'TARGET_FAILED' | 'FINALIZED'

interface Tracker {
  ctx: PhoneCallContext
  state: State
  agentExt: string
  agentAnsweredAt: number | null
  targetUniqueid: string | null
  targetStatus: TargetStatus
  targetAnsweredAt: number | null
  targetHangupAt: number | null
  lastHangupAt: number | null
  hangupCause: string
  live: Set<string>
  sawDial: boolean
}

const g = globalThis as any
const active: Map<string, Tracker> = g.__dialTrackers ?? (g.__dialTrackers = new Map())

export const activeCallCount = () => active.size

/** True while a click-to-dial for this extension is still ringing the agent's phone. */
export const hasPendingAgentLeg = (ext: string) => {
  for (const t of active.values()) if (t.agentExt === ext && t.state === 'WAIT_AGENT') return true
  return false
}

/**
 * Digits only; `(0)` after a country code dropped; `+` → `00`; separators
 * stripped. Returns '' when unusable. Feature codes (`*43`) only when allowed.
 */
export const normalizePhoneNumber = (raw: any, allowFeatureCodes = false): string => {
  let s = String(raw ?? '').trim()
  if (!s) return ''
  if (allowFeatureCodes && /^\*\d{2,3}$/.test(s)) return s
  s = s.replace(/\(0\)/g, '').replace(/^\+/, '00').replace(/[\s\-/().]/g, '')
  if (!/^\d{3,20}$/.test(s)) return ''
  return s
}

const mapDialStatus = (s: string): TargetStatus => {
  switch (String(s || '').toUpperCase()) {
    case 'ANSWER': return 'answered'
    case 'BUSY': return 'busy'
    case 'NOANSWER': return 'noanswer'
    case 'CANCEL': return 'cancel'
    case 'CONGESTION': return 'congestion'
    case 'CHANUNAVAIL': case 'INVALIDARGS': case 'DONTCALL': case 'TORTURE': return 'chanunavail'
    default: return 'unknown'
  }
}

const originateReason = (r: string): string => {
  switch (String(r || '')) {
    case '0': return 'Nebenstelle nicht erreichbar'
    case '1': return 'abgelehnt / aufgelegt'
    case '3': return 'keine Antwort'
    case '5': return 'besetzt'
    case '8': return 'Überlastung'
    default: return `Fehler (Reason ${r || '?'})`
  }
}

export interface StartCallInput {
  callerUserId: number
  callerName: string
  callerExtension: string
  orgId: number
  number: string
  targetUserId: number | null
  targetPartnerId: number | null
  targetLabel: string
}

export const startCall = async (input: StartCallInput): Promise<{ callId: string }> => {
  const config = useRuntimeConfig()
  const cfg = (config as any).asterisk || {}
  if (!cfg.enabled) throw createError({ statusCode: 503, statusMessage: 'Click-to-Dial ist deaktiviert' })
  if (!cfg.secret) throw createError({ statusCode: 503, statusMessage: 'Telefonanlage nicht konfiguriert' })
  if (active.size >= MAX_ACTIVE_CALLS) throw createError({ statusCode: 429, statusMessage: 'Zu viele aktive Anrufe' })
  if (hasPendingAgentLeg(input.callerExtension)) throw createError({ statusCode: 409, statusMessage: 'Ihr Telefon klingelt bereits' })

  const callId = randomUUID()
  const ctx: PhoneCallContext = { callId, startedAt: Date.now(), ...input }
  const agentRingTimeoutMs = Number(cfg.agentRingTimeoutMs || 30000)
  // Agent-side destinations: the extension's device(s) plus FreePBX Follow-Me
  // legs (Local/FMPR-<ext>@… = the extension itself, Local/FMGL-… = the
  // follow-me number list). Everything else dialed after the agent answered
  // is the target leg.
  const agentDevicePrefixes = [`PJSIP/${input.callerExtension}-`, `SIP/${input.callerExtension}-`, `Local/FMPR-${input.callerExtension}@`, 'Local/FMGL-']
  const isAgentDevice = (ch: string) => agentDevicePrefixes.some((p) => ch.startsWith(p))

  let ami: AmiClient
  try {
    ami = await connectAmi({ host: cfg.host, port: cfg.port, username: cfg.username, secret: cfg.secret })
  } catch (err: any) {
    console.warn(`[dial ${callId}] AMI connect/login failed:`, err?.message || err)
    throw createError({ statusCode: 502, statusMessage: 'Telefonanlage nicht erreichbar' })
  }

  const t: Tracker = {
    ctx, state: 'WAIT_AGENT', agentExt: input.callerExtension, agentAnsweredAt: null,
    targetUniqueid: null, targetStatus: 'unknown', targetAnsweredAt: null, targetHangupAt: null,
    lastHangupAt: null, hangupCause: '', live: new Set(), sawDial: false
  }

  let finalized = false
  let earlyFail: ((msg: string) => void) | null = null
  let graceTimer: NodeJS.Timeout | null = null
  let agentTimer: NodeJS.Timeout | null = null
  let hardCapTimer: NodeJS.Timeout | null = null
  let unsubscribe = () => {}

  const clearTimers = () => {
    if (graceTimer) clearTimeout(graceTimer)
    if (agentTimer) clearTimeout(agentTimer)
    if (hardCapTimer) clearTimeout(hardCapTimer)
    graceTimer = agentTimer = hardCapTimer = null
  }

  /** Tear down without writing anything (originate never got accepted). */
  const abort = () => {
    finalized = true
    clearTimers()
    unsubscribe()
    active.delete(callId)
    try { ami.close() } catch { /* ignore */ }
  }

  const finalize = (note: string) => {
    if (finalized) return
    finalized = true
    clearTimers()
    unsubscribe()
    active.delete(callId)
    try { ami.close() } catch { /* ignore */ }
    const endedAt = t.targetHangupAt || t.lastHangupAt || Date.now()
    if (t.agentAnsweredAt && !t.sawDial && t.targetStatus === 'unknown') note = [note, 'kein Wählvorgang beobachtet'].filter(Boolean).join('; ')
    const outcome: PhoneCallOutcome = {
      agentAnswered: !!t.agentAnsweredAt,
      targetStatus: t.targetStatus,
      answeredAt: t.targetAnsweredAt,
      endedAt,
      hangupCause: t.hangupCause,
      note
    }
    t.state = 'FINALIZED'
    console.info(`[dial ${callId}] finalize: agent=${outcome.agentAnswered} target=${outcome.targetStatus} duration=${outcome.answeredAt ? Math.round((endedAt - outcome.answeredAt) / 1000) + 's' : '-'}${note ? ' (' + note + ')' : ''}`)
    createPhoneCallActivity(ctx, outcome).catch(() => {})
  }

  const armGrace = () => {
    if (graceTimer) clearTimeout(graceTimer)
    graceTimer = setTimeout(() => { if (t.live.size === 0) finalize('') }, HANGUP_GRACE_MS)
    graceTimer.unref?.()
  }

  const isOurs = (evt: AmiPacket) =>
    amiChanVariable(evt, 'LOGSHIP_CALL_ID') === callId || amiChanVariable(evt, 'LOGSHIP_CALL_ID', 'Dest') === callId

  const track = (uid: string) => { if (uid) t.live.add(uid) }

  const onEvent = (evt: AmiPacket) => {
    try {
      const name = amiValue(evt, 'Event')
      if (name === 'OriginateResponse') {
        if (amiValue(evt, 'ActionID') !== callId) return
        if (agentTimer) { clearTimeout(agentTimer); agentTimer = null }
        if (amiValue(evt, 'Response') === 'Success') {
          track(amiValue(evt, 'Uniqueid'))
          if (!t.agentAnsweredAt) t.agentAnsweredAt = Date.now()
          if (t.state === 'WAIT_AGENT') t.state = 'AGENT_ANSWERED'
        } else if (t.state === 'WAIT_AGENT') {
          t.state = 'AGENT_FAILED'
          const reason = originateReason(amiValue(evt, 'Reason'))
          t.hangupCause = `Anrufer-Leg: ${reason}`
          t.lastHangupAt = Date.now()
          if (earlyFail) { earlyFail(reason); earlyFail = null; abort(); return }
          finalize('')
        }
        return
      }
      if (!isOurs(evt)) return
      if (graceTimer) { clearTimeout(graceTimer); graceTimer = null } // a late channel showed up

      const uid = amiValue(evt, 'Uniqueid')
      const destUid = amiValue(evt, 'DestUniqueid')
      const destChan = amiValue(evt, 'DestChannel')

      if (name === 'Newchannel') { track(uid); return }

      if (name === 'DialBegin') {
        track(uid); track(destUid)
        if (isAgentDevice(destChan)) return // the agent leg ringing
        if (!t.agentAnsweredAt) return      // follow-me / other legs before the agent picked up
        t.targetUniqueid = destUid || null
        t.targetStatus = 'unknown'
        t.sawDial = true
        t.state = 'TARGET_RINGING'
        return
      }

      if (name === 'DialEnd') {
        const status = mapDialStatus(amiValue(evt, 'DialStatus'))
        if (isAgentDevice(destChan)) {
          if (status === 'answered' && !t.agentAnsweredAt) { t.agentAnsweredAt = Date.now(); if (t.state === 'WAIT_AGENT') t.state = 'AGENT_ANSWERED' }
          return
        }
        if (t.targetUniqueid && destUid && destUid !== t.targetUniqueid) return
        if (t.state !== 'TARGET_RINGING' && t.state !== 'TARGET_FAILED') return
        t.targetStatus = status
        if (status === 'answered') { t.state = 'TALKING'; t.targetAnsweredAt = Date.now() }
        else t.state = 'TARGET_FAILED'
        return
      }

      if (name === 'Hangup') {
        t.live.delete(uid)
        t.lastHangupAt = Date.now()
        if (uid && uid === t.targetUniqueid) {
          t.targetHangupAt = Date.now()
          const txt = amiValue(evt, 'Cause-txt')
          if (txt) t.hangupCause = txt
        } else if (!t.hangupCause) {
          const txt = amiValue(evt, 'Cause-txt')
          if (txt) t.hangupCause = txt
        }
        if (t.live.size === 0) armGrace()
        return
      }
    } catch (err: any) {
      console.warn(`[dial ${callId}] event handling error:`, err?.message || err)
    }
  }

  // Subscribe BEFORE originating so no early event is missed.
  unsubscribe = ami.onEvent(onEvent)
  ami.onClosed((reason) => { if (!finalized) finalize(`AMI-Verbindung verloren: ${reason}`) })
  hardCapTimer = setTimeout(() => finalize('Zeitlimit erreicht (3 h)'), HARD_CAP_MS)
  hardCapTimer.unref?.()
  agentTimer = setTimeout(() => { if (t.state === 'WAIT_AGENT') finalize('keine Rückmeldung der Telefonanlage zum Anrufer-Leg') }, agentRingTimeoutMs + 15000)
  agentTimer.unref?.()
  active.set(callId, t)

  // CallerID = the TARGET (name label + number): that is what the agent's phone
  // displays while it rings. Verified on the LogYou PBX: using the agent's own
  // extension as caller number makes the softphone reject the call (SIP 404 /
  // "Unallocated number") and Follow-Me is skipped — the target number works
  // and the outbound leg then uses the trunk's caller ID (FreePBX default for
  // click-to-dial).
  const cidName = `Anruf: ${(input.targetLabel || '').replace(/["<>\r\n]/g, '').substring(0, 40)}`.replace(/\s+/g, ' ').trim().substring(0, 60)
  // Channel: a device registered on THIS PBX is rung directly (PJSIP/<ext>);
  // anything else (e.g. an extension on the remote FreePBX reached through the
  // trunk route 2XX) must go through the dialplan → Local/<ext>@from-internal/n.
  let channelTemplate: string = cfg.channelTemplate || 'PJSIP/{ext}'
  if (!cfg.channelTemplate || cfg.channelTemplate === 'PJSIP/{ext}') {
    const devices = await listPbxExtensions().catch(() => [])
    if (devices.length > 0 && !devices.some((d) => d.ext === input.callerExtension)) channelTemplate = 'Local/{ext}@{context}/n'
  }
  const channel = channelTemplate.replace('{ext}', input.callerExtension).replace('{context}', cfg.context)

  try {
    const res = await ami.send('Originate', {
      Channel: channel,
      Exten: input.number,
      Context: cfg.context,
      Priority: 1,
      Timeout: agentRingTimeoutMs,
      CallerID: `"${cidName}" <${input.number}>`,
      Async: 'true',
      EarlyMedia: 'false',
      Variable: `__LOGSHIP_CALL_ID=${callId}`
    }, ORIGINATE_ACK_TIMEOUT_MS, callId)
    if (amiValue(res, 'Response') !== 'Success') {
      const msg = amiValue(res, 'Message') || 'Originate abgelehnt'
      abort()
      console.warn(`[dial ${callId}] originate rejected:`, msg)
      throw createError({ statusCode: 502, statusMessage: `Anruf abgelehnt: ${msg}` })
    }
  } catch (err: any) {
    if (err?.statusCode) throw err
    abort()
    console.warn(`[dial ${callId}] originate failed:`, err?.message || err)
    throw createError({ statusCode: 502, statusMessage: 'Anruf konnte nicht gestartet werden' })
  }

  console.info(`[dial ${callId}] originate accepted: ${channel} → ${input.number} (user ${input.callerUserId}, target user ${input.targetUserId ?? '-'} / partner ${input.targetPartnerId ?? '-'})`)

  // Fast-fail window: an unreachable / rejecting device fails within ~0.5 s.
  const early = await new Promise<string | null>((resolve) => {
    const timer = setTimeout(() => { earlyFail = null; resolve(null) }, EARLY_FAILURE_WINDOW_MS)
    timer.unref?.()
    earlyFail = (msg) => { clearTimeout(timer); resolve(msg) }
  })
  if (early) {
    console.warn(`[dial ${callId}] agent leg failed immediately: ${early}`)
    throw createError({ statusCode: 502, statusMessage: `Ihr Telefon (Nebenstelle ${input.callerExtension}) ist nicht erreichbar: ${early}` })
  }
  return { callId }
}
