import path from "path"
import { pathToFileURL } from "url"
import { Bus } from "@/bus"
import * as Log from "@saeeol/core/util/log"
import { withTimeout } from "../util/timeout"
import {
  type Diagnostic,
  type DiagnosticRequestResult,
  type DocumentDiagnosticReport,
  type WorkspaceDiagnosticReport,
  type CapabilityRegistration,
  DIAGNOSTICS_DEBOUNCE_MS,
  DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS,
  DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS,
  DIAGNOSTICS_REQUEST_TIMEOUT_MS,
  Event,
  dedupeDiagnostics,
  getFilePath,
} from "./client-types"

const log = Log.create({ service: "lsp.client.diagnostics" })

type Connection = {
  sendRequest<T>(method: string, params: unknown): Promise<T>
}

type DiagnosticDeps = {
  connection: Connection
  serverID: string
  hasStaticPullDiagnostics: boolean
  pushDiagnostics: Map<string, Diagnostic[]>
  pullDiagnostics: Map<string, Diagnostic[]>
  published: Map<string, { at: number; version?: number }>
  diagnosticRegistrations: Map<string, CapabilityRegistration>
  registrationListeners: Set<() => void>
}

export function createDiagnosticsManager(deps: DiagnosticDeps) {
  const { connection, serverID, pushDiagnostics, pullDiagnostics, published, diagnosticRegistrations, registrationListeners } = deps

  const mergedDiagnostics = (filePath: string) =>
    dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])

  const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
    pushDiagnostics.set(filePath, next)
    Bus.publish(Event.Diagnostics, { path: filePath, serverID })
  }

  const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
    pullDiagnostics.set(filePath, next)
  }

  const emitRegistrationChange = () => {
    for (const listener of [...registrationListeners]) listener()
  }

  const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => {
    const handled = results.some((r) => r.handled)
    const matched = results.some((r) => r.matched)
    if (!handled) return { handled: false, matched: false }
    const merged = new Map<string, Diagnostic[]>()
    for (const r of results) {
      for (const [target, items] of r.byFile.entries()) {
        const existing = merged.get(target) ?? []
        merged.set(target, existing.concat(items))
      }
    }
    if (matched && !merged.has(filePath)) merged.set(filePath, [])
    for (const [target, items] of merged.entries()) updatePullDiagnostics(target, dedupeDiagnostics(items))
    return { handled, matched }
  }

  async function requestDiagnosticReport(filePath: string, identifier?: string): Promise<DiagnosticRequestResult> {
    const report = await withTimeout(
      connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {
        ...(identifier ? { identifier } : {}),
        textDocument: { uri: pathToFileURL(filePath).href },
      }),
      DIAGNOSTICS_REQUEST_TIMEOUT_MS,
    ).catch(() => null)
    if (!report) return { handled: false, matched: false, byFile: new Map() }
    const byFile = new Map<string, Diagnostic[]>()
    const push = (target: string, items: Diagnostic[]) => {
      const existing = byFile.get(target) ?? []
      byFile.set(target, existing.concat(items))
    }
    let handled = false
    let matched = false
    if (Array.isArray(report.items)) { push(filePath, report.items); handled = true; matched = true }
    for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {
      const relatedPath = getFilePath(uri)
      if (!relatedPath || !Array.isArray(related.items)) continue
      push(relatedPath, related.items); handled = true; matched = matched || relatedPath === filePath
    }
    return { handled, matched, byFile }
  }

  async function requestWorkspaceDiagnosticReport(filePath: string, identifier?: string): Promise<DiagnosticRequestResult> {
    const report = await withTimeout(
      connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", {
        ...(identifier ? { identifier } : {}),
        previousResultIds: [],
      }),
      DIAGNOSTICS_REQUEST_TIMEOUT_MS,
    ).catch(() => null)
    if (!report) return { handled: false, matched: false, byFile: new Map() }
    const byFile = new Map<string, Diagnostic[]>()
    let matched = false
    for (const item of report.items ?? []) {
      const relatedPath = item.uri ? getFilePath(item.uri) : undefined
      if (!relatedPath || !Array.isArray(item.items)) continue
      const existing = byFile.get(relatedPath) ?? []
      byFile.set(relatedPath, existing.concat(item.items))
      matched = matched || relatedPath === filePath
    }
    return { handled: true, matched, byFile }
  }

  function documentPullState() {
    const regs = [...diagnosticRegistrations.values()].filter((r) => r.registerOptions?.workspaceDiagnostics !== true)
    return {
      documentIdentifiers: [...new Set(regs.flatMap((r) => r.registerOptions?.identifier ?? []))],
      supported: deps.hasStaticPullDiagnostics || regs.length > 0,
    }
  }

  function workspacePullState() {
    const regs = [...diagnosticRegistrations.values()].filter((r) => r.registerOptions?.workspaceDiagnostics === true)
    return {
      workspaceIdentifiers: [...new Set(regs.flatMap((r) => r.registerOptions?.identifier ?? []))],
      supported: regs.length > 0,
    }
  }

  const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) =>
    results.some((r) => (r.byFile.get(filePath)?.length ?? 0) > 0)

  async function requestDiagnostics(filePath: string, requests: Promise<DiagnosticRequestResult>[], done: (results: DiagnosticRequestResult[]) => boolean) {
    if (!requests.length) return { handled: false, matched: false }
    const results: DiagnosticRequestResult[] = []
    return new Promise<{ handled: boolean; matched: boolean }>((resolve) => {
      let pending = requests.length
      let resolved = false
      const finish = (merged: { handled: boolean; matched: boolean }, force = false) => {
        if (resolved) return
        if (!force && !done(results)) return
        resolved = true; resolve(merged)
      }
      for (const request of requests) {
        request.then((result) => {
          results.push(result); pending -= 1
          const merged = mergeResults(filePath, results)
          finish(merged)
          if (pending === 0) finish(merged, true)
        })
      }
    })
  }

  async function requestDocumentDiagnostics(filePath: string) {
    const state = documentPullState()
    if (!state.supported) return { handled: false, matched: false }
    return requestDiagnostics(filePath, [
      requestDiagnosticReport(filePath),
      ...state.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)),
    ], (results) => hasCurrentFileDiagnostics(filePath, results))
  }

  async function requestFullDiagnostics(filePath: string) {
    const docState = documentPullState()
    const wsState = workspacePullState()
    if (!docState.supported && !wsState.supported) return { handled: false, matched: false }
    return mergeResults(filePath, await Promise.all([
      ...(docState.supported ? [requestDiagnosticReport(filePath)] : []),
      ...docState.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)),
      ...(wsState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []),
      ...wsState.workspaceIdentifiers.map((id) => requestWorkspaceDiagnosticReport(filePath, id)),
    ]))
  }

  function waitForRegistrationChange(timeout: number) {
    if (timeout <= 0) return Promise.resolve(false)
    return new Promise<boolean>((resolve) => {
      let finished = false
      let timer: ReturnType<typeof setTimeout> | undefined
      const finish = (result: boolean) => {
        if (finished) return; finished = true
        if (timer) clearTimeout(timer)
        registrationListeners.delete(listener); resolve(result)
      }
      const listener = () => finish(true)
      registrationListeners.add(listener)
      timer = setTimeout(() => finish(false), timeout)
    })
  }

  function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) {
    if (request.timeout <= 0) return Promise.resolve(false)
    return new Promise<boolean>((resolve) => {
      let finished = false
      let debounceTimer: ReturnType<typeof setTimeout> | undefined
      let timeoutTimer: ReturnType<typeof setTimeout> | undefined
      let unsub: (() => void) | undefined
      const finish = (result: boolean) => {
        if (finished) return; finished = true
        if (debounceTimer) clearTimeout(debounceTimer)
        if (timeoutTimer) clearTimeout(timeoutTimer)
        unsub?.(); resolve(result)
      }
      const schedule = () => {
        const hit = published.get(request.path)
        if (!hit) return
        if (typeof hit.version === "number" && hit.version !== request.version) return
        if (hit.at < request.after && hit.version !== request.version) return
        if (debounceTimer) clearTimeout(debounceTimer)
        debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)))
      }
      timeoutTimer = setTimeout(() => finish(false), request.timeout)
      unsub = Bus.subscribe(Event.Diagnostics, (event) => {
        if (event.properties.path !== request.path || event.properties.serverID !== serverID) return
        schedule()
      })
      schedule()
    })
  }

  async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) {
    const startedAt = request.after ?? Date.now()
    const pushWait = waitForFreshPush({ path: request.path, version: request.version, after: startedAt, timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS })
    while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) {
      const result = await requestDocumentDiagnostics(request.path)
      if (result.matched) return
      const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt)
      if (remaining <= 0) return
      const next = await Promise.race([
        pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),
        waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))),
      ])
      if (next !== "registration") return
    }
  }

  async function waitForFullDiagnostics(request: { path: string; version: number; after?: number }) {
    const startedAt = request.after ?? Date.now()
    const pushWait = waitForFreshPush({ path: request.path, version: request.version, after: startedAt, timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS })
    while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) {
      const result = await requestFullDiagnostics(request.path)
      if (result.handled || result.matched) return
      const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt)
      if (remaining <= 0) return
      const next = await Promise.race([
        pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),
        waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))),
      ])
      if (next !== "registration") return
    }
  }

  return { mergedDiagnostics, updatePushDiagnostics, updatePullDiagnostics, emitRegistrationChange, waitForDocumentDiagnostics, waitForFullDiagnostics }
}
