import crypto from 'node:crypto'
import path from 'node:path'

import sharp from 'sharp'

import { runtimeConfig } from '../../config/runtime.js'
import { createFileAsset, type FileAssetRow } from '../../repositories/file-asset-repo.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
import { logWarn } from '../../utils/logger.js'

const IMAGE_VARIANT_THUMB = 'thumb'
const IMAGE_VARIANT_MEDIUM = 'medium'
const THUMB_MAX_SIDE = 480
const MEDIUM_MAX_SIDE = 1280
const FILE_URL_TTL_SECONDS = 15 * 60

const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])

const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])

export type UploadedFileDto = {
  objectKey: string
  url: string
  thumbnailUrl: string
  mediumUrl: string
  filename: string
  contentType: string
  size: number
}

type UploadFileAssetInput = {
  file: Express.Multer.File | null | undefined
  scene: unknown
  uploaderType: string
  uploaderId: string
}

export async function uploadFileAsset(input: UploadFileAssetInput) {
  const file = input.file
  if (!file?.buffer?.length) {
    throw createHttpError('请选择要上传的文件', {
      statusCode: 400,
      errorCode: 'upload_file_required',
    })
  }

  const maxUploadSize = getMaxUploadSizeBytes()
  if (file.buffer.length > maxUploadSize) {
    throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, {
      statusCode: 400,
      errorCode: 'upload_file_too_large',
    })
  }

  const contentType = normalizeContentType(file.mimetype, file.buffer)
  if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
    throw createHttpError('文件不符合规则，仅支持 JPG、PNG、WebP 或 PDF', {
      statusCode: 400,
      errorCode: 'upload_file_type_invalid',
    })
  }

  const scene = normalizeScene(input.scene)
  const objectKey = createObjectKey(scene, file.originalname, contentType)
  const storage = getObjectStorage()
  const uploadedKeys: string[] = []
  try {
    uploadedKeys.push(objectKey)
    await storage.putObject({
      key: objectKey,
      content: file.buffer,
      contentType,
      metadata: {
        'original-filename': file.originalname,
      },
    })
    const variants = await generateImageVariants(objectKey, file.buffer, contentType)
    let thumbnailObjectKey = ''
    let mediumObjectKey = ''

    for (const variant of variants) {
      try {
        uploadedKeys.push(variant.key)
        await storage.putObject({
          key: variant.key,
          content: variant.content,
          contentType: variant.contentType,
          metadata: {
            'source-object': objectKey,
          },
        })
        if (variant.name === IMAGE_VARIANT_THUMB) {
          thumbnailObjectKey = variant.key
        }
        if (variant.name === IMAGE_VARIANT_MEDIUM) {
          mediumObjectKey = variant.key
        }
      } catch {
        // 缩略图失败不阻断原图上传；预览接口会回退到原图。
      }
    }

    const now = nowIso()
    const fileUrl = buildUnsignedFileUrl(objectKey)
    const thumbnailUrl = thumbnailObjectKey ? buildUnsignedFileUrl(thumbnailObjectKey) : fileUrl
    const mediumUrl = mediumObjectKey ? buildUnsignedFileUrl(mediumObjectKey) : fileUrl
    const asset = await createFileAsset({
      objectKey,
      scene,
      originalFilename: file.originalname,
      contentType,
      sizeBytes: file.buffer.length,
      thumbnailObjectKey,
      mediumObjectKey,
      url: fileUrl,
      thumbnailUrl,
      mediumUrl,
      uploaderType: String(input.uploaderType || '').trim(),
      uploaderId: String(input.uploaderId || '').trim(),
      metadataJson: JSON.stringify({
        originalMimeType: file.mimetype,
        originalSize: file.size,
      }),
      now,
    })
    if (!asset) {
      throw new Error('文件元数据写入失败')
    }

    return {
      file: mapUploadedFile(asset, {
        objectKey,
        fileUrl,
        thumbnailUrl,
        mediumUrl,
        filename: file.originalname,
        contentType,
        size: file.buffer.length,
      }),
    }
  } catch (error) {
    const cleanupResults = await Promise.allSettled(
      uploadedKeys.map((key) => storage.deleteObject(key)),
    )
    const cleanupFailures = cleanupResults.filter(
      (result): result is PromiseRejectedResult => result.status === 'rejected',
    )
    if (cleanupFailures.length > 0) {
      void logWarn('[file-storage]', '上传失败后的对象清理未完全成功', {
        objectKeys: uploadedKeys,
        failedCount: cleanupFailures.length,
        errors: cleanupFailures.map((result) =>
          result.reason instanceof Error ? result.reason.message : String(result.reason || ''),
        ),
      })
    }
    throw error
  }
}

