/**
 * LogShip Mobile (Android) release channel — server-side storage + signed download URLs.
 *
 * Files live OUTSIDE the build in `data/mobile-releases/` on the host (git-ignored, preserved by
 * the deploy script's DATA_EXTRA list) so a new APK can be published at any time without an ERP
 * deployment — via the admin/service upload endpoint (server/api/settings/mobile-app/publish.post.ts)
 * or by simply dropping the files into the directory (read on every request, never cached).
 *
 * Layout (identical to the old logyou.de/logship web root so it can be seeded 1:1):
 *   version.json           { versionName, versionCode, apkUrl: 'logship-mobile.apk', releaseNotes,
 *                            releaseDate, minRequiredVersion, sha256?, size? }
 *   history.json           [ { versionName, versionCode, releaseDate, releaseNotes, apkUrl: 'history/…' } ]
 *   logship-mobile.apk     current build
 *   history/logship-mobile-<versionName>-<versionCode>.apk   archived builds
 *
 * Downloads: Android's DownloadManager (AppUpdatePlugin.java) sends NO auth header, so the download
 * URL itself is the credential — `/api/public/mobile/<expires>.<hmac>/<file>`, HMAC-SHA256 over
 * `<expires>|<file>` with MOBILE_APP_DOWNLOAD_SECRET. The manifest routes (session-gated) embed
 * such URLs with a short TTL; the QR install page gets a longer one.
 */
import { createHmac, createHash, timingSafeEqual } from 'crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync, readdirSync } from 'fs'
import path from 'path'
import fetchHelper from './fetchHelper'
import { isMobileWorkerUser } from './mobileWorkerHelper'

export const CURRENT_APK = 'logship-mobile.apk'
export const HISTORY_SUBDIR = 'history'

export interface ReleaseManifest {
  versionName: string
  versionCode: number
  apkUrl: string
  releaseNotes?: string
  releaseDate?: string
  minRequiredVersion?: string
  sha256?: string
  size?: number
}
export interface HistoryEntry {
  versionName: string
  versionCode: number
  releaseDate?: string
  releaseNotes?: string
  apkUrl: string
  sha256?: string
  size?: number
}

export const releasesDir = () => path.join(process.cwd(), 'data', 'mobile-releases')
export const historyDir = () => path.join(releasesDir(), HISTORY_SUBDIR)

const readJson = <T>(file: string, fallback: T): T => {
  try {
    if (!existsSync(file)) return fallback
    return JSON.parse(readFileSync(file, 'utf8')) as T
  } catch (err: any) {
    console.warn('[MobileReleases] cannot read', file, err?.message || err)
    return fallback
  }
}

export const readManifest = (): ReleaseManifest | null => {
  const m = readJson<ReleaseManifest | null>(path.join(releasesDir(), 'version.json'), null)
  if (!m || !m.versionName) return null
  return { ...m, versionCode: Number(m.versionCode) || 0, apkUrl: m.apkUrl || CURRENT_APK }
}

export const readHistory = (): HistoryEntry[] => {
  const list = readJson<any>(path.join(releasesDir(), 'history.json'), [])
  return Array.isArray(list) ? list : []
}

/**
 * Only two shapes are ever served: the current APK and archived ones in history/.
 * Returns the absolute path or null (also when the file does not exist).
 */
export const safeApkPath = (rel: string): string | null => {
  const r = String(rel || '').replace(/^\/+/, '')
  let abs: string | null = null
  if (r === CURRENT_APK) abs = path.join(releasesDir(), CURRENT_APK)
  else {
    const m = /^history\/([A-Za-z0-9._-]+\.apk)$/.exec(r)
    if (m && !m[1].includes('..')) abs = path.join(historyDir(), m[1])
  }
  if (!abs || !existsSync(abs)) return null
  return abs
}

// --------------------------------------------------------------------------
// Signed URLs
// --------------------------------------------------------------------------
const cfg = () => {
  const c: any = useRuntimeConfig()
  return {
    secret: String(c.mobileApp?.downloadSecret || ''),
    publicBase: String(c.mobileApp?.publicBase || 'https://app.logship.de').replace(/\/+$/, ''),
    keepHistory: Number(c.mobileApp?.keepHistory ?? 10),
    legacyFallback: c.mobileApp?.legacyFallback !== false
  }
}

const hmac = (secret: string, expires: number, rel: string) =>
  createHmac('sha256', secret).update(`${expires}|${rel}`).digest('hex')

export const isDownloadSecretConfigured = () => !!cfg().secret

/** Absolute, expiring download URL for a relative APK path (`logship-mobile.apk` or `history/x.apk`). */
export const signDownloadUrl = (rel: string, ttlSec: number): string => {
  const { secret, publicBase } = cfg()
  if (!secret) throw new Error('MOBILE_APP_DOWNLOAD_SECRET is not configured')
  const expires = Math.floor(Date.now() / 1000) + Math.max(60, ttlSec)
  const token = `${expires}.${hmac(secret, expires, rel)}`
  return `${publicBase}/api/public/mobile/${token}/${rel}`
}

