import { Elysia, redirect, t } from "elysia"
import ExifReader from "exifreader"
import { exists, mkdir } from "fs/promises"
import { staticPlugin } from "@elysiajs/static"
import sharp from "sharp"
import { unlink } from "fs/promises"
import path from "path"
import { readdir } from "fs/promises"
import { log } from "console"

// --- Configuration ---
const FOLDER_BASE = "./data"
const FOLDER_PHOTOS = `${FOLDER_BASE}/photos`
const FILE_METADATA = `${FOLDER_BASE}/metadata.json`

// --- Types ---
// Interface for our metadata entries
interface PhotoMetadata {
  id: string
  owner_key: string // This will be filtered out for public requests
  upload_time: string
  original_photo: string
  edited_photo: string
  thumbnail: string
  thumbnail_edited: string
  exif: Record<string, string>
  [key: string]: any // Custom properties
}

class HttpError extends Error {
  constructor(public status: number, public content: string | object) {
    super()
  }
}
function done(status: number, content: string | object): never {
  throw new HttpError(status, content)
}

/**
 * Loads the metadata.json file.
 * Returns an empty array if the file doesn't exist.
 */
async function getMetadata(): Promise<PhotoMetadata[]> {
  const file = Bun.file(FILE_METADATA)
  if (!(await file.exists())) return []
  return await file.json()
}

/**
 * Safely saves the metadata array to metadata.json.
 */
async function saveMetadata(data: PhotoMetadata[]): Promise<void> {
  try {
    await Bun.write(FILE_METADATA, JSON.stringify(data, null, 2))
  } catch (e) {
    console.error("Error: Could not write to 'metadata.json'.", e)
  }
}

/**
 * Extracts EXIF data from an uploaded file.
 */
async function getExifData(file: File): Promise<any> {
  // const whitelist = ["Image Height", "Image Width", "Subsampling", "DateTime", "ExposureProgram",
  //   "ISOSpeedRatings", "SensitivityType", "RecommendedExposureIndex",
  //   "DateTimeOriginal", "OffsetTime", "BrightnessValue", "MeteringMode", "ExposureMode", "ColorSpace",
  //   "FNumber", "ExposureTime", "FocalLength", "FocalLengthIn35mmFilm", "ScaleFactorTo35mmEquivalent", "FieldOfView", "ApertureValue", "ExposureBiasValue", "Flash", "WhiteBalance", "FocalLengthIn35mmFormat",
  //   "LensModel", "LensSpecification"]
  const rawRaw = ExifReader.load(await file.arrayBuffer(), {
    expanded: false,
    includeUnknown: false,
  })
  const raw = JSON.parse(JSON.stringify(rawRaw))

  const data = Object.entries(raw)
    // .filter(([key, _]) => whitelist.includes(key))
    .reduce((acc, [key, value]) => {
    acc[key] = (value as any)?.description as string
    return acc
  }, {} as Record<string, string>)

  return data
}

async function generateThumbnail(originalPath: string, thumbnailPath: string) {
  const bytes = await Bun.file(originalPath).arrayBuffer()
  await sharp(bytes).resize({ width: 1000 }).webp({ quality: 80 }).toFile(thumbnailPath)
}

// --- Elysia Server ---

// Load secrets and ensure directories exist before starting
const INSTANT_KEY = process.env.INSTANT_KEY!
if (!await exists(FOLDER_PHOTOS)) await mkdir(FOLDER_PHOTOS, { recursive: true })

console.log("Server started.")

function checkHeaderKey(headers: Record<string, string | undefined>, expectList: string[] = [INSTANT_KEY]) {
  const key = headers["x-instant-key"]
  if (!expectList.includes(key ?? "")) done(401, "Invalid authentication key")
}


// Retroactively generate missing edit thumbnails on startup
for (const dirent of await readdir(FOLDER_PHOTOS)) {
  const photoDir = path.join(FOLDER_PHOTOS, dirent)
  const thumbEditPath = path.join(photoDir, "thumb.edit.webp")
  if (!(await exists(thumbEditPath))) {
    const editedPhotoPath = path.join(photoDir, "edit.jpg")
    if (await exists(editedPhotoPath)) {
      console.log(`[+] Generating missing edited thumbnail for ${dirent}`)
      await generateThumbnail(editedPhotoPath, thumbEditPath)
    }
    // Add metadata
    const metadata = await getMetadata()
    const photoIndex = metadata.findIndex((p) => p.id === dirent)
    if (photoIndex !== -1) {
      const photo = metadata[photoIndex]
      photo.thumbnail_edited = Bun.file(thumbEditPath).name!
      metadata[photoIndex] = photo
      await saveMetadata(metadata)
    }
  }
}

