/**
 * Synology Surveillance Station helper.
 *
 * Mirrors the working Python tool we already use to pull commissioning
 * footage off the NAS. The login is shared and reused across requests (see
 * synologyLogin); every call carries a timeout and re-logs-in once when the
 * NAS reports a dead session (error 105/106/107/119).
 *
 *   const { sid, did } = await synologyLogin()
 *   const recs        = await synologyListRecordings(sid, did, [2], from, to)
 *   await synologyDownloadRecording(sid, did, recs[0].id, '/tmp/foo.mp4')
 *   await synologyLogout(sid, did)
 */

import { createWriteStream } from 'node:fs'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'

// Every NAS call is bounded. Without a timeout an unreachable/hung Surveillance
// Station pins the Nuxt request (and its RangeExport polling loop) for minutes.
const API_TIMEOUT_MS = 15_000
const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000

// DSM error codes that mean "this sid is no longer valid" (not "no data"):
//   105 insufficient privilege (returned for a dead/foreign sid too),
//   106 session timeout, 107 session interrupted, 119 SID not found.
const SESSION_ERROR_CODES = new Set([105, 106, 107, 119])

// One shared login for the whole server instead of login+logout per request:
// fewer auth round-trips, no session-limit surprises when two users open
// recordings at once, and a transparent re-login when the NAS drops the sid.
const SESSION_MAX_AGE_MS = 30 * 60 * 1000
let cachedSession: { session: SynoSession; at: number } | null = null
let loginInflight: Promise<SynoSession> | null = null

/** fetch() with an AbortSignal timeout. */
function synoFetch(url: string, timeoutMs: number = API_TIMEOUT_MS): Promise<Response> {
  return fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
}

export class SynoApiError extends Error {
  code: number | null
  constructor(message: string, code: number | null) {
    super(message)
    this.code = code
  }
}

/**
 * JSON API call with automatic re-login when the NAS says the sid is gone.
 * Mutates `session.sid` in place so callers holding the object keep working.
 * Returns the parsed JSON on success, throws SynoApiError on `success:false`.
 */
async function synoJson(session: SynoSession, params: Record<string, string | number>, retried: boolean = false): Promise<any> {
  const { url } = getConfig()
  const res = await synoFetch(buildEntryUrl(url, { ...params, _sid: session.sid }))
  if (!res.ok) throw new SynoApiError(`HTTP ${res.status}`, null)
  const json: any = await res.json().catch(() => null)
  if (json?.success) return json
  const code = Number(json?.error?.code)
  if (!retried && SESSION_ERROR_CODES.has(code)) {
    invalidateSynologySession()
    const fresh = await synologyLogin()
    session.sid = fresh.sid
    session.did = fresh.did
    return synoJson(session, params, true)
  }
  throw new SynoApiError(`code ${Number.isFinite(code) ? code : 'unknown'}`, Number.isFinite(code) ? code : null)
}

export function invalidateSynologySession(): void {
  cachedSession = null
}

type SynoConfig = {
  url: string
  user: string
  password: string
}

function getConfig(): SynoConfig {
  const config = useRuntimeConfig()
  const url = (config.synology?.url || '').toString().replace(/\/+$/, '')
  const user = (config.synology?.user || '').toString()
  const password = (config.synology?.password || '').toString()
  if (!url || !user || !password) {
    throw createError({
      statusCode: 500,
      statusMessage: 'Synology Surveillance Station is not configured (SYNOLOGY_URL / SYNOLOGY_USER / SYNOLOGY_PASSWORD).'
    })
  }
  return { url, user, password }
}

/** Build `${base}/webapi/entry.cgi?…` with the given params. */
function buildEntryUrl(base: string, params: Record<string, string | number>): string {
  const qs = new URLSearchParams()
  for (const [k, v] of Object.entries(params)) {
    qs.set(k, String(v))
  }
  return `${base}/webapi/entry.cgi?${qs.toString()}`
}

export type SynoSession = {
  sid: string
  did?: string
}

async function doLogin(): Promise<SynoSession> {
  const { url, user, password } = getConfig()
  const target = buildEntryUrl(url, {
    api: 'SYNO.API.Auth',
    method: 'login',
    version: 7,
    account: user,
    passwd: password,
    session: 'SurveillanceStation',
    format: 'sid',
  })

  let res: Response
  try {
    res = await synoFetch(target)
  } catch (err: any) {
    throw createError({
      statusCode: 502,
      statusMessage: `Synology login failed (${err?.name === 'TimeoutError' ? 'timeout' : (err?.message ?? 'network error')}).`
    })
  }
  const json: any = await res.json().catch(() => null)
  if (!res.ok || !json?.success || !json?.data?.sid) {
    const code = json?.error?.code ?? res.status
    throw createError({
      statusCode: 502,
      statusMessage: `Synology login failed (code ${code}).`
    })
  }
  return { sid: json.data.sid, did: json.data.did }
}

