import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import semver from "semver"

declare const OPENCODE_CLI_NAME: string | undefined

export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
type Method = "npm" | "pnpm" | "bun" | "yarn"

const packageName =
  typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
    ? OPENCODE_CLI_NAME
    : "@opencode-ai/cli"

export interface Interface {
  readonly check: () => Effect.Effect<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}

export function decodePolicy(text: string): Policy | undefined {
  // The CLI only projects this host-level preference instead of initializing
  // the location-scoped server configuration graph.
  const errors: ParseError[] = []
  const input: unknown = parse(text, errors, { allowTrailingComma: true })
  if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
  const value = input.autoupdate
  if (typeof value === "boolean" || value === "notify") return value
}

export function action(current: string, latest: string, policy: Policy): Action {
  if (policy === false) return "none"
  if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
  // Major upgrades are never installed automatically.
  if (semver.major(latest) !== semver.major(current)) return "none"
  return "upgrade"
}

export const layer = Layer.effect(
  Service,
  Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem
    const global = yield* Global.Service
    const appProcess = yield* AppProcess.Service
    const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")

    const readPolicy = Effect.fnUntraced(function* () {
      const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
        fs
          .readFileString(path.join(global.config, name))
          .pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))),
      )
      return values.findLast((value) => value !== undefined) ?? true
    })

    const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
      return yield* appProcess
        .run(ChildProcess.make(command[0], command.slice(1)), {
          timeout,
          maxOutputBytes: 100_000,
          maxErrorBytes: 100_000,
        })
        .pipe(
          Effect.map((result) => ({
            code: result.exitCode,
            stdout: result.stdout.toString("utf8"),
            stderr: result.stderr.toString("utf8"),
          })),
          Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
        )
    })

    const method = Effect.fnUntraced(function* () {
      const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
        { method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
        { method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
        { method: "bun", command: ["bun", "pm", "ls", "-g"] },
        { method: "yarn", command: ["yarn", "global", "list"] },
      ]
      const results = yield* Effect.forEach(
        checks,
        (check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
        { concurrency: "unbounded" },
      )
      return results.find((result) => result.result.stdout.includes(packageName))?.check.method
    })

    const latest = Effect.fnUntraced(function* () {
      const response = yield* Effect.tryPromise({
        try: () =>
          fetch(
            `https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`,
            { headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.timeout(10_000) },
          ),
        catch: (cause) => new Error("Failed to check for updates", { cause }),
      })
      if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
      const data = yield* Effect.tryPromise({
        try: () => response.json(),
        catch: (cause) => new Error("Failed to read update information", { cause }),
      })
      if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
        return yield* Effect.fail(new Error("Update information did not include a version"))
      }
      return data.version
    })

    const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
      const target = `${packageName}@${version}`
      const commands: Record<Exclude<Method, "bun">, string[]> = {
        npm: ["npm", "install", "--global", target],
        pnpm: ["pnpm", "install", "--global", target],
        yarn: ["yarn", "global", "add", target],
      }
      const result = yield* (method === "bun"
        ? Effect.scoped(
            Effect.gen(function* () {
              // Bun does not prune old versions from its shared package cache.
              yield* fs.makeDirectory(global.cache, { recursive: true })
              const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
              return yield* run(["bun", "install", "--global", "--cache-dir", cache, target], "5 minutes")
            }),
          )
        : run(commands[method], "5 minutes"))
      if (result.code === 0) return
      return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
    })

    const check = Effect.fn("cli.updater.check")(function* () {
      if (
        OPENCODE_LOCAL ||
        ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
      )
        return yield* Effect.logInfo("update check skipped", {
          reason: OPENCODE_LOCAL ? "local-install" : "disabled",
          version: OPENCODE_VERSION,
          channel: OPENCODE_CHANNEL,
        })
      const policy = yield* readPolicy()
      if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })

      return yield* Effect.gen(function* () {
        const version = yield* latest()
        yield* Effect.logInfo("update check", {
          current: OPENCODE_VERSION,
          latest: version,
        })
        const next = action(OPENCODE_VERSION, version, policy)
        if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
        const detected = yield* method()
        if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
        yield* upgrade(detected, version)
        yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
      })
    }, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))

    return Service.of({ check })
  }),
)

export * as Updater from "./updater"
