import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { DateTime } from 'luxon'

/**
 * Login sessions (`user_sessions`). One row per refresh token family, so users and
 * admins can see where an account is signed in and revoke a single session.
 *
 * Writing a session must never break signing in or refreshing: errors are only logged.
 */

export type SessionEndReason = 'logout' | 'reuse' | 'password_reset'

type Request = HttpContext['request']

function sqlDate(date: DateTime) {
  return date.toUTC().toFormat('yyyy-MM-dd HH:mm:ss')
}

/** Called when a new refresh token family starts (sign in, email verification, mobile login). */
export async function startSession(
  sessionId: string,
  userId: string,
  request: Request,
  expiresAt: DateTime
) {
  const userAgent = (request.header('user-agent') ?? '').slice(0, 512)
  const ua = describeUserAgent(userAgent)
  const now = sqlDate(DateTime.now())
  const ip = request.ip()?.slice(0, 45) ?? null

  try {
    await db.table('user_sessions').insert({
      id: sessionId,
      user_id: userId,
      platform: isAppRequest(request) ? 'app' : 'web',
      browser: ua.browser,
      os: ua.os,
      device_type: ua.deviceType,
      device_model: ua.deviceModel,
      app_version: isAppRequest(request) ? appVersion(request) : null,
      user_agent: userAgent || null,
      ip,
      last_ip: ip,
      created_at: now,
      last_used_at: now,
      expires_at: sqlDate(expiresAt),
    })
  } catch (error) {
    logger.warn({ err: error }, 'could not start user session')
  }
}

/** Called on every token refresh. Creates the row when the session predates this table. */
export async function touchSession(
  sessionId: string,
  userId: string,
  request: Request,
  expiresAt: DateTime
) {
  // The app may have been updated since the last refresh
  const version = isAppRequest(request) ? appVersion(request) : null

  try {
    const updated = await db
      .from('user_sessions')
      .where('id', sessionId)
      .update({
        last_used_at: sqlDate(DateTime.now()),
        last_ip: request.ip()?.slice(0, 45) ?? null,
        expires_at: sqlDate(expiresAt),
        ...(version ? { app_version: version } : {}),
      })
    if (!Number(Array.isArray(updated) ? updated[0] : updated)) {
      await startSession(sessionId, userId, request, expiresAt)
    }
  } catch (error) {
    logger.warn({ err: error }, 'could not update user session')
  }
}

/** Marks sessions as ended. The refresh tokens themselves are revoked by the caller. */
export async function endSessions(
  where: { sessionId: string } | { userId: string },
  reason: SessionEndReason
) {
  try {
    const query = db.from('user_sessions').whereNull('revoked_at')
    if ('sessionId' in where) query.where('id', where.sessionId)
    else query.where('user_id', where.userId)
    await query.update({ revoked_at: sqlDate(DateTime.now()), revoked_reason: reason })
  } catch (error) {
    logger.warn({ err: error }, 'could not end user session')
  }
}

/** The Vitrap app sends this header from its webview (see app.vitrap authInstance). */
function isAppRequest(request: Request) {
  return request.header('X-Client-Platform')?.toLowerCase() === 'native'
}

/** The app's version ("1.2.0"), from the X-App-Version header. Older builds don't send it. */
function appVersion(request: Request) {
  const version = request.header('X-App-Version')?.trim()
  return version && /^\d+\.\d+\.\d+$/.test(version) ? version.slice(0, 20) : null
}

/**
 * Rough, dependency-free user agent parsing: enough to recognise a device in a list.
 * Order matters: e.g. Edge and Opera also say "Chrome", and Chrome also says "Safari".
 */
export function describeUserAgent(ua: string) {
  const match = (re: RegExp) => ua.match(re)?.[1]

  let os: string | null = null
  if (/Windows NT/.test(ua)) os = 'Windows'
  else if (/Android/.test(ua)) os = withVersion('Android', match(/Android (\d+)/))
  else if (/iPhone|iPad|iPod/.test(ua)) os = withVersion('iOS', match(/OS (\d+)_/))
  else if (/Macintosh|Mac OS X/.test(ua)) os = 'macOS'
  else if (/CrOS/.test(ua)) os = 'ChromeOS'
  else if (/Linux/.test(ua)) os = 'Linux'

  let browser: string | null = null
  if (/; wv\)/.test(ua)) browser = 'Android WebView'
  else if (/Edg(?:A|iOS)?\//.test(ua)) browser = withVersion('Edge', match(/Edg(?:A|iOS)?\/(\d+)/))
  else if (/OPR\/|Opera/.test(ua)) browser = withVersion('Opera', match(/OPR\/(\d+)/))
  else if (/SamsungBrowser/.test(ua))
    browser = withVersion('Samsung Internet', match(/SamsungBrowser\/(\d+)/))
  else if (/Firefox\/|FxiOS/.test(ua))
    browser = withVersion('Firefox', match(/(?:Firefox|FxiOS)\/(\d+)/))
  else if (/Chrome\/|CriOS/.test(ua))
    browser = withVersion('Chrome', match(/(?:Chrome|CriOS)\/(\d+)/))
  else if (/Safari\//.test(ua)) browser = withVersion('Safari', match(/Version\/(\d+)/))

  let deviceType: 'desktop' | 'mobile' | 'tablet' | null = null
  if (ua) {
    if (/iPad|Tablet/.test(ua) || (/Android/.test(ua) && !/Mobile/.test(ua))) deviceType = 'tablet'
    else if (/Mobi|iPhone|iPod|Android/.test(ua)) deviceType = 'mobile'
    else deviceType = 'desktop'
  }

  // Android user agents may name the model ("Pixel 8"); modern Chrome reduces it to "K".
  let deviceModel: string | null = null
  // That reduced form also always says "Android 10", so the version means nothing then.
  const androidModel = match(/Android [\d.]+; ([^;)]+?)(?: Build\/[^;)]*)?(?:;|\))/)
  if (androidModel === 'K') os = 'Android'
  else if (androidModel) deviceModel = androidModel.trim().slice(0, 50)
  else if (/iPhone/.test(ua)) deviceModel = 'iPhone'
  else if (/iPad/.test(ua)) deviceModel = 'iPad'

  return { browser, os, deviceType, deviceModel }
}

function withVersion(name: string, version: string | undefined) {
  return version ? `${name} ${version}` : name
}
