/**
 * POST /api/surveillance/shipment-recording/init
 *
 * Body: { shipmentId: string|number }
 *
 * Reads the shipment's `shipping_date` (set when commissioning happened),
 * pulls the matching recording window off the Synology NAS, caches it
 * to a temp file on the Nuxt server, and returns a token that the
 * `stream` and `cleanup` endpoints use to find that file.
 */

import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomBytes } from 'node:crypto'

import refreshTokenHelper from '../../../utils/refreshTokenHelper'
import getTokenHelper from '../../../utils/getTokenHelper'
import fetchHelper from '../../../utils/fetchHelper'
import {
  synologyLogin,
  synologyLogout,
  synologyListRecordings,
  synologyRangeExport,
  getDefaultCameraIds,
  getWindowSeconds,
} from '../../../utils/synologyHelper'

const CACHE_DIR_NAME = 'logship-surveillance'
// Hard cap on the cached file's lifetime in case the cleanup endpoint is
// never called (browser tab closed mid-stream, network drop, etc.).
const MAX_TTL_MS = 30 * 60 * 1000 // 30 min

function cacheDir(): string {
  return join(tmpdir(), CACHE_DIR_NAME)
}

function isLimitedRole(event: any): boolean {
  // Mirrors the frontend `getMenuType() === 'c'` (customer) check so we
  // never download footage for a limited role even if the button leaks.
  try {
    const raw = getCookie(event, 'logship_role')
    if (!raw) return false
    const role = JSON.parse(raw)
    const fm = role?.FrontendMenu
    const id = typeof fm === 'object' ? (fm?.id ?? fm?.identifier) : fm
    return String(id || '').toLowerCase() === 'c'
  } catch {
    return false
  }
}

const fetchShipmentShippingDate = async (event: any, shipmentId: string | number, authToken: any = null): Promise<string | null> => {
  const token = authToken ?? await getTokenHelper(event)
  const res: any = await fetchHelper(
    event,
    `models/m_inout/${shipmentId}?$select=shipping_date,IsCommissioned,DocumentNo`,
    'GET',
    token,
    null
  )
  const sd = res?.shipping_date
  return typeof sd === 'string' && sd.length > 0 ? sd : null
}

type CommissionActivity = {
  ActionType?: string
  Created?: string
  ActionTime?: string
  SessionUID?: string
  Description?: string
  DurationMs?: number
}

const fetchCommissionActivities = async (event: any, shipmentId: string | number, authToken: any = null): Promise<CommissionActivity[]> => {
  const token = authToken ?? await getTokenHelper(event)
  const res: any = await fetchHelper(
    event,
    `models/cust_commissionactivity?$filter=M_InOut_ID eq ${shipmentId}&$select=ActionType,Created,ActionTime,SessionUID`,
    'GET',
    token,
    null
  )
  const records = res?.records
  return Array.isArray(records) ? records : []
}

/**
 * Like fetchCommissionActivities but also pulls every marker-relevant field
 * so the modal can render a clickable detail panel below the video (scanned
 * product, parcel id, shipping carrier, …). $expand=M_Product_ID is needed
 * to get the full {id, identifier} object for the product, not just the FK.
 * Best-effort.
 */
const fetchAllActivities = async (event: any, shipmentId: string | number, authToken: any = null): Promise<any[]> => {
  const token = authToken ?? await getTokenHelper(event)
  const res: any = await fetchHelper(
    event,
    `models/cust_commissionactivity?$filter=M_InOut_ID eq ${shipmentId}&$expand=M_Product_ID`,
    'GET',
    token,
    null
  )
  const records = res?.records
  return Array.isArray(records) ? records : []
}

// Plain ISO-Z → UNIX seconds. iDempiere `ActionTime` and `shipping_date`
// are real UTC, and Synology Surveillance Station's `Recording.List`
// indexes by real UTC too (verified empirically: filenames embed Berlin
// local clock-time for humans, but the stored ms timestamp matches real
// UTC and `fromTime`/`toTime` parameters do as well). No timezone shift
// is needed — earlier code added a Berlin offset on the assumption
// Synology used "Berlin-local-as-fake-UTC", which it does not; that
// shift threw queries 1–2 h past every recording.
//
// NB: do NOT pass `Created` to this. iDempiere stamps `Created` with
// Berlin local clock-time and a misleading `Z` suffix — parsing it as
// UTC drifts results by the Berlin offset. Use `ActionTime` instead.
const parseTs = (s: string | undefined | null): number | null => {
  if (!s) return null
  const t = Math.floor(new Date(s).getTime() / 1000)
  if (!Number.isFinite(t) || t <= 0) return null
  return t
}