/**
 * Returns the shared session (logging in when there is none or it is stale).
 * Callers receive a *copy* so a re-login inside one request never swaps the
 * sid under another request's feet; synoJson re-syncs the copy on demand.
 */
export async function synologyLogin(): Promise<SynoSession> {
  if (cachedSession && Date.now() - cachedSession.at < SESSION_MAX_AGE_MS) {
    return { ...cachedSession.session }
  }
  if (!loginInflight) {
    loginInflight = doLogin()
      .then((session) => {
        cachedSession = { session, at: Date.now() }
        return session
      })
      .finally(() => { loginInflight = null })
  }
  const session = await loginInflight
  return { ...session }
}

/**
 * Kept for API compatibility with the route handlers' `finally` blocks. The
 * session is shared and reused, so this is intentionally a no-op; pass
 * `force = true` to really log the NAS session out (e.g. on shutdown).
 */
export async function synologyLogout(session: SynoSession, force: boolean = false): Promise<void> {
  if (!force) return
  try {
    const { url } = getConfig()
    const target = buildEntryUrl(url, {
      api: 'SYNO.API.Auth',
      method: 'logout',
      version: 7,
      _sid: session.sid,
      session: 'SurveillanceStation',
    })
    await synoFetch(target).catch(() => null)
  } catch {
    // Best-effort logout; ignore failures.
  } finally {
    if (cachedSession?.session.sid === session.sid) cachedSession = null
  }
}

export type SynoRecording = {
  id: number | string
  cameraId: number | string | null
  startTime: number | null
  filePath: string | null
  raw: any
}

/**
 * On some DSM/Surveillance Station builds the List response does NOT
 * include a startTime field at all — instead the recording's start UTC
 * timestamp is embedded as a 13-digit millisecond number in `filePath`,
 * e.g. `Kommissionierung-1-20260503-002000-1777760400027-7.mp4`.
 *
 * Returns the start time in UTC seconds, or null if it can't be parsed.
 */
function parseStartFromFilePath(filePath?: string | null): number | null {
  if (!filePath) return null
  const m = /([0-9]{13})-[0-9]+\.mp4$/.exec(filePath)
  if (!m) return null
  const ms = Number(m[1])
  if (!Number.isFinite(ms) || ms <= 0) return null
  return Math.floor(ms / 1000)
}

/**
 * List recordings overlapping the [from, to] UNIX-timestamp window for the
 * given camera ids. Tries the API parameter formats most SS builds accept,
 * falling back to an unfiltered query + client-side filter.
 */
export async function synologyListRecordings(
  session: SynoSession,
  cameraIds: number[],
  fromTs: number,
  toTs: number,
  apiVersion: number = 6
): Promise<SynoRecording[]> {
  const camCsv = cameraIds.join(',')
  const camBracket = `[${camCsv}]`

  const attempts: Array<Record<string, string | number>> = [
    { cameraIds: camCsv },
    { cameraIds: camBracket },
    {}, // last resort: unfiltered
  ]

  let lastError: any = null
  for (const extra of attempts) {
    let json: any
    try {
      json = await synoJson(session, {
        api: 'SYNO.SurveillanceStation.Recording',
        method: 'List',
        version: apiVersion,
        fromTime: fromTs,
        toTime: toTs,
        ...extra,
      })
    } catch (err: any) {
      lastError = err
      // A timeout / dead NAS won't get better on the next parameter variant.
      if (err?.name === 'TimeoutError' || err?.name === 'AbortError') break
      continue
    }
    const items: any[] = json?.data?.recordings || json?.data?.events || []
    const camSet = new Set(cameraIds.map((c) => Number(c)))
    const filtered = items
      .filter((r) => {
        // If we used the unfiltered fallback, filter client-side.
        if (Object.keys(extra).length === 0) {
          const cid = Number(r?.camera_id ?? r?.cameraId)
          return camSet.has(cid)
        }
        return true
      })
      .map((r) => {
        const filePath = r?.filePath ?? r?.file_path ?? null
        const startTime =
          r?.startTime ??
          r?.start_time ??
          r?.recordingTime ??
          r?.recordTime ??
          parseStartFromFilePath(filePath)
        return {
          id: r?.id ?? r?.recordId,
          cameraId: r?.camera_id ?? r?.cameraId ?? null,
          startTime: startTime ?? null,
          filePath,
          raw: r,
        }
      })
    return filtered
  }

  throw createError({
    statusCode: lastError?.name === 'TimeoutError' ? 504 : 502,
    statusMessage: `Synology Recording.List failed: ${lastError?.name === 'TimeoutError' ? 'timeout' : (lastError?.message ?? lastError?.code ?? 'unknown error')}`
  })
}