export const app = new Elysia()
  // Error handling: Return status code and message
  .onError(({ error, status }) => {
    if (error instanceof HttpError) {
      const errorMessage = typeof error.content === "string" ? error.content : JSON.stringify(error.content)
      console.error(`[-] HTTP ${error.status}: ${errorMessage}`)
      if (typeof error.content === "string") return status(error.status, { msg: error.content })
      else return status(error.status, error.content)
    }
    else throw error
  })
  .get("/", ({ redirect }) => redirect("https://aza.moe/photo"))

  .use(staticPlugin({ assets: FOLDER_PHOTOS, prefix: "/data" }))

  .post("/upload", async ({ body, headers }) => {
    const { owner_key, photo, edited_photo } = body
    checkHeaderKey(headers)

    console.log("[+] Received upload request")

    // Generate an ID of 8 numbers
    const id = Math.random().toString().substring(2, 10)
    let metadata = await getMetadata()

    // Check duplicate owner key
    if (metadata.find((p) => p.owner_key === owner_key)) {
      done(400, "Owner key already in use. Please use a unique owner key.")
    }

    // Process and save files
    // We use {id}-1 and {id}-2 as requested by "{id}-{number}"
    const originalExt = photo.name.split(".").pop() || "jpg"
    const editedExt = edited_photo.name.split(".").pop() || "jpg"

    // Using "1" for original and "2" for edited
    const originalPath = `${FOLDER_PHOTOS}/${id}/orig.${originalExt}`
    const editedPath = `${FOLDER_PHOTOS}/${id}/edit.${editedExt}`

    // Run file saving and EXIF parsing in parallel
    const [originalExif] = await Promise.all([
      getExifData(photo),
      Bun.write(originalPath, photo),
      Bun.write(editedPath, edited_photo),
    ])

    // Generate thumbnail
    const thumbnailPath = `${FOLDER_PHOTOS}/${id}/thumb.webp`
    await generateThumbnail(originalPath, thumbnailPath)
    const editedThumb = `${FOLDER_PHOTOS}/${id}/thumb.edit.webp`
    await generateThumbnail(editedPath, editedThumb)

    // 4. Create new metadata entry
    const newEntry: PhotoMetadata = {
      id, owner_key,
      upload_time: new Date().toISOString(),
      exif: originalExif,
      original_photo: Bun.file(originalPath).name!,
      edited_photo: Bun.file(editedPath).name!,
      thumbnail: Bun.file(thumbnailPath).name!,
      thumbnail_edited: Bun.file(editedThumb).name!,
    }

    // 5. Save updated metadata
    metadata.push(newEntry)
    await saveMetadata(metadata)

    console.log(`[+] Upload complete for photo ${newEntry.id}`)

    done(201, { success: true, id: newEntry.id })
  }, {
    // --- Validation Schema ---
    body: t.Object({
      owner_key: t.String(),
      photo: t.File({
        type: ["image/jpeg", "image/png", "image/webp", "image/avif", "image/heic", "image/heif"],
        error: "Original photo must be a valid image (jpg, png, webp, avif, heic, heif).",
      }),
      edited_photo: t.File({
        type: ["image/jpeg", "image/png", "image/webp", "image/avif", "image/heic", "image/heif"],
        error: "Edited photo must be a valid image (jpg, png, webp, avif, heic, heif).",
      }),
    }),
  })

  .post("/delete", async ({ body, headers }) => {
    const { id } = body
    checkHeaderKey(headers)
    log(`[+] Received delete request for photo ${id} (type: ${typeof id})`)
    if (typeof id !== "string" || id.trim() === "") done(400, "Invalid photo ID")

    const metadata = await getMetadata()

    const photoIndex = metadata.findIndex((p) => p.id === id)
    if (photoIndex === -1) done(404, "Photo not found")

    const meta = metadata[photoIndex]
    metadata.splice(photoIndex, 1)
    await saveMetadata(metadata)

    // Delete from filesystem
    await unlink(path.join(FOLDER_PHOTOS, id, path.basename(meta.original_photo)))

    console.log(`[+] Deleted photo ${id}`)
    done(200, { success: true })
  }, {
    body: t.Object({ id: t.String() }),
  })

  // ----- 2. GET /photos -----
  // Returns all metadata entries *without* the 'owner_key'.
  .get("/photos", async () => {
    const metadata = (await getMetadata()).filter((entry) => !entry.hide)

    // Map over the array and filter out the 'owner_key' from each object
    const pubMeta = metadata.map((entry) => {
      const { owner_key, ...publicEntry } = entry
      return publicEntry
    })

    return pubMeta
  })

  // ----- 4. POST /edit -----
  // Edits a specific field in the metadata.
  .post("/edit", async ({ headers, body }) => {
    const { id, key, field, value } = body
    const metadata = await getMetadata()

    const photoIndex = metadata.findIndex((p) => p.id === id)
    if (photoIndex === -1) done(404, "Photo not found")

    const photo = metadata[photoIndex]
    checkHeaderKey(headers, [photo.owner_key, INSTANT_KEY])

    // Prevent editing core, protected fields
    const protectedFields = ["id", "owner_key", "upload_time", "original_photo", "edited_photo"]
    if (protectedFields.includes(field)) done(400, `Cannot edit protected field: ${field}`)

    // Apply the edit
    console.log(`Editing photo ${id}: Set ${field} = ${value}`)
    photo[field] = value
    metadata[photoIndex] = photo // Update the photo in the main array

    await saveMetadata(metadata)
    done(200, { success: true, id, updated: { [field]: value } })
  }, {
    body: t.Object({ id: t.String(), key: t.String(), field: t.String(), value: t.Any() })
  })

  // Redirect for short links. ID can be either ID or owner_key
  .get("/:idOrKey", async ({ params, redirect }) => {
    const { idOrKey } = params
    const metadata = await getMetadata()

    const photo = metadata.find((p) => p.id === idOrKey) || metadata.find((p) => p.owner_key === idOrKey)
    if (!photo || photo.hide === true) done(404, "Photo not found")
    if (photo.id != idOrKey) return redirect(`https://aza.moe/photo/${photo.id}?owner_key=${idOrKey}`)

    return redirect(`https://aza.moe/photo/${photo.id}`)
  }, {
    params: t.Object({ idOrKey: t.String() })
  })

  .listen(3000)

console.log(`🦊 Elysia server running at http://${app.server?.hostname}:${app.server?.port}`)