export const verifyDownloadToken = (token: string, rel: string): { ok: boolean, reason?: string } => {
  const { secret } = cfg()
  if (!secret) return { ok: false, reason: 'not configured' }
  const m = /^(\d{1,12})\.([a-f0-9]{64})$/.exec(String(token || ''))
  if (!m) return { ok: false, reason: 'malformed' }
  const expires = Number(m[1])
  if (expires < Math.floor(Date.now() / 1000)) return { ok: false, reason: 'expired' }
  const expected = Buffer.from(hmac(secret, expires, rel), 'hex')
  const given = Buffer.from(m[2], 'hex')
  if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { ok: false, reason: 'invalid' }
  return { ok: true }
}

export const TTL_MANIFEST = 6 * 3600
export const TTL_INSTALL_PAGE = 24 * 3600

export const manifestWithSignedUrl = (m: ReleaseManifest, ttl = TTL_MANIFEST): ReleaseManifest =>
  ({ ...m, apkUrl: signDownloadUrl(CURRENT_APK, ttl) })

export const historyWithSignedUrls = (list: HistoryEntry[], ttl = TTL_MANIFEST): HistoryEntry[] =>
  list
    .filter(e => e && e.apkUrl)
    .map(e => ({ ...e, apkUrl: safeApkPath(e.apkUrl) ? signDownloadUrl(String(e.apkUrl).replace(/^\/+/, ''), ttl) : e.apkUrl }))

export const legacyFallbackEnabled = () => cfg().legacyFallback

// --------------------------------------------------------------------------
// Publishing
// --------------------------------------------------------------------------
export interface PublishInput {
  apk: Buffer
  versionName: string
  versionCode: number
  releaseNotes?: string
  releaseDate?: string
  minRequiredVersion?: string
  force?: boolean
}

let publishLock: Promise<any> = Promise.resolve()

const sha256Of = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex')

const writeJsonAtomic = (file: string, data: any) => {
  const tmp = `${file}.${process.pid}.tmp`
  writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n')
  renameSync(tmp, file)
}

/**
 * Publish a new build: archive the current APK into history/ (+ history.json entry, pruned to
 * keepHistory), write the new APK, then version.json LAST so a device never sees a manifest
 * pointing at a half-written file. Serialised per process.
 */
export const publishRelease = (input: PublishInput): Promise<{ manifest: ReleaseManifest, archived: string | null }> => {
  const run = async () => {
    const versionName = String(input.versionName || '').trim()
    const versionCode = Number(input.versionCode)
    if (!/^\d+(\.\d+){1,3}$/.test(versionName)) throw new Error(`Invalid versionName "${versionName}" (expected e.g. 1.0.41)`)
    if (!Number.isInteger(versionCode) || versionCode <= 0) throw new Error('Invalid versionCode (positive integer required)')
    if (!input.apk || input.apk.length < 1024) throw new Error('APK file missing or empty')
    // APK = ZIP container → must start with the local-file-header magic "PK\x03\x04"
    if (!(input.apk[0] === 0x50 && input.apk[1] === 0x4b && input.apk[2] === 0x03 && input.apk[3] === 0x04)) {
      throw new Error('File is not an APK (ZIP signature missing)')
    }

    const dir = releasesDir()
    mkdirSync(historyDir(), { recursive: true })
    const current = readManifest()
    if (current && !input.force) {
      if (versionCode <= current.versionCode) {
        throw new Error(`versionCode ${versionCode} must be greater than the published ${current.versionCode} (use force to override)`)
      }
    }

    // 1) write the new APK next to the current one
    const finalApk = path.join(dir, CURRENT_APK)
    const partApk = `${finalApk}.part`
    writeFileSync(partApk, input.apk)

    // 2) archive the current APK (if any and if it is a different version)
    let archived: string | null = null
    if (current && existsSync(finalApk) && current.versionCode !== versionCode) {
      const name = `logship-mobile-${current.versionName}-${current.versionCode}.apk`
      const dest = path.join(historyDir(), name)
      if (existsSync(dest)) unlinkSync(dest)
      renameSync(finalApk, dest)
      archived = name
      let hist = readHistory().filter(h => Number(h.versionCode) !== current.versionCode)
      hist.unshift({
        versionName: current.versionName,
        versionCode: current.versionCode,
        releaseDate: current.releaseDate || '',
        releaseNotes: current.releaseNotes || '',
        apkUrl: `${HISTORY_SUBDIR}/${name}`,
        ...(current.sha256 ? { sha256: current.sha256 } : {}),
        ...(current.size ? { size: current.size } : {})
      })
      hist.sort((a, b) => (Number(b.versionCode) || 0) - (Number(a.versionCode) || 0))
      const keep = cfg().keepHistory
      if (keep > 0 && hist.length > keep) {
        for (const dropped of hist.splice(keep)) {
          const f = safeApkPath(dropped.apkUrl)
          if (f) { try { unlinkSync(f) } catch {} }
        }
      }
      writeJsonAtomic(path.join(dir, 'history.json'), hist)
    } else if (existsSync(finalApk)) {
      unlinkSync(finalApk) // same version re-published (force) → just replace
    }
    if (!existsSync(path.join(dir, 'history.json'))) writeJsonAtomic(path.join(dir, 'history.json'), [])

    // 3) promote the new APK, 4) manifest last
    renameSync(partApk, finalApk)
    const manifest: ReleaseManifest = {
      versionName,
      versionCode,
      apkUrl: CURRENT_APK,
      releaseNotes: String(input.releaseNotes ?? current?.releaseNotes ?? ''),
      releaseDate: String(input.releaseDate || new Date().toISOString().slice(0, 10)),
      minRequiredVersion: String(input.minRequiredVersion || current?.minRequiredVersion || '1.0.0'),
      sha256: sha256Of(finalApk),
      size: statSync(finalApk).size
    }
    writeJsonAtomic(path.join(dir, 'version.json'), manifest)
    console.log(`[MobileReleases] published ${versionName} (code ${versionCode})${archived ? `, archived ${archived}` : ''}`)
    return { manifest, archived }
  }
  const p = publishLock.then(run, run)
  publishLock = p.catch(() => {})
  return p
}

