import path from "path"
import semver from "semver"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "./filesystem"
import { Global } from "./global"
import { EffectFlock } from "./util/effect-flock"

export namespace Npm {
  export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
    add: Schema.Array(Schema.String).pipe(Schema.optional),
    dir: Schema.String,
    cause: Schema.optional(Schema.Defect),
  }) {}

  export interface EntryPoint {
    readonly directory: string
    readonly entrypoint: Option.Option<string>
  }

  export interface Interface {
    readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
    readonly install: (
      dir: string,
      input?: { add: string[] },
    ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
    readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
    readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
  }

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

  const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined

  export function sanitize(pkg: string) {
    if (!illegal) return pkg
    return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
  }

  const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
    let entrypoint: Option.Option<string>
    try {
      const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
      entrypoint = Option.some(resolved)
    } catch {
      entrypoint = Option.none()
    }
    return {
      directory: dir,
      entrypoint,
    }
  }

  interface ArboristNode {
    name: string
    path: string
  }

  interface ArboristTree {
    edgesOut: Map<string, { to?: ArboristNode }>
  }

  const reify = (input: { dir: string; add?: string[] }) =>
    Effect.gen(function* () {
      const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
      const arborist = new Arborist({
        path: input.dir,
        binLinks: true,
        progress: false,
        savePrefix: "",
        ignoreScripts: true,
      })
      return yield* Effect.tryPromise({
        try: () =>
          arborist.reify({
            add: input?.add || [],
            save: true,
            saveType: "prod",
          }),
        catch: (cause) =>
          new InstallFailedError({
            cause,
            add: input?.add,
            dir: input.dir,
          }),
      }) as Effect.Effect<ArboristTree, InstallFailedError>
    }).pipe(
      Effect.withSpan("Npm.reify", {
        attributes: input,
      }),
    )

  export const layer = Layer.effect(
    Service,
    Effect.gen(function* () {
      const afs = yield* AppFileSystem.Service
      const global = yield* Global.Service
      const fs = yield* FileSystem.FileSystem
      const flock = yield* EffectFlock.Service
      const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))

      const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
        const response = yield* Effect.tryPromise({
          try: () => fetch(`https://registry.npmjs.org/${pkg}`),
          catch: () => undefined,
        }).pipe(Effect.orElseSucceed(() => undefined))

        if (!response || !response.ok) {
          return false
        }

        const data = yield* Effect.tryPromise({
          try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
          catch: () => undefined,
        }).pipe(Effect.orElseSucceed(() => undefined))

        const latestVersion = data?.["dist-tags"]?.latest
        if (!latestVersion) {
          return false
        }

        const range = /[\s^~*xX<>|=]/.test(cachedVersion)
        if (range) return !semver.satisfies(latestVersion, cachedVersion)

        return semver.lt(cachedVersion, latestVersion)
      })

      const add = Effect.fn("Npm.add")(function* (pkg: string) {
        const dir = directory(pkg)
        yield* flock.acquire(`npm-install:${dir}`)

        const tree = yield* reify({ dir, add: [pkg] })
        const first = tree.edgesOut.values().next().value?.to
        if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
        return resolveEntryPoint(first.name, first.path)
      }, Effect.scoped)

      const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
        const canWrite = yield* afs.access(dir, { writable: true }).pipe(
          Effect.as(true),
          Effect.orElseSucceed(() => false),
        )
        if (!canWrite) return

        yield* flock.acquire(`npm-install:${dir}`)

        yield* Effect.gen(function* () {
          const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
          if (!nodeModulesExists) {
            yield* reify({ add: input?.add, dir })
            return
          }
        }).pipe(Effect.withSpan("Npm.checkNodeModules"))

        yield* Effect.gen(function* () {
          const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
          const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))

          const pkgAny = pkg as any
          const lockAny = lock as any
          const declared = new Set([
            ...Object.keys(pkgAny?.dependencies || {}),
            ...Object.keys(pkgAny?.devDependencies || {}),
            ...Object.keys(pkgAny?.peerDependencies || {}),
            ...Object.keys(pkgAny?.optionalDependencies || {}),
            ...(input?.add || []),
          ])

          const root = lockAny?.packages?.[""] || {}
          const locked = new Set([
            ...Object.keys(root?.dependencies || {}),
            ...Object.keys(root?.devDependencies || {}),
            ...Object.keys(root?.peerDependencies || {}),
            ...Object.keys(root?.optionalDependencies || {}),
          ])

          for (const name of declared) {
            if (!locked.has(name)) {
              yield* reify({ dir, add: input?.add })
              return
            }
          }
        }).pipe(Effect.withSpan("Npm.checkDirty"))

        return
      }, Effect.scoped)

      const which = Effect.fn("Npm.which")(function* (pkg: string) {
        const dir = directory(pkg)
        const binDir = path.join(dir, "node_modules", ".bin")

        const pick = Effect.fnUntraced(function* () {
          const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))

          if (files.length === 0) return Option.none<string>()
          if (files.length === 1) return Option.some(files[0])

          const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)

          if (Option.isSome(pkgJson)) {
            const parsed = pkgJson.value as { bin?: string | Record<string, string> }
            if (parsed?.bin) {
              const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
              const bin = parsed.bin
              if (typeof bin === "string") return Option.some(unscoped)
              const keys = Object.keys(bin)
              if (keys.length === 1) return Option.some(keys[0])
              return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
            }
          }

          return Option.some(files[0])
        })

        return yield* Effect.gen(function* () {
          const bin = yield* pick()
          if (Option.isSome(bin)) {
            return Option.some(path.join(binDir, bin.value))
          }

          yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))

          yield* add(pkg)

          const resolved = yield* pick()
          if (Option.isNone(resolved)) return Option.none<string>()
          return Option.some(path.join(binDir, resolved.value))
        }).pipe(
          Effect.scoped,
          Effect.orElseSucceed(() => Option.none<string>()),
        )
      })

      return Service.of({
        add,
        install,
        outdated,
        which,
      })
    }),
  )

  export const defaultLayer = layer.pipe(
    Layer.provide(EffectFlock.layer),
    Layer.provide(AppFileSystem.layer),
    Layer.provide(Global.layer),
    Layer.provide(NodeFileSystem.layer),
  )
}
