<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
import { useDark } from "@vueuse/core"
import { basicSetup, EditorView } from "codemirror"
import { Compartment, EditorState } from "@codemirror/state"
import { oneDark } from "@codemirror/theme-one-dark"
import { javascript } from "@codemirror/lang-javascript"
import { html } from "@codemirror/lang-html"
import { css } from "@codemirror/lang-css"
import { json } from "@codemirror/lang-json"
import { python } from "@codemirror/lang-python"
import { sql } from "@codemirror/lang-sql"
import { php } from "@codemirror/lang-php"
import { yaml } from "@codemirror/lang-yaml"
import {
  AlignLeft,
  ChevronDown,
  FileCode2,
} from "@lucide/vue"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { FieldFrame } from "@/components/ui/field-frame"
import { cn } from "@/lib/utils"

export interface CodeEditorProps {
  modelValue?: string | null
  id?: string
  name?: string
  label?: string
  hideLabel?: boolean
  placeholder?: string
  required?: boolean
  readonly?: boolean
  disabled?: boolean
  helpText?: string
  errors?: string[]
  /** Language for syntax highlighting. Defaults to "javascript". */
  language?: string
  /** Fixed height for the editor area. If unset, min/max-height applies. */
  height?: string
  minHeight?: string
  maxHeight?: string
}

const props = withDefaults(defineProps<CodeEditorProps>(), {
  errors: () => [],
  language: "javascript",
  minHeight: "12rem",
  maxHeight: "40rem",
})

const emit = defineEmits<{
  (e: "update:modelValue", value: string): void
}>()

// ---------------------------------------------------------------------------
// Language registry
// ---------------------------------------------------------------------------

interface LangOption {
  label: string
  value: string
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  ext: () => any
}

const LANGUAGES: LangOption[] = [
  { label: "JavaScript", value: "javascript", ext: () => javascript() },
  { label: "TypeScript", value: "typescript", ext: () => javascript({ typescript: true }) },
  { label: "JSX", value: "jsx", ext: () => javascript({ jsx: true }) },
  { label: "TSX", value: "tsx", ext: () => javascript({ jsx: true, typescript: true }) },
  { label: "HTML", value: "html", ext: () => html() },
  { label: "CSS", value: "css", ext: () => css() },
  { label: "JSON", value: "json", ext: () => json() },
  { label: "Python", value: "python", ext: () => python() },
  { label: "SQL", value: "sql", ext: () => sql() },
  { label: "PHP", value: "php", ext: () => php() },
  { label: "YAML", value: "yaml", ext: () => yaml() },
]

function getLangExtension(lang: string) {
  return LANGUAGES.find((l) => l.value === lang)?.ext() ?? javascript()
}

// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------

const editorEl = ref<HTMLElement | null>(null)
let view: EditorView | null = null

const isDark = useDark()
const isInert = computed(() => Boolean(props.disabled || props.readonly))
const hasErrors = computed(() => props.errors.length > 0)

const activeLanguage = ref(props.language ?? "javascript")

// CodeMirror Compartments allow hot-swapping extensions
const langCompartment = new Compartment()
const themeCompartment = new Compartment()
const editableCompartment = new Compartment()
const readonlyCompartment = new Compartment()
const heightCompartment = new Compartment()

// Prevent re-emitting when we programmatically set content
let suppressEmit = false

function buildTheme() {
  return isDark.value ? oneDark : []
}

/**
 * Apply sizing to the editor's `.cm-editor` root (not to `.cm-scroller`).
 * Per the CodeMirror docs, heights belong on `&` and `.cm-scroller` only
 * needs `overflow: auto` so content scrolls inside the bounded editor.
 */
function buildHeightTheme() {
  const sizing: Record<string, string> = props.height
    ? { height: props.height }
    : {
        minHeight: props.minHeight ?? "12rem",
        maxHeight: props.maxHeight ?? "40rem",
      }
  return EditorView.theme({
    "&": sizing,
    ".cm-scroller": { overflow: "auto" },
  })
}

// ---------------------------------------------------------------------------
// Mount / unmount
// ---------------------------------------------------------------------------

onMounted(() => {
  if (!editorEl.value) return

  const startDoc = props.modelValue ?? ""

  const state = EditorState.create({
    doc: startDoc,
    extensions: [
      basicSetup,
      langCompartment.of(getLangExtension(activeLanguage.value)),
      themeCompartment.of(buildTheme()),
      editableCompartment.of(EditorView.editable.of(!isInert.value)),
      readonlyCompartment.of(EditorState.readOnly.of(isInert.value)),
      EditorView.updateListener.of((update) => {
        if (update.docChanged && !suppressEmit) {
          emit("update:modelValue", update.state.doc.toString())
        }
      }),
      heightCompartment.of(buildHeightTheme()),
      EditorView.theme({
        "&": { fontSize: "0.8125rem" },
        ".cm-scroller": {
          fontFamily:
            "'ui-monospace', 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace",
        },
        ".cm-focused": { outline: "none" },
      }),
    ],
  })

  view = new EditorView({ state, parent: editorEl.value })
})

