import os from "os"
import path from "path"
import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config"
import { InstanceState } from "@/effect"
import { Flag } from "@/flag/flag"
import { AppFileSystem } from "@woozlit/shared/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global"
import { Log } from "../util"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"

const log = Log.create({ service: "instruction" })

const FILES = [
  "WOOZLIT.md",
  path.join(".woozlit", "WOOZLIT.md"),
  "AGENTS.md",
  path.join(".woozlit", "AGENTS.md"),
  ...(Flag.WOOZLIT_CODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md", path.join(".woozlit", "CLAUDE.md")]),
  "CONTEXT.md", // deprecated
  path.join(".woozlit", "CONTEXT.md"),
]

// When the project AGENTS.md has fewer than this many (trimmed) characters, treat it as
// sparse and also load CLAUDE.md so its guidance isn't dropped by the first-match-wins rule.
const CLAUDE_FALLBACK_MAX_CHARS = 500

function globalFiles() {
  const files = []
  if (Flag.WOOZLIT_CODE_CONFIG_DIR) {
    files.push(path.join(Flag.WOOZLIT_CODE_CONFIG_DIR, "AGENTS.md"))
  }
  files.push(path.join(Global.Path.config, "AGENTS.md"))
  if (!Flag.WOOZLIT_CODE_DISABLE_CLAUDE_CODE_PROMPT) {
    files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
  }
  return files
}

function extract(messages: MessageV2.WithParts[]) {
  const paths = new Set<string>()
  for (const msg of messages) {
    for (const part of msg.parts) {
      if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
        if (part.state.time.compacted) continue
        const loaded = part.state.metadata?.loaded
        if (!loaded || !Array.isArray(loaded)) continue
        for (const p of loaded) {
          if (typeof p === "string") paths.add(p)
        }
      }
    }
  }
  return paths
}

export interface Interface {
  readonly clear: (messageID: MessageID) => Effect.Effect<void>
  readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
  readonly system: () => Effect.Effect<{ paths: Set<string>; content: string[] }, AppFileSystem.Error>
  readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
  readonly resolve: (
    messages: MessageV2.WithParts[],
    filepath: string,
    messageID: MessageID,
  ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
}

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

export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
  Layer.effect(
    Service,
    Effect.gen(function* () {
      const cfg = yield* Config.Service
      const fs = yield* AppFileSystem.Service
      const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))

      const state = yield* InstanceState.make(
        Effect.fn("Instruction.state")(() =>
          Effect.succeed({
            // Track which instruction files have already been attached for a given assistant message.
            claims: new Map<MessageID, Set<string>>(),
          }),
        ),
      )

      const relative = Effect.fnUntraced(function* (instruction: string) {
        const ctx = yield* InstanceState.context
        if (!Flag.WOOZLIT_CODE_DISABLE_PROJECT_CONFIG) {
          return yield* fs
            .globUp(instruction, ctx.directory, ctx.worktree)
            .pipe(Effect.catch(() => Effect.succeed([] as string[])))
        }
        if (!Flag.WOOZLIT_CODE_CONFIG_DIR) {
          log.warn(
            `Skipping relative instruction "${instruction}" - no WOOZLIT_CODE_CONFIG_DIR set while project config is disabled`,
          )
          return []
        }
        return yield* fs
          .globUp(instruction, Flag.WOOZLIT_CODE_CONFIG_DIR, Flag.WOOZLIT_CODE_CONFIG_DIR)
          .pipe(Effect.catch(() => Effect.succeed([] as string[])))
      })

      const read = Effect.fnUntraced(function* (filepath: string) {
        return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
      })

      const fetch = Effect.fnUntraced(function* (url: string) {
        const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
          Effect.timeout(5000),
          Effect.catch(() => Effect.succeed(null)),
        )
        if (!res) return ""
        const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
        return new TextDecoder().decode(body)
      })

