import { getErrorMessage } from "@workshop/shared/errors"
import kv from "@workshop/shared/kv"
import { getPackageInfo, type PackageInfo } from "@workshop/shared/packages"
import { DateTime } from "luxon"
import { z } from "zod"

export const startCron = async () => {
  const crons = await getCrons()
  await runCrons(crons)
}

const runCrons = async (crons: CronJob[]) => {
  try {
    const promises = crons.map((cron) => {
      return cron.runIfDue()
    })

    await Promise.allSettled(promises)
  } catch (error) {
    console.error("💥 Error running cron jobs:", error)
  } finally {
    setTimeout(() => {
      runCrons(crons)
    }, 500)
  }
}

const getCrons = async () => {
  const packageInfo = await getPackageInfo()
  const cronScripts: CronJob[] = []

  for (const info of packageInfo) {
    if (!info.json.scripts) continue
    for (const [scriptName, scriptCommand] of Object.entries(info.json.scripts)) {
      if (!scriptName.startsWith("cron:")) continue

      try {
        const cronJob = new CronJob(scriptName, scriptCommand, info)

        // Create an entry for everything so we see it in the UI
        await kv.update("cronJobs", {}, (jobs) => {
          if (jobs[cronJob.id]) return jobs
          jobs[cronJob.id] = []
          return jobs
        })

        await cronJob.recoverLastRun()

        cronScripts.push(cronJob)
      } catch (error) {
        console.error(`Invalid cron job "${scriptName}" in package "${info.json.name}":`, error)
        continue
      }
    }
  }

  return cronScripts
}

const DEBUG = false
export class CronJob {
  schedule: CronSchedule
  id: string
  private lastRunAt: DateTime = DateTime.now().minus({ seconds: 60 })
  private scriptName: string
  private script: string
  private info: PackageInfo

  constructor(scriptName: string, script: string, info: PackageInfo, lastRunAt?: DateTime) {
    this.scriptName = scriptName
    this.script = script
    this.info = info
    this.schedule = parseScriptName(this.scriptName)
    this.id = `${this.info.json.name} ${this.scriptName} ${this.script}`
    this.lastRunAt = lastRunAt || DateTime.now().minus({ seconds: 60 })
  }

  static isValid(cron: string): boolean {
    try {
      parseScriptName(cron)
      return true
    } catch (error) {
      return false
    }
  }

  debug(...args: unknown[]) {
    if (!DEBUG) return
    console.debug("🪲", ...args)
  }

  async recoverLastRun() {
    const cronJobs = await kv.get("cronJobs", {})
    const lastJob = cronJobs[this.id]?.[0]
    const defaultValue = DateTime.now().minus({ seconds: 60 })
    if (!lastJob) return defaultValue

    const lastRunAt = DateTime.fromISO(lastJob.runAt)
    this.debug(`Recovering last run for "${this.scriptName}"`, lastRunAt.toISO())
    this.lastRunAt = lastRunAt.isValid ? lastRunAt : defaultValue
  }

  toString() {
    const format = "LLL d, yyyy HH:mm:ss ZZZZ"
    return `CronJob({"${this.scriptName}": "${this.script}"} lastRun:"${this.lastRunAt.toFormat(format)}")`
  }

  isDue(now: DateTime): boolean {
    if (this.schedule.unit === "constantly") {
      const diff = now.diff(this.lastRunAt, "minutes").minutes
      return diff >= 1
    }

    let previousScheduledAt = now.setZone("UTC").set({
      hour: this.schedule.hour,
      minute: this.schedule.minute,
      second: 0,
      millisecond: 0,
    })

    if (this.schedule.unit === "weekly") {
      previousScheduledAt = previousScheduledAt.startOf("week")
    } else if (this.schedule.unit === "monthly") {
      previousScheduledAt = previousScheduledAt.startOf("month")
    }

    // If the scheduled time is in the future, go back one period
    if (previousScheduledAt > now) {
      const unitMap = {
        hourly: { hour: 1 },
        daily: { day: 1 },
        weekly: { week: 1 },
        monthly: { month: 1 },
      }
      previousScheduledAt = previousScheduledAt.minus(unitMap[this.schedule.unit])
    }

    const shouldRun = this.lastRunAt < previousScheduledAt // If the last run is before the previous scheduled time, it's due
    this.debug(
      `${shouldRun ? "✅" : "❌"} ${
        this.scriptName
      }: Last run at "${this.lastRunAt.toISO()}" previous scheduled at "${previousScheduledAt.toISO()}"`
    )

    return shouldRun
  }

  async runIfDue() {
    const now = DateTime.now()
    if (!this.isDue(now)) return
    this.run(now)
  }

  async run(now: DateTime) {
    this.lastRunAt = now

    let runError: unknown
    let stdout: string | undefined
    let stderr: string | undefined

    try {
      // --silent to suppress output of the command to stderr
      const proc = Bun.spawn(["bun", "run", "--silent", this.scriptName], {
        stderr: "pipe",
        stdout: "pipe",
        cwd: this.info.path,
      })

      await proc.exited

      stdout = typeof proc.stdout === "number" ? proc.stdout : await Bun.readableStreamToText(proc.stdout)
      stderr = typeof proc.stderr === "number" ? proc.stderr : await Bun.readableStreamToText(proc.stderr)

      const status = proc.exitCode
      if (status !== 0) {
        throw new Error(`Failed with exit code ${status}`)
      }
    } catch (error) {
      console.error(`💥 Error running cron job "${this.scriptName}" in "${this.info.json.name}":`, error)
      runError = error
    } finally {
      await kv.update("cronJobs", {}, (jobs) => {
        let history = jobs[this.id] || []
        history = history.slice(0, 4) // Keep only the last 5 runs
        history.unshift({
          runAt: now.toISO()!,
          error: runError ? getErrorMessage(runError) : undefined,
          stdout,
          stderr,
        })
        jobs[this.id] = history
        return jobs
      })
    }
  }
}

const parseScriptName = (scriptName: string): CronSchedule => {
  const cron = scriptName.replace("cron:", "").replace(/ #.*$/, "")
  const parts = cron.split("@")
  const [unit, time] = parts
  const [hour, minute] = time?.split(":") || []

  return cronScheduleSchema.parse({ unit, hour, minute })
}

export type CronSchedule = z.infer<typeof cronScheduleSchema>
const cronScheduleSchema = z.object({
  unit: z.enum(["constantly", "hourly", "daily", "weekly", "monthly"]),
  hour: z.coerce.number().int().min(0).max(23).default(0),
  minute: z.coerce.number().int().min(0).max(59).default(0),
})