onBeforeUnmount(() => {
  view?.destroy()
  view = null
})

// ---------------------------------------------------------------------------
// Reactivity
// ---------------------------------------------------------------------------

// External modelValue → editor
watch(
  () => props.modelValue,
  (value) => {
    if (!view) return
    const current = view.state.doc.toString()
    const next = value ?? ""
    if (current === next) return
    suppressEmit = true
    view.dispatch({
      changes: { from: 0, to: view.state.doc.length, insert: next },
    })
    suppressEmit = false
  },
)

// Dark mode toggle
watch(isDark, () => {
  view?.dispatch({ effects: themeCompartment.reconfigure(buildTheme()) })
})

// disabled/readonly
watch(isInert, (v) => {
  view?.dispatch({
    effects: [
      editableCompartment.reconfigure(EditorView.editable.of(!v)),
      readonlyCompartment.reconfigure(EditorState.readOnly.of(v)),
    ],
  })
})

// Language change (programmatic)
watch(activeLanguage, (lang) => {
  view?.dispatch({ effects: langCompartment.reconfigure(getLangExtension(lang)) })
})

// language prop → activeLanguage (consumer controls)
watch(
  () => props.language,
  (lang) => {
    if (lang && lang !== activeLanguage.value) {
      activeLanguage.value = lang
    }
  },
)

// height / minHeight / maxHeight prop changes
watch(
  () => [props.height, props.minHeight, props.maxHeight],
  () => {
    view?.dispatch({
      effects: heightCompartment.reconfigure(buildHeightTheme()),
    })
  },
)

// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------

const activeLangLabel = computed(
  () => LANGUAGES.find((l) => l.value === activeLanguage.value)?.label ?? activeLanguage.value,
)

const canFormatJson = computed(() => activeLanguage.value === "json")

function setLanguage(lang: string): void {
  activeLanguage.value = lang
}

function formatJson(): void {
  if (!view) return
  try {
    const raw = view.state.doc.toString()
    const formatted = JSON.stringify(JSON.parse(raw), null, 2)
    if (formatted === raw) return
    suppressEmit = true
    view.dispatch({
      changes: { from: 0, to: view.state.doc.length, insert: formatted },
    })
    suppressEmit = false
    emit("update:modelValue", formatted)
  } catch {
    // Invalid JSON — ignore
  }
}
</script>

<template>
  <FieldFrame
    :label="label"
    :required="required"
    :hide-label="hideLabel"
    :for="id"
    :help-text="helpText"
    :errors="errors"
    data-testid="code-editor"
  >
    <div
      :class="cn(
        'overflow-hidden rounded-md border border-input shadow-sm',
        hasErrors ? 'border-destructive ring-1 ring-destructive' : '',
        isInert ? 'opacity-60' : '',
      )"
      :data-element-name="name ?? undefined"
    >
      <!-- Toolbar -->
      <div
        class="flex items-center gap-1 border-b border-border/60 bg-muted/40 px-2 py-1"
        :class="isInert ? 'pointer-events-none' : ''"
      >
        <FileCode2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />

        <!-- Language selector -->
        <DropdownMenu>
          <DropdownMenuTrigger as-child>
            <Button
              type="button"
              variant="ghost"
              size="sm"
              class="h-6 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
              data-testid="code-editor-lang-trigger"
            >
              {{ activeLangLabel }}
              <ChevronDown class="h-3 w-3" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start" class="w-40">
            <DropdownMenuItem
              v-for="lang in LANGUAGES"
              :key="lang.value"
              :class="activeLanguage === lang.value ? 'bg-muted font-medium' : ''"
              @click="setLanguage(lang.value)"
            >
              {{ lang.label }}
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>

        <!-- Format JSON button -->
        <Button
          v-if="canFormatJson"
          type="button"
          variant="ghost"
          size="sm"
          class="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
          title="Format JSON"
          data-testid="code-editor-format"
          @click="formatJson"
        >
          <AlignLeft class="h-3 w-3" />
          Format
        </Button>
      </div>

      <!-- CodeMirror mount point -->
      <div
        ref="editorEl"
        class="code-editor-cm"
        :data-testid="'code-editor-content'"
      />
    </div>
  </FieldFrame>
</template>

<style scoped>
/* Scoped overrides — keep CodeMirror inside our border/radius container.
 * Heights are owned by `heightCompartment` (a CodeMirror theme on `&`)
 * so we deliberately do NOT set height here — a fixed `height: 100%`
 * would override the theme-supplied min/max-height. */
:deep(.cm-editor.cm-focused) {
  outline: none;
}

:deep(.cm-gutters) {
  border-right-color: var(--border);
  background-color: var(--muted);
}

:deep(.cm-activeLineGutter) {
  background-color: color-mix(in oklch, var(--muted) 80%, var(--background));
}

:deep(.cm-selectionBackground),
:deep(.cm-focused .cm-selectionBackground) {
  background-color: color-mix(in oklch, var(--primary) 25%, transparent) !important;
}

:deep(.cm-cursor) {
  border-left-color: var(--foreground);
}
</style>
