/**
 * Call recordings for a contact (lead / user / partner) — FreePBX CDR rows with
 * a `recordingfile`, matched by the contact's phone numbers, served by the small
 * LAN service on the PBX (`/opt/logship-ivr/recordings.js`, systemd
 * `logship-recordings`, runtimeConfig.pbxRecordings) and transcribed HERE with
 * the same whisper.cpp pipeline the ticket voice memos / staff chat use
 * (`server/utils/chatTranscribe.ts`). Transcripts are cached per recording in
 * `data/call-transcripts/<file>.json` (git-ignored) so a recording is only ever
 * transcribed once. Whisper runs ONE recording at a time per process (serial
 * queue) so a batch never starves the app of CPU.
 *
 * Transcription is ASYNCHRONOUS towards HTTP: the edge nginx (ISPConfig vhost on
 * 192.168.12.51, `location /` without proxy_read_timeout → 60 s default) cuts any
 * request that stays open longer, which every call beyond a few minutes did — the
 * browser got a 504 while whisper kept running and cached the result. So
 * `startTranscription()` writes a pending marker (`<file>.pending.json`, shared
 * between the PM2 instances via disk), runs the job in the background and the
 * route answers 202; the modal polls `transcriptionStatus()` until the cache file
 * exists or the marker carries an error. Keep every request well under 60 s.
 */
import { existsSync } from 'fs'
import { mkdir, readFile, writeFile, unlink } from 'fs/promises'
import { join } from 'path'
import os from 'os'
import { exec } from 'child_process'
import { promisify } from 'util'
import { getPool, extFromChannel } from './cdr'
import { transcribeAudioDetailed, type TranscriptSegment } from '../chatTranscribe'

const pexec = promisify(exec)

export interface CallRecording {
  file: string
  at: string
  direction: 'in' | 'out' | 'internal'
  number: string        // the contact's side
  extension: string     // our side
  answered: boolean
  duration: number      // talk seconds
  legs: number
  linkedid: string
  size?: number
  hasTranscript?: boolean
}
export interface CallTranscript {
  file: string
  language: string
  text: string
  segments: TranscriptSegment[]
  createdAt: string
  audioSeconds?: number
}