export async function getStoredFileObject(
  key: unknown,
  variant: unknown = '',
  access: { expires?: unknown; signature?: unknown } = {},
) {
  const objectKey = normalizeObjectKey(key)
  verifyFileAccessSignature(objectKey, access.expires, access.signature)
  const variantName = normalizeVariant(variant)
  const candidates = variantName ? imageVariantFallbackKeys(objectKey, variantName) : [objectKey]
  let lastError: unknown = null

  for (const candidate of candidates) {
    try {
      return await getObjectStorage().getObject(candidate)
    } catch (error) {
      lastError = error
    }
  }

  throw createHttpError('文件不存在或暂不可访问', {
    statusCode: 404,
    errorCode: 'file_not_found',
    cause: lastError,
  })
}

export function getMaxUploadSizeBytes() {
  return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
}

export function buildFileUrl(objectKey: string, nowSeconds = Math.floor(Date.now() / 1000)) {
  const normalizedKey = normalizeObjectKey(objectKey)
  const expires = nowSeconds + FILE_URL_TTL_SECONDS
  const signature = createFileAccessSignature(normalizedKey, expires)
  return `${buildUnsignedFileUrl(normalizedKey)}&expires=${expires}&signature=${encodeURIComponent(signature)}`
}

export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '') {
  const rawUrl = String(value || '').trim()
  const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
  return objectKey ? buildFileUrl(objectKey) : rawUrl
}

export function normalizeStoredFileUrl(value: unknown, fallbackObjectKey = '') {
  const rawUrl = String(value || '').trim()
  const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
  return objectKey ? buildUnsignedFileUrl(objectKey) : rawUrl
}

export function refreshUploadedFileUrls(value: unknown) {
  const source =
    value && typeof value === 'object' && !Array.isArray(value)
      ? (value as Record<string, unknown>)
      : {}
  const objectKey = String(source.objectKey || source.object_key || '').trim()
  if (!objectKey) return source
  return {
    ...source,
    objectKey,
    url: buildFileUrl(objectKey),
    thumbnailUrl: refreshStoredFileUrl(source.thumbnailUrl || source.thumbnail_url, objectKey),
    mediumUrl: refreshStoredFileUrl(source.mediumUrl || source.medium_url, objectKey),
  }
}

export function verifyFileAccessSignature(
  objectKey: string,
  expiresValue: unknown,
  signatureValue: unknown,
  nowSeconds = Math.floor(Date.now() / 1000),
) {
  const expires = Number(expiresValue || 0)
  const signature = String(signatureValue || '').trim()
  if (!Number.isSafeInteger(expires) || expires < nowSeconds || !signature) {
    throw createHttpError('文件访问链接无效或已过期', {
      statusCode: 403,
      errorCode: 'file_access_expired',
    })
  }
  const expected = createFileAccessSignature(normalizeObjectKey(objectKey), expires)
  const expectedBuffer = Buffer.from(expected)
  const actualBuffer = Buffer.from(signature)
  if (
    expectedBuffer.length !== actualBuffer.length ||
    !crypto.timingSafeEqual(expectedBuffer, actualBuffer)
  ) {
    throw createHttpError('文件访问签名不正确', {
      statusCode: 403,
      errorCode: 'file_access_signature_invalid',
    })
  }
}

export function imageVariantKey(objectKey: string, variant: string) {
  const ext = path.extname(objectKey)
  const base = ext ? objectKey.slice(0, -ext.length) : objectKey
  return `${base}.${variant}.jpg`
}

function mapUploadedFile(
  asset: FileAssetRow | null,
  fallback: {
    objectKey: string
    fileUrl: string
    thumbnailUrl: string
    mediumUrl: string
    filename: string
    contentType: string
    size: number
  },
): UploadedFileDto {
  return {
    objectKey: asset?.object_key || fallback.objectKey,
    url: buildFileUrl(asset?.object_key || fallback.objectKey),
    thumbnailUrl: buildFileUrl(
      asset?.thumbnail_object_key ||
        extractObjectKeyFromFileUrl(fallback.thumbnailUrl) ||
        fallback.objectKey,
    ),
    mediumUrl: buildFileUrl(
      asset?.medium_object_key ||
        extractObjectKeyFromFileUrl(fallback.mediumUrl) ||
        fallback.objectKey,
    ),
    filename: asset?.original_filename || fallback.filename,
    contentType: asset?.content_type || fallback.contentType,
    size: Number(asset?.size_bytes || fallback.size || 0),
  }
}