/** Fill in sha256/size for a seeded manifest that predates those fields (fail-soft, in memory only). */
export const enrichManifest = (m: ReleaseManifest): ReleaseManifest => {
  if (m.sha256 && m.size) return m
  const f = safeApkPath(CURRENT_APK)
  if (!f) return m
  try { return { ...m, size: m.size || statSync(f).size, sha256: m.sha256 || sha256Of(f) } } catch { return m }
}

export const listArchivedFiles = (): string[] => {
  try { return readdirSync(historyDir()).filter(f => f.endsWith('.apk')) } catch { return [] }
}

// --------------------------------------------------------------------------
// Access helpers
// --------------------------------------------------------------------------
const parseCookieJson = (event: any, name: string): any => {
  try {
    const raw = getCookie(event, name)
    if (!raw) return null
    return typeof raw === 'string' ? JSON.parse(raw) : raw
  } catch { return null }
}

const truthy = (v: any) => v === true || v === 'Y' || v === 'true' || v === 1 || v === '1'

/** Cookie-level admin flag (same trust level as the rest of the app's UI gating). */
export const isAdminCookie = (event: any): boolean => truthy(parseCookieJson(event, 'logship_role')?.IsClientAdministrator)

/** May this session use the mobile app? IsMobileWorker on the user, or a client administrator. */
export const canUseMobileApp = (event: any): boolean => {
  const user = parseCookieJson(event, 'logship_user')
  return isMobileWorkerUser(user) || isAdminCookie(event)
}

/** `Authorization: Bearer <service token>` (the non-expiring SuperUser token from .env) — used by the release script. */
export const isServiceTokenRequest = (event: any): boolean => {
  const c: any = useRuntimeConfig()
  const expected = String(c.api?.idempieretoken || '')
  const header = String(getHeader(event, 'authorization') || '')
  const given = header.replace(/^Bearer\s+/i, '').trim()
  if (!expected || !given) return false
  const a = Buffer.from(expected), b = Buffer.from(given)
  return a.length === b.length && timingSafeEqual(a, b)
}

const adminCache: Map<string, { ok: boolean, expires: number }> =
  (globalThis as any).__mobileAdminCache ?? ((globalThis as any).__mobileAdminCache = new Map())

/** Server-authoritative admin check: the role's IsClientAdministrator resolved against iDempiere (10-min cache). */
export const isAdminVerified = async (event: any, token: string): Promise<boolean> => {
  const roleId = getCookie(event, 'logship_role_id')
  if (!roleId || !token) return false
  const key = `${roleId}:${token.slice(-16)}`
  const hit = adminCache.get(key)
  if (hit && hit.expires > Date.now()) return hit.ok
  let ok = false
  try {
    const res: any = await fetchHelper(event, `models/ad_role/${roleId}?$select=IsClientAdministrator`, 'GET', token, null)
    ok = truthy(res?.IsClientAdministrator ?? res?.isClientAdministrator)
  } catch (err: any) {
    console.warn('[MobileReleases] admin check failed:', err?.data?.message || err?.message || err)
  }
  adminCache.set(key, { ok, expires: Date.now() + 10 * 60 * 1000 })
  return ok
}