const NAME_RE = /^[A-Za-z0-9._#+@-]{8,220}\.(wav|WAV|mp3|gsm)$/
export const validRecordingName = (name: any): string | null => {
  const n = String(name || '').trim()
  return NAME_RE.test(n) && !n.includes('..') ? n : null
}

// ---- number normalisation ------------------------------------------------
export const digitsOf = (n: any) => String(n ?? '').replace(/\D+/g, '')
/** National significant number: strips 00 / + / country code 49 / trunk 0. */
export const nsn = (raw: any): string => {
  let d = digitsOf(raw)
  if (d.startsWith('00')) d = d.slice(2)
  if (d.startsWith('49') && d.length >= 11) d = d.slice(2)
  else if (d.startsWith('0')) d = d.slice(1)
  return d
}
const sameNumber = (a: string, b: string): boolean => {
  const x = nsn(a), y = nsn(b)
  if (!x || !y) return false
  const len = Math.min(x.length, y.length)
  if (len < 7) return x === y
  return x.slice(-len) === y.slice(-len)
}

// ---- CDR lookup ----------------------------------------------------------
export const findRecordings = async (numbers: string[], limit = 300): Promise<{ available: boolean; rows: CallRecording[] }> => {
  const nums = [...new Set(numbers.map((n) => nsn(n)).filter((n) => n.length >= 6))]
  if (!nums.length) return { available: true, rows: [] }
  const pool = getPool()
  if (!pool) return { available: false, rows: [] }
  const where = nums.map(() => '(src LIKE ? OR dst LIKE ?)').join(' OR ')
  const params: string[] = []
  for (const n of nums) { const tail = '%' + n.slice(-7); params.push(tail, tail) }
  const [res] = await pool.query(
    `SELECT calldate, clid, src, dst, channel, dstchannel, disposition, billsec, duration, recordingfile, linkedid FROM cdr WHERE recordingfile <> '' AND (${where}) ORDER BY calldate DESC LIMIT 4000`,
    params
  )
  const rows: any[] = Array.isArray(res) ? (res as any[]) : []
  const byFile = new Map<string, CallRecording>()
  for (const r of rows) {
    const file = String(r.recordingfile || '')
    if (!validRecordingName(file)) continue
    const srcMatch = nums.some((n) => sameNumber(r.src, n))
    const dstMatch = nums.some((n) => sameNumber(r.dst, n))
    if (!srcMatch && !dstMatch) continue
    const at = r.calldate instanceof Date ? r.calldate.toISOString() : new Date(r.calldate).toISOString()
    const answered = String(r.disposition || '') === 'ANSWERED' && Number(r.billsec) > 0
    const ext = [r.channel, r.dstchannel].map((c) => extFromChannel(String(c || ''))).find((e) => e && !/^FM/.test(e)) || ''
    const cur = byFile.get(file)
    if (!cur) {
      byFile.set(file, {
        file, at,
        direction: srcMatch && dstMatch ? 'internal' : srcMatch ? 'in' : 'out',
        number: String(srcMatch ? r.src : r.dst || ''),
        extension: ext,
        answered, duration: Number(r.billsec) || 0, legs: 1, linkedid: String(r.linkedid || '')
      })
    } else {
      cur.legs++
      if (at < cur.at) cur.at = at
      if (answered) { cur.answered = true; cur.duration = Math.max(cur.duration, Number(r.billsec) || 0) }
      if (!cur.extension && ext) cur.extension = ext
    }
  }
  const out = [...byFile.values()].sort((a, b) => (a.at < b.at ? 1 : -1)).slice(0, limit)
  return { available: true, rows: out }
}

// ---- PBX recordings service ----------------------------------------------
const pbxCfg = () => {
  const cfg: any = (useRuntimeConfig() as any).pbxRecordings || {}
  return { baseUrl: String(cfg.baseUrl || '').replace(/\/+$/, ''), secret: String(cfg.secret || '') }
}
export const pbxAvailable = () => { const c = pbxCfg(); return !!(c.baseUrl && c.secret) }

export const statRecordings = async (names: string[]): Promise<Record<string, { size: number; mtime: string } | null>> => {
  const { baseUrl, secret } = pbxCfg()
  if (!baseUrl || !secret || !names.length) return {}
  const out: Record<string, { size: number; mtime: string } | null> = {}
  // POST body (Node 8 on the PBX caps request headers at 8 KB — a GET query string overflows).
  for (let i = 0; i < names.length; i += 500) {
    const chunk = names.slice(i, i + 500)
    try {
      const res: any = await $fetch(`${baseUrl}/recordings/stat`, { method: 'POST', body: { names: chunk }, headers: { 'X-IVR-Secret': secret }, timeout: 15000, retry: 0 })
      Object.assign(out, res || {})
    } catch (err: any) {
      console.warn('[recordings] stat failed:', err?.message || err)
      for (const n of chunk) out[n] = null
    }
  }
  return out
}

export const fetchRecording = async (name: string): Promise<{ buffer: Buffer; type: string }> => {
  const { baseUrl, secret } = pbxCfg()
  if (!baseUrl || !secret) throw new Error('Recordings service not configured')
  const res = await $fetch.raw<ArrayBuffer>(`${baseUrl}/recordings/file/${encodeURIComponent(name)}`, { headers: { 'X-IVR-Secret': secret }, responseType: 'arrayBuffer', timeout: 120000, retry: 0 })
  const type = String(res.headers.get('content-type') || 'audio/wav')
  return { buffer: Buffer.from(res._data as ArrayBuffer), type }
}

// No browser ships a GSM 06.10 decoder, so a .gsm recording's <audio> element
// never fires canplay/error — it just sits at readyState=HAVE_NOTHING forever
// (this is what DialButton.vue's play-button stall-timer comment already
// anticipated, one layer up). ffmpeg (already a hard dependency here — see
// chatTranscribe.ts, which already decodes these same .gsm files fine for
// whisper) transcodes it to mp3, which every browser plays natively. .wav/.mp3
// pass through untouched — both are already browser-playable.
export const fetchPlayableRecording = async (name: string): Promise<{ buffer: Buffer; type: string }> => {
  const { buffer, type } = await fetchRecording(name)
  if (!/\.gsm$/i.test(name)) return { buffer, type }

  const tmpDir = join(process.cwd(), '.tmp-call-recordings')
  const stamp = `${Date.now()}-${Math.floor(Math.random() * 1e9)}`
  const srcPath = join(tmpDir, `${stamp}-${name.replace(/[^\w.\-]/g, '_')}`)
  const mp3Path = srcPath.replace(/\.\w+$/, '') + '.play.mp3'
  try {
    if (!existsSync(tmpDir)) await mkdir(tmpDir, { recursive: true })
    await writeFile(srcPath, buffer)
    await pexec(`ffmpeg -i "${srcPath}" -ar 16000 -ac 1 -b:a 64k -y "${mp3Path}"`, { timeout: 30000 })
    return { buffer: await readFile(mp3Path), type: 'audio/mpeg' }
  } finally {
    unlink(srcPath).catch(() => {})
    unlink(mp3Path).catch(() => {})
  }
}

// ---- transcript cache ------------------------------------------------------
const cacheDir = () => join(process.cwd(), 'data', 'call-transcripts')
const cachePath = (file: string) => join(cacheDir(), file.replace(/[^\w.\-]/g, '_') + '.json')
export const readTranscript = async (file: string): Promise<CallTranscript | null> => {
  try {
    const p = cachePath(file)
    if (!existsSync(p)) return null
    const t = JSON.parse(await readFile(p, 'utf8'))
    return t && typeof t.text === 'string' ? t : null
  } catch { return null }
}
export const hasTranscript = (file: string) => existsSync(cachePath(file))
const writeTranscript = async (t: CallTranscript) => {
  try {
    if (!existsSync(cacheDir())) await mkdir(cacheDir(), { recursive: true })
    await writeFile(cachePath(t.file), JSON.stringify(t), 'utf8')
  } catch (err: any) { console.warn('[recordings] transcript cache write failed:', err?.message || err) }
}

// ---- transcription (serial per process) -------------------------------------
const g = globalThis as any
let queue: Promise<any> = g.__recTranscribeQueue ?? Promise.resolve()
const enqueue = <T>(fn: () => Promise<T>): Promise<T> => {
  const run = queue.then(fn, fn)
  queue = g.__recTranscribeQueue = run.catch(() => {})
  return run
}

// ---- pending marker (shared between PM2 instances via disk) ------------------
export interface TranscriptionJob {
  file: string
  language: string
  startedAt: string
  /** ISO time after which a still-pending marker is treated as dead (process gone). */
  deadline: string
  pid: number
  error?: string
  finishedAt?: string
}
/** Hard cap of one job incl. download + ffmpeg + whisper (whisper itself is capped at 20 min). */
const JOB_MAX_MS = 25 * 60000
/** Marker error meaning "whisper produced no text" (→ 422, not a server failure). */
const NO_SPEECH = 'no-speech'
const pendingPath = (file: string) => cachePath(file).replace(/\.json$/, '.pending.json')
const readJob = async (file: string): Promise<TranscriptionJob | null> => {
  try {
    const p = pendingPath(file)
    if (!existsSync(p)) return null
    const j = JSON.parse(await readFile(p, 'utf8'))
    return j && j.file ? j : null
  } catch { return null }
}
const writeJob = async (j: TranscriptionJob) => {
  try {
    if (!existsSync(cacheDir())) await mkdir(cacheDir(), { recursive: true })
    await writeFile(pendingPath(j.file), JSON.stringify(j), 'utf8')
  } catch (err: any) { console.warn('[recordings] job marker write failed:', err?.message || err) }
}
const clearJob = async (file: string) => { try { await unlink(pendingPath(file)) } catch { /* ignore */ } }
const pidAlive = (pid: number) => { if (!pid || pid === process.pid) return true; try { process.kill(pid, 0); return true } catch (err: any) { return err?.code === 'EPERM' } }
/** Records a failure on the marker (keeps the original start time when the marker still exists). */
const failJob = async (file: string, language: string, error: string) => {
  const now = new Date().toISOString()
  const prev = await readJob(file)
  await writeJob({ file, language, startedAt: prev?.startedAt || now, deadline: prev?.deadline || now, pid: process.pid, error, finishedAt: now })
}

export type TranscriptionStatus =
  | { state: 'done'; transcript: CallTranscript; cached: boolean }
  | { state: 'pending'; startedAt: string }
  | { state: 'failed'; message: string; noSpeech?: boolean }
  | { state: 'none' }

/** Current state of a recording's transcription: cache file → done, marker → pending/failed. */
export const transcriptionStatus = async (file: string): Promise<TranscriptionStatus> => {
  const cached = await readTranscript(file)
  if (cached && cached.text) return { state: 'done', transcript: cached, cached: true }
  const job = await readJob(file)
  if (!job) return { state: 'none' }
  if (job.error) return { state: 'failed', message: job.error, noSpeech: job.error === NO_SPEECH }
  if (new Date(job.deadline).getTime() < Date.now()) return { state: 'failed', message: 'Transkription abgebrochen (Zeitlimit überschritten)' }
  // Both PM2 instances run on this host, so a marker whose process is gone (restart/deploy mid-job) is dead.
  if (!pidAlive(job.pid)) return { state: 'failed', message: 'Transkription abgebrochen (Server wurde neu gestartet) – bitte erneut starten' }
  return { state: 'pending', startedAt: job.startedAt }
}

/** The actual work — serial per process, result lands in the cache file, errors in the marker. */
const runTranscriptionJob = (file: string, language: string): Promise<CallTranscript | null> => enqueue(async () => {
  try {
    const { buffer } = await fetchRecording(file)
    if (buffer.length < 1000) { await failJob(file, language, NO_SPEECH); return null }
    // FreePBX records 8 kHz 16-bit mono WAV → 16 kB/s. Whisper base is ~real-time-ish on
    // a busy host, so allow 3× the audio length (+ 60 s), capped at 20 minutes.
    const audioSeconds = /\.wav$/i.test(file) ? Math.round(buffer.length / 16000) : 0
    const timeoutMs = Math.min(20 * 60000, Math.max(120000, audioSeconds * 3000 + 60000))
    const threads = Math.max(1, Math.min(4, (os.cpus()?.length || 2) - 1))
    const res = await transcribeAudioDetailed(buffer, file, language === 'auto' ? undefined : language, { timeoutMs, extraArgs: ['-t', String(threads)] })
    if (!res.text) { await failJob(file, language, NO_SPEECH); return null }
    const transcript: CallTranscript = { file, language, text: res.text, segments: res.segments, createdAt: new Date().toISOString(), audioSeconds }
    await writeTranscript(transcript)
    await clearJob(file)
    return transcript
  } catch (err: any) {
    const msg = String(err?.message || err || 'Transcription failed')
    console.warn('[recordings] transcribe job failed:', file, msg)
    await failJob(file, language, msg)
    return null
  }
})

/**
 * Starts (or joins) the transcription of one recording and returns immediately:
 * the cached transcript when it exists, otherwise `pending` — the work continues
 * in the background of THIS process. A fresh marker from another process/request
 * is joined, a finished/failed/dead one is replaced (that's also what `force` does).
 */
export const startTranscription = async (file: string, language = 'de', force = false): Promise<TranscriptionStatus> => {
  if (!force) {
    const status = await transcriptionStatus(file)
    if (status.state === 'done' || status.state === 'pending') return status
  } else {
    // force = re-transcribe: drop the marker AND the cached transcript, otherwise the old
    // cache would answer "done" while the new job is still running.
    await clearJob(file)
    try { await unlink(cachePath(file)) } catch { /* ignore */ }
  }
  const startedAt = new Date().toISOString()
  await writeJob({ file, language, startedAt, deadline: new Date(Date.now() + JOB_MAX_MS).toISOString(), pid: process.pid })
  runTranscriptionJob(file, language).catch(() => { /* recorded in the marker */ })
  return { state: 'pending', startedAt }
}

/** Waits up to `maxWaitMs` for a running job (polls the disk state) so short calls still resolve in one request. */
export const waitForTranscription = async (file: string, maxWaitMs = 20000, stepMs = 1000): Promise<TranscriptionStatus> => {
  const until = Date.now() + maxWaitMs
  let status = await transcriptionStatus(file)
  while (status.state === 'pending' && Date.now() < until) {
    await new Promise((r) => setTimeout(r, stepMs))
    status = await transcriptionStatus(file)
  }
  return status
}

/** Transcribe one recording and wait for it (cached). Returns null when whisper produced nothing. */
export const transcribeRecording = async (file: string, language = 'de', force = false): Promise<{ transcript: CallTranscript; cached: boolean } | null> => {
  const started = await startTranscription(file, language, force)
  if (started.state === 'done') return { transcript: started.transcript, cached: true }
  const final = await waitForTranscription(file, JOB_MAX_MS, 1000)
  if (final.state === 'done') return { transcript: final.transcript, cached: false }
  if (final.state === 'failed' && !final.noSpeech) throw new Error(final.message)
  return null
}