function buildUnsignedFileUrl(objectKey: string) {
  return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}`
}

function createFileAccessSignature(objectKey: string, expires: number) {
  const secret =
    String(runtimeConfig.admin.sessionSecret || '').trim() ||
    String(runtimeConfig.storage.secretAccessKey || '').trim()
  return crypto.createHmac('sha256', secret).update(`${objectKey}\n${expires}`).digest('base64url')
}

function extractObjectKeyFromFileUrl(value: string) {
  if (!value) return ''
  try {
    const parsed = new URL(value, 'http://localhost')
    if (parsed.pathname !== '/api/v1/files/object') return ''
    return normalizeObjectKey(parsed.searchParams.get('key'))
  } catch {
    return ''
  }
}

function normalizeContentType(mimeType: unknown, data: Buffer) {
  const detected = detectContentType(data)
  if (detected) {
    return detected
  }

  return (
    String(mimeType || '')
      .trim()
      .toLowerCase()
      .split(';')[0] || 'application/octet-stream'
  )
}

function detectContentType(data: Buffer) {
  if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
    return 'image/jpeg'
  }
  if (
    data.length >= 8 &&
    data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
  ) {
    return 'image/png'
  }
  if (
    data.length >= 12 &&
    data.subarray(0, 4).toString('ascii') === 'RIFF' &&
    data.subarray(8, 12).toString('ascii') === 'WEBP'
  ) {
    return 'image/webp'
  }
  if (data.length >= 4 && data.subarray(0, 4).toString('ascii') === '%PDF') {
    return 'application/pdf'
  }
  return ''
}

function normalizeScene(value: unknown) {
  const scene = String(value || '')
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9_-]+/g, '-')
    .replace(/^-+|-+$/g, '')
  return scene || 'misc'
}

function createObjectKey(scene: string, filename: string, contentType: string) {
  const now = new Date()
  const token = crypto.randomBytes(12).toString('hex')
  const ext = extensionForContentType(contentType) || path.extname(filename).toLowerCase() || '.bin'
  const month = String(now.getMonth() + 1).padStart(2, '0')
  const day = String(now.getDate()).padStart(2, '0')
  return `${scene}/${now.getFullYear()}/${month}/${day}/${token}${ext}`
}

function extensionForContentType(contentType: string) {
  if (contentType === 'image/jpeg') return '.jpg'
  if (contentType === 'image/png') return '.png'
  if (contentType === 'image/webp') return '.webp'
  if (contentType === 'application/pdf') return '.pdf'
  return ''
}

async function generateImageVariants(objectKey: string, data: Buffer, contentType: string) {
  if (!IMAGE_CONTENT_TYPES.has(contentType)) {
    return []
  }

  const configs = [
    { name: IMAGE_VARIANT_THUMB, maxSide: THUMB_MAX_SIDE, quality: 76 },
    { name: IMAGE_VARIANT_MEDIUM, maxSide: MEDIUM_MAX_SIDE, quality: 82 },
  ]
  const variants: Array<{ name: string; key: string; content: Buffer; contentType: string }> = []

  for (const config of configs) {
    try {
      const content = await sharp(data)
        .rotate()
        .resize({
          width: config.maxSide,
          height: config.maxSide,
          fit: 'inside',
          withoutEnlargement: true,
        })
        .flatten({ background: '#ffffff' })
        .jpeg({ quality: config.quality, mozjpeg: true })
        .toBuffer()
      variants.push({
        name: config.name,
        key: imageVariantKey(objectKey, config.name),
        content,
        contentType: 'image/jpeg',
      })
    } catch {
      return variants
    }
  }

  return variants
}

function imageVariantFallbackKeys(objectKey: string, variant: string) {
  const keys = [imageVariantKey(objectKey, variant), objectKey]
  return [...new Set(keys)]
}

function normalizeVariant(value: unknown) {
  const variant = String(value || '')
    .trim()
    .toLowerCase()
  if (variant === IMAGE_VARIANT_THUMB || variant === IMAGE_VARIANT_MEDIUM) {
    return variant
  }
  return ''
}