      const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
        const s = yield* InstanceState.get(state)
        s.claims.delete(messageID)
      })

      const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
        const config = yield* cfg.get()
        const ctx = yield* InstanceState.context
        const paths = new Set<string>()

        // The first project-level match wins so we don't stack WOOZLIT.md/AGENTS.md/CLAUDE.md from every ancestor.
        if (!Flag.WOOZLIT_CODE_DISABLE_PROJECT_CONFIG) {
          const woozlit = yield* fs.up({
            targets: ["WOOZLIT.md", path.join(".woozlit", "WOOZLIT.md")],
            start: ctx.directory,
            stop: ctx.worktree,
          })
          if (woozlit.length > 0) {
            woozlit.forEach((item) => paths.add(path.resolve(item)))
            // A sparse WOOZLIT.md likely doesn't carry the full project guidance, so pull in CLAUDE.md too.
            if (!Flag.WOOZLIT_CODE_DISABLE_CLAUDE_CODE_PROMPT) {
              const content = (yield* Effect.forEach(woozlit, read, { concurrency: 8 })).join("").trim()
              if (content.length < CLAUDE_FALLBACK_MAX_CHARS) {
                const claude = yield* fs.up({
                  targets: ["CLAUDE.md", path.join(".woozlit", "CLAUDE.md")],
                  start: ctx.directory,
                  stop: ctx.worktree,
                })
                claude.forEach((item) => paths.add(path.resolve(item)))
              }
            }
          } else {
            const agents = yield* fs.up({
              targets: ["AGENTS.md", path.join(".woozlit", "AGENTS.md")],
              start: ctx.directory,
              stop: ctx.worktree,
            })
            if (agents.length > 0) {
              agents.forEach((item) => paths.add(path.resolve(item)))
              // A sparse AGENTS.md likely doesn't carry the full project guidance, so pull in CLAUDE.md too.
              if (!Flag.WOOZLIT_CODE_DISABLE_CLAUDE_CODE_PROMPT) {
                const content = (yield* Effect.forEach(agents, read, { concurrency: 8 })).join("").trim()
                if (content.length < CLAUDE_FALLBACK_MAX_CHARS) {
                  const claude = yield* fs.up({
                    targets: ["CLAUDE.md", path.join(".woozlit", "CLAUDE.md")],
                    start: ctx.directory,
                    stop: ctx.worktree,
                  })
                  claude.forEach((item) => paths.add(path.resolve(item)))
                }
              }
            } else {
              for (const file of FILES) {
                if (
                  file === "WOOZLIT.md" ||
                  file === path.join(".woozlit", "WOOZLIT.md") ||
                  file === "AGENTS.md" ||
                  file === path.join(".woozlit", "AGENTS.md")
                ) continue
                const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
                if (matches.length > 0) {
                  matches.forEach((item) => paths.add(path.resolve(item)))
                  break
                }
              }
            }
          }
        }

        for (const file of globalFiles()) {
          if (yield* fs.existsSafe(file)) {
            paths.add(path.resolve(file))
            break
          }
        }

        if (config.instructions) {
          for (const raw of config.instructions) {
            if (raw.startsWith("https://") || raw.startsWith("http://")) continue
            const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
            const matches = yield* (
              path.isAbsolute(instruction)
                ? fs.glob(path.basename(instruction), {
                    cwd: path.dirname(instruction),
                    absolute: true,
                    include: "file",
                  })
                : relative(instruction)
            ).pipe(Effect.catch(() => Effect.succeed([] as string[])))
            matches.forEach((item) => paths.add(path.resolve(item)))
          }
        }

        return paths
      })

      const system = Effect.fn("Instruction.system")(function* () {
        const config = yield* cfg.get()
        const paths = yield* systemPaths()
        const urls = (config.instructions ?? []).filter(
          (item) => item.startsWith("https://") || item.startsWith("http://"),
        )

        const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
        const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })

        return {
          paths,
          content: [
            ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
            ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
          ],
        }
      })

      const find = Effect.fn("Instruction.find")(function* (dir: string) {
        for (const file of FILES) {
          const filepath = path.resolve(path.join(dir, file))
          if (yield* fs.existsSafe(filepath)) return filepath
        }
      })

      const resolve = Effect.fn("Instruction.resolve")(function* (
        messages: MessageV2.WithParts[],
        filepath: string,
        messageID: MessageID,
      ) {
        const sys = yield* systemPaths()
        const already = extract(messages)
        const results: { filepath: string; content: string }[] = []
        const s = yield* InstanceState.get(state)
        const root = path.resolve(yield* InstanceState.directory)

        const target = path.resolve(filepath)
        let current = path.dirname(target)

        // Walk upward from the file being read and attach nearby instruction files once per message.
        while (current.startsWith(root) && current !== root) {
          const found = yield* find(current)
          if (!found || found === target || sys.has(found) || already.has(found)) {
            current = path.dirname(current)
            continue
          }

          let set = s.claims.get(messageID)
          if (!set) {
            set = new Set()
            s.claims.set(messageID, set)
          }
          if (set.has(found)) {
            current = path.dirname(current)
            continue
          }

          set.add(found)
          const content = yield* read(found)
          if (content) {
            results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
          }

          current = path.dirname(current)
        }

        return results
      })

      return Service.of({ clear, systemPaths, system, find, resolve })
    }),
  )

export const defaultLayer = layer.pipe(
  Layer.provide(Config.defaultLayer),
  Layer.provide(AppFileSystem.defaultLayer),
  Layer.provide(FetchHttpClient.layer),
)

export function loaded(messages: MessageV2.WithParts[]) {
  return extract(messages)
}

// Friendly label for an instruction file: worktree-relative when inside the project (so two
// same-named ancestor AGENTS.md files stay distinguishable), `~`-prefixed under the home dir,
// otherwise the absolute path.
export function display(filepath: string, worktree: string) {
  const rel = path.relative(worktree, filepath)
  if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel
  const home = os.homedir()
  if (filepath === home || filepath.startsWith(home + path.sep)) return path.join("~", path.relative(home, filepath))
  return filepath
}

export * as Instruction from "./instruction"