/**
 * Stream a recording to disk. Returns the number of bytes written.
 * Used by the init endpoint to cache the file before serving it.
 *
 * `offsetTimeMs` / `playTimeMs` slice a sub-window out of the underlying
 * (typically ~30-min) recording so we don't download the whole file when
 * we only need a few minutes around the commissioning timestamp. Both
 * are optional — passing neither gives the full recording.
 */
export async function synologyDownloadRecording(
  session: SynoSession,
  recordingId: number | string,
  destPath: string,
  apiVersion: number = 6,
  offsetTimeMs?: number,
  playTimeMs?: number
): Promise<number> {
  const { url } = getConfig()
  const params: Record<string, string | number> = {
    api: 'SYNO.SurveillanceStation.Recording',
    method: 'Download',
    version: apiVersion,
    id: recordingId,
    mountId: 0,
    _sid: session.sid,
  }
  if (Number.isFinite(offsetTimeMs) && (offsetTimeMs as number) >= 0) {
    params.offsetTimeMs = Math.floor(offsetTimeMs as number)
  }
  if (Number.isFinite(playTimeMs) && (playTimeMs as number) > 0) {
    params.playTimeMs = Math.floor(playTimeMs as number)
  }
  const target = buildEntryUrl(url, params)

  const res = await synoFetch(target, DOWNLOAD_TIMEOUT_MS)
  if (!res.ok || !res.body) {
    throw createError({
      statusCode: 502,
      statusMessage: `Synology Recording.Download failed (HTTP ${res.status}).`
    })
  }

  // Some SS builds return a JSON error with HTTP 200 if the recording is gone.
  const ct = res.headers.get('content-type') || ''
  if (ct.includes('application/json')) {
    const json: any = await res.json().catch(() => null)
    throw createError({
      statusCode: 502,
      statusMessage: `Synology Recording.Download returned an error (code ${json?.error?.code ?? '?'}).`
    })
  }

  const out = createWriteStream(destPath)
  // Node 18+ converts a WHATWG ReadableStream into a Readable via fromWeb.
  const nodeStream = Readable.fromWeb(res.body as any)
  let written = 0
  nodeStream.on('data', (chunk: Buffer) => { written += chunk.length })
  await pipeline(nodeStream, out)
  return written
}

/**
 * Server-side time-range export. Documented in the Surveillance Station
 * Web API as a 3-step async flow:
 *
 *   1. RangeExport(camId, fromTime, toTime, fileName) → returns dlid
 *   2. GetRangeExportProgress(dlid)                   → poll until progress=100
 *      (must be called within every 20 s to keep the task alive)
 *   3. OnRangeExportDone(dlid, fileName)              → stream the merged MP4
 *      (must be called within 1 min of progress=100, else NAS cleans up)
 *
 * On a continuously-recording camera (one ~30-min file per period) this
 * returns a single MP4 covering the exact window asked for — no local
 * stitching needed. On a motion-triggered camera the result is a merged
 * file with motion gaps elided (same content as our previous ffmpeg
 * concat path, just produced server-side); set the camera back to
 * continuous recording for full timeline alignment.
 *
 * Throws if the NAS reports `progress=-1`, returns a `zip` (which means
 * codec/resolution mismatch in the range and the API gave us a bundle
 * of pieces, not a merged file), or if any step times out.
 */