type WindowResolution = {
  fromTs: number
  toTs: number
  source: 'activity-start' | 'activity-complete' | 'shipping-date'
  anchorTs: number  // a representative timestamp for "when commissioning happened"
}

/**
 * Resolves the [from, to] surveillance window for a shipment.
 *
 * Picks the best available anchor timestamp, then applies the SAME
 * `[anchor - pre, anchor + post]` window math regardless of source.
 * Treating activity timestamps the same way as `shipping_date` makes
 * the path symmetric and reliable.
 *
 * Anchor priority:
 *   1. latest `session_start.ActionTime` (when the user began scanning)
 *   2. latest `session_complete.ActionTime` (if no start was logged)
 *   3. `m_inout.shipping_date` (older shipments without activity rows)
 *
 * IMPORTANT: we anchor on `ActionTime`, NOT `Created`. iDempiere stores
 * `ActionTime` as real UTC (like `shipping_date`), but stamps `Created`
 * with Berlin-local clock time and a *misleading* `Z` suffix — parsing
 * `Created` as UTC drifts results forward by the Berlin offset
 * (+1 h CET / +2 h CEST), pointing the Synology query 1–2 hours past
 * the real recording.
 *
 * parseTs is a plain ISO-Z → unix conversion (no shift). Synology's
 * RangeExport / Recording.List `fromTime` / `toTime` parameters are
 * real UTC seconds, matching what we pass straight through.
 */
const resolveWindow = async (event: any, shipmentId: string | number): Promise<WindowResolution> => {
  const { pre, post } = getWindowSeconds()

  // Try the activity log first.
  let activities: CommissionActivity[] = []
  try {
    activities = await fetchCommissionActivities(event, shipmentId)
  } catch (err) {
    try {
      const refreshed = await refreshTokenHelper(event)
      activities = await fetchCommissionActivities(event, shipmentId, refreshed)
    } catch {
      activities = []
    }
  }

  if (activities.length > 0) {
    // Anchor the window to the actual session boundaries when both ends
    // exist: from `session_start - pre` to `session_complete + post`,
    // so the entire scanning + labelling sequence is captured no matter
    // how long it took. SYNOLOGY_POST_SECONDS still applies — it's a
    // small post-roll past the *complete* event, not a hard duration.
    //
    // If only session_start (or only session_complete) is recorded, we
    // fall back to a single-anchor window with the configured pre/post.
    const latestOf = (type: string) => activities
      .filter(a => a.ActionType === type && parseTs(a.ActionTime))
      .sort((a, b) => parseTs(b.ActionTime)! - parseTs(a.ActionTime)!)[0]

    const start = latestOf('session_start')
    const complete = latestOf('session_complete')

    if (start && complete) {
      const startTs = parseTs(start.ActionTime)!
      const completeTs = parseTs(complete.ActionTime)!
      // Defensive: if complete is somehow before start (clock skew /
      // out-of-order activity rows), don't invert the window — fall
      // through to the single-anchor branch using start.
      if (completeTs >= startTs) {
        return {
          fromTs: startTs - pre,
          toTs: completeTs + post,
          anchorTs: startTs,
          source: 'activity-start',
        }
      }
    }

    const anchor = start ?? complete
    if (anchor) {
      const anchorTs = parseTs(anchor.ActionTime)!
      return {
        fromTs: anchorTs - pre,
        toTs: anchorTs + post,
        anchorTs,
        source: start ? 'activity-start' : 'activity-complete',
      }
    }
  }

  // Fallback: shipping_date on the shipment record.
  let shippingDate: string | null = null
  try {
    shippingDate = await fetchShipmentShippingDate(event, shipmentId)
  } catch (err) {
    try {
      const refreshed = await refreshTokenHelper(event)
      shippingDate = await fetchShipmentShippingDate(event, shipmentId, refreshed)
    } catch (error: any) {
      throw createError({ statusCode: 502, statusMessage: 'Failed to load shipment.' })
    }
  }
  if (!shippingDate) {
    throw createError({
      statusCode: 404,
      statusMessage: 'No commissioning activity or shipping_date found for this shipment.'
    })
  }
  const ts = parseTs(shippingDate)
  if (!ts) {
    throw createError({ statusCode: 400, statusMessage: `Invalid shipping_date: ${shippingDate}` })
  }
  // shipping_date is set when commissioning *finishes* (label printed,
  // session_complete fired). The interesting frame is what happens after
  // — sealing the parcel, sticking the label, handover — so we use a
  // narrower window starting at the moment with 2 min of post-roll.
  // Activity-based windows still get the configured pre/post since the
  // activity timestamps anchor on the *start* of scanning.
  return {
    fromTs: ts,
    toTs: ts + 120,
    anchorTs: ts,
    source: 'shipping-date'
  }
}

