/** @jsxImportSource @opentui/solid */
import type { TextRenderable } from "@opentui/core"
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { onCleanup } from "solid-js"
import { backoffDelayMs, processPoll, resolveServerUrls } from "./src/stats.ts"

type StreamSample = {
  at: number
  tokens: number
}

const STREAM_WINDOW_MS = 5_000
const SLOT_POLL_MS = 500
const BACKOFF_BASE_MS = 1_000
const BACKOFF_MAX_MS = 10_000
type MessageTiming = {
  sessionID: string
  requestStartAt: number
  firstResponseAt?: number
  firstTokenAt?: number
  lastTokenAt?: number
}

 type TrackerState = {
  streamSamplesBySession: Record<string, StreamSample[]>
  messageTimingByID: Record<string, MessageTiming>
  lastPrefillRate: number
  lastGeneratedTps: number
  isPrefilling: boolean
  prefillSlotId: number | null
  prefillCapturedTokens: number | null
  prefillStartAt: number
  isGenerating: boolean
  generateSlotId: number | null
  generatePrevNd: number
  generateStartAt: number
  prevNdBySlot: Record<number, { prevNd: number; idTask: number; classification: "TG" | "PP" | "neither" }>
  prevPromptTokensBySlot: Record<number, { tokens: number; idTask: number }>
  failure: string | null
}

function estimateStreamTokens(delta: string) {
  return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
}

async function fetchSlots(baseUrl: string, model?: string): Promise<unknown | null> {
  try {
    const url = model ? `${baseUrl}/slots?model=${encodeURIComponent(model)}` : `${baseUrl}/slots`
    const resp = await fetch(url)
    if (!resp.ok) return null
    return await resp.json()
  } catch {
    return null
  }
}

function formatPps(value: number) {
  if (!Number.isFinite(value) || value <= 0) return undefined
  return `${Math.round(value)}`
}

function formatTps(value: number) {
  if (!Number.isFinite(value) || value <= 0) return undefined
  return `${Math.round(value)}`
}

function SessionPromptRight(props: {
  api: Parameters<TuiPlugin>[0]
  sessionID: string
  tracker: TrackerState
  subscribe: (listener: () => void) => () => void
}) {
  let textRef: TextRenderable | undefined

  const sync = () => {
    if (!textRef) return
    textRef.content = getStatusText()
    props.api.renderer.requestRender()
  }

  const unsubscribe = props.subscribe(sync)
  onCleanup(unsubscribe)

  return (
    <text
      ref={(ref: TextRenderable) => {
        textRef = ref
        sync()
      }}
      fg={props.api.theme.current.textMuted}
    >
      {getStatusText()}
    </text>
  )

  function getStatusText() {
    const s = props.tracker
    if (s.failure) {
      return s.failure
    }
    const pp = s.isPrefilling && s.lastPrefillRate > 0 ? formatPps(s.lastPrefillRate) : undefined
    const tps = s.isGenerating && s.lastGeneratedTps > 0 ? formatTps(s.lastGeneratedTps) : undefined
    let result = "- tps (TG)"
    if (s.isPrefilling && pp) {
      result = `${pp} tps (PP)`
    } else if (s.isGenerating && tps) {
      result = `${tps} tps (TG)`
    }
    return result
  }
}