export async function synologyRangeExport(
  session: SynoSession,
  cameraId: number,
  fromTs: number,
  toTs: number,
  destPath: string,
  fileName: string = 'video',
  apiVersion: number = 6,
): Promise<{ bytes: number; fileExt: 'mp4' | 'zip' }> {
  const { url } = getConfig()

  // Step 1: kick off the export task.
  let startJson: any
  try {
    startJson = await synoJson(session, {
      api: 'SYNO.SurveillanceStation.Recording',
      method: 'RangeExport',
      version: apiVersion,
      camId: cameraId,
      fromTime: fromTs,
      toTime: toTs,
      fileName,
    })
  } catch (err: any) {
    throw createError({
      statusCode: 502,
      statusMessage: `RangeExport start failed (${err?.name === 'TimeoutError' ? 'timeout' : (err?.message ?? 'unknown')}).`
    })
  }
  if (!Number.isFinite(startJson?.data?.dlid)) {
    throw createError({ statusCode: 502, statusMessage: 'RangeExport start failed (no dlid).' })
  }
  const dlid = Number(startJson.data.dlid)

  // Step 2: poll progress every 1.5 s until done. Hard cap at 5 min so a
  // stuck task can't pin the request indefinitely. The doc says we MUST
  // poll at least every 20 s to keep the task alive; 1.5 s is fine.
  let fileExt = ''
  const t0 = Date.now()
  const MAX_MS = 5 * 60 * 1000
  while (Date.now() - t0 < MAX_MS) {
    await new Promise((r) => setTimeout(r, 1500))
    let progressJson: any
    try {
      progressJson = await synoJson(session, {
        api: 'SYNO.SurveillanceStation.Recording',
        method: 'GetRangeExportProgress',
        version: apiVersion,
        dlid,
      })
    } catch (err: any) {
      throw createError({
        statusCode: 502,
        statusMessage: `RangeExport progress failed (${err?.name === 'TimeoutError' ? 'timeout' : (err?.message ?? 'unknown')}).`
      })
    }
    const progress = Number(progressJson?.data?.progress)
    fileExt = String(progressJson?.data?.fileExt ?? '')
    if (progress === -1) {
      throw createError({ statusCode: 502, statusMessage: 'RangeExport task failed (progress=-1).' })
    }
    if (progress === 100) break
  }
  if (fileExt !== 'mp4' && fileExt !== 'zip') {
    throw createError({
      statusCode: 504,
      statusMessage: `RangeExport timed out after ${Math.round((Date.now() - t0) / 1000)}s (last fileExt='${fileExt}').`
    })
  }
  if (fileExt === 'zip') {
    throw createError({
      statusCode: 502,
      statusMessage: 'RangeExport produced a zip (codec/resolution mismatch in the time range). Set the camera to continuous recording with consistent encoding.'
    })
  }

  // Step 3: stream the merged file to disk.
  const downloadUrl = buildEntryUrl(url, {
    api: 'SYNO.SurveillanceStation.Recording',
    method: 'OnRangeExportDone',
    version: apiVersion,
    dlid,
    fileName,
    _sid: session.sid,
  })
  const dlRes = await synoFetch(downloadUrl, DOWNLOAD_TIMEOUT_MS)
  if (!dlRes.ok || !dlRes.body) {
    throw createError({
      statusCode: 502,
      statusMessage: `RangeExport download failed (HTTP ${dlRes.status}).`
    })
  }
  const ct = dlRes.headers.get('content-type') || ''
  if (ct.includes('application/json')) {
    const json: any = await dlRes.json().catch(() => null)
    throw createError({
      statusCode: 502,
      statusMessage: `RangeExport download returned error (code ${json?.error?.code ?? '?'}).`
    })
  }
  const out = createWriteStream(destPath)
  const nodeStream = Readable.fromWeb(dlRes.body as any)
  let written = 0
  nodeStream.on('data', (chunk: Buffer) => { written += chunk.length })
  await pipeline(nodeStream, out)
  return { bytes: written, fileExt: 'mp4' }
}

export type SynoCamera = { id: number; name: string }

/**
 * List the cameras configured in Surveillance Station, as `{ id, name }`.
 * The `id` is the value to store in CUST_CommissionTable.synology_camera_id.
 * Camera.List's version differs across Surveillance Station releases, so we
 * try newest → oldest and read id/name from the several field shapes SS uses.
 */
export async function synologyListCameras(session: SynoSession): Promise<SynoCamera[]> {
  const versions = [9, 8, 7, 6]
  let lastError: any = null
  for (const version of versions) {
    let json: any
    try {
      json = await synoJson(session, {
        api: 'SYNO.SurveillanceStation.Camera',
        method: 'List',
        version,
      })
    } catch (err: any) {
      lastError = err
      if (err?.name === 'TimeoutError' || err?.name === 'AbortError') break
      continue
    }
    const items: any[] = json?.data?.cameras || json?.data?.camera || []
    return items
      .map((c) => ({
        id: Number(c?.id ?? c?.cameraId ?? c?.camera_id),
        name: String(c?.newName ?? c?.name ?? c?.detailInfo?.camName ?? `Camera ${c?.id ?? ''}`).trim(),
      }))
      .filter((c) => Number.isFinite(c.id) && c.id > 0)
      .sort((a, b) => a.id - b.id)
  }
  throw createError({
    statusCode: 502,
    statusMessage: `Synology Camera.List failed: ${lastError?.message ?? lastError?.code ?? 'unknown error'}`
  })
}

export function getDefaultCameraIds(): number[] {
  const config = useRuntimeConfig()
  const raw = (config.synology?.cameraIds || '').toString().trim()
  if (!raw) return [2]
  return raw
    .split(/[,\s]+/)
    .map((s) => Number(s))
    .filter((n) => Number.isFinite(n) && n > 0)
}

export function getWindowSeconds(): { pre: number; post: number } {
  const config = useRuntimeConfig()
  const pre = Number(config.synology?.preSeconds ?? 10)
  const post = Number(config.synology?.postSeconds ?? 180)
  return {
    pre: Number.isFinite(pre) && pre >= 0 ? pre : 10,
    post: Number.isFinite(post) && post >= 0 ? post : 180,
  }
}