export default defineEventHandler(async (event) => {
  if (isLimitedRole(event)) {
    throw createError({ statusCode: 403, statusMessage: 'Not allowed for this role.' })
  }

  const body = await readBody(event)
  const shipmentId = body?.shipmentId
  if (!shipmentId) {
    throw createError({ statusCode: 400, statusMessage: 'shipmentId is required.' })
  }

  // 1) Resolve [from, to] (and a representative anchor ts) from commission
  //    activity records, falling back to shipping_date for older orders.
  //    `let` because if the activity-derived window returns zero recordings
  //    (stale/aborted session, clock skew between iDempiere and the NAS,
  //    timestamp landing in a long motion-detection gap), we rebind to the
  //    shipping_date window further down rather than 404 the modal.
  const window = await resolveWindow(event, shipmentId)
  let fromTs = window.fromTs
  let toTs = window.toTs
  let commissionTs = window.anchorTs
  let windowSource: 'activity-start' | 'activity-complete' | 'shipping-date' | 'shipping-date-fallback' = window.source
  const cameraIds = getDefaultCameraIds()

  // 2) Pull all activity rows (not just start/complete) so the modal can
  //    render markers on the timeline + a click-to-detail panel. Best-effort
  //    — if it fails we just ship an empty list and the video still plays.
  type EventOut = {
    ts: number
    actionTime: string
    type: string
    description: string
    durationMs: number
    qty: number | null
    productId: number | string | null
    productName: string | null
    productValue: string | null
    productSku: string | null
    productUpc: string | null
    // Strapi documentId — used by the modal to fetch the product image
    // from `/api/materials/products/{documentId}/galleries` for
    // item_scanned events. Strapi v5's REST API keys `/api/m-products/`
    // by documentId (string), not the numeric Strapi_Product_ID.
    productStrapiDocumentId: string | null
    shippingService: string | null
    // Position inside the merged MP4 (seconds from clip start). Computed
    // server-side because the merged clip skips motion-detection gaps,
    // so the modal can't derive this from wall-clock time alone.
    clipOffsetSec?: number
  }
  let events: EventOut[] = []
  try {
    const all = await fetchAllActivities(event, shipmentId)
    events = all
      .map((a: any) => {
        const p = a.M_Product_ID
        return {
          // Anchor on ActionTime (real UTC). Created is Berlin-local-with-
          // a-lying-Z-suffix and would push every marker 1-2 h past where
          // it belongs on the timeline. See resolveWindow's docblock.
          ts: parseTs(a.ActionTime) ?? 0,
          actionTime: typeof a.ActionTime === 'string' ? a.ActionTime : '',
          type: String(a.ActionType ?? ''),
          description: String(a.Description ?? ''),
          durationMs: Number(a.DurationMs ?? 0) || 0,
          qty: a.Qty != null ? Number(a.Qty) : null,
          productId: p?.id ?? null,
          // With $expand=M_Product_ID we get the full product object — pull
          // Name first; fall back to `identifier` if iDempiere ever returns
          // the un-expanded reference shape.
          productName: p?.Name ?? p?.identifier ?? null,
          productValue: p?.Value ?? null,
          productSku: p?.SKU ?? null,
          productUpc: p?.UPC ?? null,
          productStrapiDocumentId: typeof p?.Strapi_Product_documentId === 'string' && p.Strapi_Product_documentId
            ? p.Strapi_Product_documentId
            : null,
          shippingService: a.ShippingService ?? null,
        }
      })
      .filter((e: EventOut) => e.ts > 0)
      .sort((a: EventOut, b: EventOut) => a.ts - b.ts)
  } catch {
    events = []
  }

  // 3) Talk to Synology: log in, find the matching recording, download it.
  const session = await synologyLogin()
  let token = ''
  let filePath = ''
  let bytes = 0
  let recordingStart: number | null = null
  let clipStart: number | null = null

  try {
    let recs = await synologyListRecordings(session, cameraIds, fromTs, toTs)

    // If the activity-derived window returns nothing, retry with the
    // shipment's shipping_date window. Activity Created can land in a
    // long motion-detection dead zone (cameras here are motion-triggered;
    // ~30 s gaps between adjacent chunks are common), or point at an
    // aborted earlier session. shipping_date is the timestamp the live
    // test harness verifies and is empirically the most reliable anchor.
    if (!recs.length && (windowSource === 'activity-start' || windowSource === 'activity-complete')) {
      let shippingDate: string | null = null
      try {
        shippingDate = await fetchShipmentShippingDate(event, shipmentId)
      } catch {
        try {
          const refreshed = await refreshTokenHelper(event)
          shippingDate = await fetchShipmentShippingDate(event, shipmentId, refreshed)
        } catch {
          shippingDate = null
        }
      }
      const fallbackTs = shippingDate ? parseTs(shippingDate) : null
      if (fallbackTs) {
        // shipping_date is the completion moment — keep the same
        // 0/+120 window the primary shipping-date branch uses so the
        // fallback path is consistent.
        console.warn('[surveillance] activity window empty, retrying with shipping_date', {
          shipmentId, originalSource: windowSource,
          originalFromTs: fromTs, originalToTs: toTs,
          fallbackFromTs: fallbackTs, fallbackToTs: fallbackTs + 120,
        })
        fromTs = fallbackTs
        toTs = fallbackTs + 120
        commissionTs = fallbackTs
        windowSource = 'shipping-date-fallback'
        recs = await synologyListRecordings(session, cameraIds, fromTs, toTs)
      }
    }

    if (!recs.length) {
      throw createError({
        statusCode: 404,
        statusMessage: 'No surveillance recording found for this shipment\'s commissioning window.'
      })
    }

    // The List call above is just a sanity check / fallback trigger —
    // we do NOT download those individual chunks. Instead we hand the
    // [fromTs, toTs] window to Synology's RangeExport API, which
    // returns a single MP4 covering exactly the requested range. With
    // continuous recording the result is timeline-aligned to wall
    // clock 1:1, so marker math is just (event.ts - fromTs).
    await mkdir(cacheDir(), { recursive: true })
    token = randomBytes(16).toString('hex')
    filePath = join(cacheDir(), `${token}.mp4`)

    const cameraId = cameraIds[0]
    const exportResult = await synologyRangeExport(session, cameraId, fromTs, toTs, filePath)
    bytes = exportResult.bytes

    // The clip starts at fromTs in wall-clock terms. Markers position
    // by event.ts - fromTs (clamped to ≥ 0). Falls back gracefully for
    // events that predate the window (e.g. session_start fired a few
    // seconds before fromTs because of clock skew).
    recordingStart = fromTs
    clipStart = fromTs
    events = events.map(e => ({
      ...e,
      clipOffsetSec: Math.max(0, e.ts - fromTs),
    }))
  } finally {
    await synologyLogout(session)
  }

  // 4) Schedule a fallback cleanup so the file can't linger if the modal
  //    is never closed. We don't track the timer; on a successful cleanup
  //    call the unlink will already have happened and this is a no-op.
  setTimeout(() => {
    import('node:fs/promises').then(({ unlink }) => unlink(filePath).catch(() => null))
  }, MAX_TTL_MS).unref?.()

  return {
    token,
    sizeBytes: bytes,
    fromTs,
    toTs,
    commissionTs,
    recordingStartTs: clipStart ?? recordingStart,
    cameraIds,
    windowSource,
    events,
  }
})