const tui: TuiPlugin = async (api, options) => {
  console.log("[oc-ls-stats] TUI plugin loaded!")
  const tracker: TrackerState = {
    streamSamplesBySession: {},
    messageTimingByID: {},
    lastPrefillRate: 0,
    lastGeneratedTps: 0,
    isPrefilling: false,
    prefillSlotId: null,
    prefillCapturedTokens: null,
    prefillStartAt: 0,
    isGenerating: false,
    generateSlotId: null,
    generatePrevNd: 0,
    generateStartAt: 0,
    prevNdBySlot: {},
    prevPromptTokensBySlot: {},
    failure: null,
  }
  const listeners = new Set<() => void>()

  const bump = () => {
    for (const listener of listeners) listener()
  }

  const pruneSamples = (now = Date.now()) => {
    let changed = false

    for (const [sessionID, samples] of Object.entries(tracker.streamSamplesBySession)) {
      const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
      if (next.length !== samples.length) {
        changed = true
        if (next.length > 0) tracker.streamSamplesBySession[sessionID] = next
        else delete tracker.streamSamplesBySession[sessionID]
      }
    }

    if (changed) bump()
  }

  const clearLiveSamples = (sessionID: string) => {
    if (!tracker.streamSamplesBySession[sessionID]?.length) return
    delete tracker.streamSamplesBySession[sessionID]
    bump()
  }

  const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
    const now = sample.at
    tracker.streamSamplesBySession[sessionID] = [
      ...(tracker.streamSamplesBySession[sessionID] ?? []).filter((item) => now - item.at <= STREAM_WINDOW_MS),
      sample,
    ]
    const timing = tracker.messageTimingByID[messageID]
    if (timing) {
      tracker.messageTimingByID[messageID] = timing.firstTokenAt
        ? { ...timing, lastTokenAt: now }
        : {
            ...timing,
            firstResponseAt: timing.firstResponseAt ?? now,
            firstTokenAt: now,
            lastTokenAt: now,
          }
    }
    bump()
  }

  let llamaServerUrls: string[] = []
  let llamaServerModel: string | undefined
  const backoffByUrl = new Map<string, { failures: number; nextRetryAt: number }>()

  const pollMetrics = async () => {
    if (!llamaServerUrls.length) return

    let model = llamaServerModel
    if (!model) {
      const routeSessionID = api.route.current.params?.sessionID
      if (routeSessionID) {
        const session = api.state.session.get(routeSessionID)
        if (session?.model?.id) {
          model = session.model.id
        }
      }
    }

    for (const baseUrl of llamaServerUrls) {
      const now = Date.now()
      const backoff = backoffByUrl.get(baseUrl)
      if (backoff && now < backoff.nextRetryAt) {
        tracker.failure = "n/a"
        bump()
        continue
      }

      const slots = await fetchSlots(baseUrl, model)
      if (slots == null) {
        const failures = (backoff?.failures ?? 0) + 1
        const delay = backoffDelayMs(failures, BACKOFF_BASE_MS, BACKOFF_MAX_MS)
        const jitter = Math.floor(Math.random() * (delay / 4))
        backoffByUrl.set(baseUrl, {
          failures,
          nextRetryAt: Date.now() + Math.min(delay + jitter, BACKOFF_MAX_MS),
        })
        tracker.failure = "n/a"
        bump()
        continue
      }

      backoffByUrl.delete(baseUrl)
      const slotList = Array.isArray(slots) ? slots : Object.values(slots)
      if (slotList.length === 0) {
        tracker.failure = "n/a"
        bump()
        continue
      }
      tracker.failure = null
      bump()

      const result = processPoll(slotList, tracker)
      bump()
    }
  }

  const onDelta = api.event.on("message.part.delta", (evt) => {
    if (evt.properties.field !== "text") return
    const parts = api.state.part(evt.properties.messageID)
    const part = parts.find((item) => item.id === evt.properties.partID)
    if (!part) return
    if (part.type !== "text" && part.type !== "reasoning") return
    appendSample(evt.properties.sessionID, evt.properties.messageID, {
      at: Date.now(),
      tokens: estimateStreamTokens(evt.properties.delta),
    })
  })

  const onMessage = api.event.on("message.updated", (evt) => {
    if (evt.properties.info.role !== "assistant") return

    if (!evt.properties.info.time.completed) {
      const existing = tracker.messageTimingByID[evt.properties.info.id]
      tracker.messageTimingByID[evt.properties.info.id] = {
        sessionID: evt.properties.sessionID,
        requestStartAt: evt.properties.info.time.created,
        firstResponseAt: existing?.firstResponseAt,
        firstTokenAt: existing?.firstTokenAt,
        lastTokenAt: existing?.lastTokenAt,
      }
      bump()
      return
    }

    delete tracker.messageTimingByID[evt.properties.info.id]
    pruneSamples(evt.properties.info.time.completed)
    bump()
  })

  const onPart = api.event.on("message.part.updated", (evt) => {
    if (evt.properties.part.type !== "tool") return
    if (
      evt.properties.part.state.status === "running" ||
      evt.properties.part.state.status === "completed" ||
      evt.properties.part.state.status === "error"
    ) {
      clearLiveSamples(evt.properties.sessionID)
    }
    const timing = tracker.messageTimingByID[evt.properties.part.messageID]
    if (!timing) return
    if (evt.properties.part.state.status === "pending") {
      tracker.messageTimingByID[evt.properties.part.messageID] = {
        ...timing,
        firstResponseAt: timing.firstResponseAt ?? evt.properties.time,
      }
      bump()
      return
    }
    if (evt.properties.part.state.status !== "running") return
    bump()
  })

  const timer = setInterval(() => {
    pruneSamples()
  }, 1000)

  const slotTimer = setInterval(async () => {
    await pollMetrics()
  }, SLOT_POLL_MS)

  api.lifecycle.onDispose(() => {
    onDelta()
    onMessage()
    onPart()
    clearInterval(timer)
    clearInterval(slotTimer)
  })

  try {
    llamaServerUrls = resolveServerUrls(options, api.state.provider, api.state.config)
  } catch (e) {
    console.error("[oc-ls-stats] resolveServerUrls error:", e)
    llamaServerUrls = ["http://localhost:8080"]
  }

  api.slots.register({
    slots: {
      session_prompt_right(_ctx, value) {
        return <SessionPromptRight api={api} sessionID={value.session_id} tracker={tracker} subscribe={(listener) => {
          listeners.add(listener)
          return () => {
            listeners.delete(listener)
          }
        }} />
      },
    },
  })
}

const plugin: TuiPluginModule & { id: string } = {
  id: "@troed/oc-ls-stats",
  tui,
}

export default plugin
