<template>
  <div class="fc-tag-autocomplete">
    <v-text-field
      ref="inputRef"
      v-model="query"
      placeholder="Add tag (or kind:name — character/fandom/series)"
      density="compact" hide-details
      @keydown.down.prevent="moveHighlight(1)"
      @keydown.up.prevent="moveHighlight(-1)"
      @keydown.enter.prevent="onEnter"
      @keydown.tab="onTab"
      @keydown.esc="$emit('cancel')"
    />
    <v-list
      v-if="rows.length"
      ref="listRef"
      density="compact" class="fc-tag-autocomplete__list"
    >
      <template v-for="(row, idx) in rows" :key="row.key">
        <!-- One unified row per DB tag (server autocomplete). Every suggestion is
             a canonical tag now, so when the model ALSO scored this tag for the
             image the row just carries its confidence (🔧 %) in place of the
             redundant kind label — the coloured leading icon already shows kind.
             That means a searched tag that's also a suggestion shows its % on ONE
             row, with no dedup and no flicker. Picking it emits pick-existing;
             TagPanel.findPending routes a matching suggestion through accept() so
             the acceptance is still recorded + it drops from the panel. -->
        <v-list-item
          v-if="row.type === 'hit'"
          :active="idx === highlight" @click="onPickRow(row)"
        >
          <template #prepend>
            <v-icon size="small" :color="store.colorFor(row.hit.kind)">
              {{ iconFor(row.hit.kind) }}
            </v-icon>
          </template>
          <v-list-item-title>
            {{ row.hit.name }}
            <span v-if="row.hit.fandom_name" class="text-caption">— {{ row.hit.fandom_name }}</span>
          </v-list-item-title>
          <template #append>
            <span
              v-if="row.sugg"
              class="fc-tag-autocomplete__sugg-tag"
              :class="{ 'fc-tag-autocomplete__sugg-tag--below': !row.sugg.above_threshold }"
              :title="row.sugg.above_threshold
                ? 'The model suggests this tag for this image'
                : 'The model scored this tag below its suggest threshold'"
            >
              <v-icon size="x-small">mdi-auto-fix</v-icon>
              {{ scorePct(row.sugg) }}
            </span>
            <span v-else class="text-caption">{{ row.hit.kind }}</span>
          </template>
        </v-list-item>

        <!-- Create-new row. -->
        <v-list-item
          v-else
          :active="idx === highlight" @click="onPickRow(row)"
        >
          <template #prepend>
            <v-icon size="small" :color="store.colorFor(parsedKind)">
              {{ iconFor(parsedKind) }}
            </v-icon>
          </template>
          <v-list-item-title>
            {{ createLabel }}
          </v-list-item-title>
        </v-list-item>
      </template>
    </v-list>

    <!-- @after-enter is the reliable moment to focus the picker: the dialog's
         open transition + focus-trap have settled, so focusSearch lands in the
         fandom field instead of fighting the trap (operator-flagged 2026-06-07). -->
    <v-dialog
      v-model="fandomDialog" max-width="480"
      @after-enter="fandomPickerRef?.focusSearch?.()"
    >
      <FandomPicker
        ref="fandomPickerRef"
        @confirm="onFandomChosen" @cancel="onFandomCancel"
      />
    </v-dialog>
  </div>
</template>

<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useTagStore } from '../../stores/tags.js'
import { useSuggestionsStore } from '../../stores/suggestions.js'
import { useInflightToken } from '../../composables/useInflightToken.js'
import FandomPicker from './FandomPicker.vue'

const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
// The host surface's applied tags (host.current?.tags). The dropdown filters
// against this so a just-added tag drops out of the search the moment the chip
// rail updates, not after the next modal refresh (operator-asked 2026-07-03).
const props = defineProps({
  appliedTags: { type: Array, default: () => [] },
})
const store = useTagStore()
// The image's ML (Camie) suggestions, surfaced inline in this dropdown so the
// operator can pick a suggestion while typing instead of hunting for it in the
// Suggestions panel below (operator-asked 2026-06-07).
const suggestions = useSuggestionsStore()

// Autofocus on modal open so the operator can type the moment the view
// modal renders, no extra click required (operator-asked 2026-06-01).
// Vuetify's v-text-field exposes .focus() on the component instance;
// nextTick waits for the modal's mount to finish so the inner <input>
// element exists.
//
// EXCEPTION — not on mobile (operator-flagged 2026-06-05): focusing the
// field pops the soft keyboard, which shrinks the visual viewport and
// shoves the pinned image + its nav/close controls out of view. 900px is
// the modal's stacked-layout breakpoint (ImageViewer.vue). matchMedia is
// safe on plain HTTP (not a secure-context API).
const inputRef = ref(null)
function focusInput () {
  if (window.matchMedia?.('(max-width: 900px)')?.matches) return
  // Focus the inner <input> with preventScroll: handing focus back after an
  // accept/reject must NOT yank the rail's scroll up to this field, which sits
  // above the suggestions list (operator-flagged 2026-07-06 — "I have to scroll
  // back down every time I apply/reject"). Vuetify's own .focus() forwards no
  // options, so reach the element directly; fall back to it if it's not there.
  nextTick(() => {
    const el = inputRef.value?.$el?.querySelector?.('input')
    if (el) el.focus({ preventScroll: true })
    else inputRef.value?.focus?.()
  })
}
onMounted(focusInput)
// Exposed so the parent (TagPanel) can hand focus back to this field after an
// auto-suggestion is accepted, keeping the keyboard flow on the input instead
// of dropping to <body> (operator-asked 2026-06-08). Mobile guard preserved.
defineExpose({ focus: focusInput })

// Single text input; no kind dropdown. Client-side mirror of the
// backend's parse_kind_prefix lives below — kept in sync with
// KNOWN_KINDS in backend/app/utils/tag_prefix.py. The backend is the
// canonical parser; this mirror just powers the live Create-label
// preview ("Create 'Eric' as artist") and the autocomplete query.
const query = ref('')
const hits = ref([])
const highlight = ref(0)
const listRef = ref(null)
const fandomDialog = ref(false)
const fandomPickerRef = ref(null)
let pendingNewName = null

const KNOWN_KINDS = new Set([
  'character', 'fandom', 'series',
])
const KIND_ICONS = {
  general: 'mdi-tag', character: 'mdi-account-circle',
  fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
  // 'system' is a suggestion category (wip/banner/editor), not a tag kind —
  // matches the shield marker on system-tag chips.
  system: 'mdi-shield-outline',
}
function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }

const parsed = computed(() => {
  const raw = query.value.trim()
  if (raw.includes(':')) {
    const idx = raw.indexOf(':')
    const prefix = raw.slice(0, idx).toLowerCase()
    if (KNOWN_KINDS.has(prefix)) {
      return { kind: prefix, name: raw.slice(idx + 1).trim() }
    }
  }
  return { kind: 'general', name: raw }
})
const parsedKind = computed(() => parsed.value.kind)
const parsedName = computed(() => parsed.value.name)

// Inflight guard: the debounce only clears the TIMER, so once a fetch has fired
// it still races later ones — a slower earlier-prefix response ("s") could land
// after "sex" and overwrite the dropdown with stale, wrong-prefix matches
// (operator-flagged 2026-07-06). Gate each response on a token so only the latest
// query's results are applied.
const acInflight = useInflightToken()
let debounceId = null
watch(query, () => {
  highlight.value = 0
  if (debounceId) clearTimeout(debounceId)
  acInflight.cancel()
  debounceId = setTimeout(async () => {
    const q = parsedName.value
    if (!q) { hits.value = []; return }
    // Autocomplete across ALL kinds. When the user typed a prefix the
    // matches list is naturally narrower because the parsed name is
    // shorter; we don't filter server-side by kind.
    const t = acInflight.claim()
    const res = await store.autocomplete(q, null, 10)
    if (t.isCurrent()) hits.value = res
  }, 200)
})

// A same-name character ALREADY exists. Characters are unique by
// (name, kind, fandom), so this is still a valid distinct tag in another fandom.
const sameNameCharExists = computed(() =>
  parsedKind.value === 'character' &&
  hits.value.some(h =>
    h.kind === 'character' && h.name.toLowerCase() === parsedName.value.toLowerCase(),
  ),
)

const allowCreate = computed(() => {
  const q = parsedName.value
  if (!q) return false
  // Characters disambiguate by fandom, so a same-named character in a DIFFERENT
  // fandom is a valid new tag — always offer Create (the fandom picker resolves
  // it; find_or_create is idempotent if you re-pick the same fandom). Other
  // kinds are unique by (name, kind): an exact match means it already exists.
  if (parsedKind.value === 'character') return true
  return !hits.value.some(h =>
    h.name.toLowerCase() === q.toLowerCase() && h.kind === parsedKind.value,
  )
})

const createLabel = computed(() =>
  sameNameCharExists.value
    ? `Create another "${parsedName.value}" character (different fandom)`
    : `Create "${parsedName.value}" as ${parsedKind.value}`,
)

function scorePct (s) { return `${Math.round(s.score * 100)}%` }

const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id)))

// Server matches minus the image's applied tags. allowCreate/sameNameCharExists
// keep reading the RAW hits — they reason about the tag universe (does this tag
// exist?), not about this image.
const visibleHits = computed(() =>
  hits.value.filter(h => !appliedIds.value.has(h.id)),
)

// This image's suggestions keyed by canonical tag id, so a matching DB-tag row
// can show the model's confidence inline. Every suggestion is a canonical tag
// now (tagging-v2), so the id is the join key — no name/kind matching, no dedup,
// no flicker. Rejected suggestions are excluded: a dismissed tag shouldn't
// advertise a score in the type-to-add dropdown (un-reject lives in the panel).
const suggByTagId = computed(() => {
  const m = new Map()
  for (const list of Object.values(suggestions.byCategory)) {
    for (const s of list || []) {
      if (s.rejected) continue
      if (s.canonical_tag_id != null) m.set(s.canonical_tag_id, s)
    }
  }
  return m
})

// One ordered list backing both the rendered dropdown and keyboard nav, so the
// highlight index maps 1:1 to a row. Each hit is annotated with its suggestion
// (score) when the model scored that tag for this image.
const rows = computed(() => {
  const r = visibleHits.value.map(h => ({
    type: 'hit', key: `h${h.id}`, hit: h, sugg: suggByTagId.value.get(h.id) || null,
  }))
  if (allowCreate.value) r.push({ type: 'create', key: 'create' })
  return r
})

function moveHighlight (delta) {
  const total = rows.value.length
  if (total === 0) return
  highlight.value = (highlight.value + delta + total) % total
  // Keep the highlighted row visible — the list is capped at 240px and arrowing
  // past the fold otherwise left the active item off-screen (operator-flagged
  // 2026-06-07). block:'nearest' scrolls the minimum needed.
  nextTick(() => {
    listRef.value?.$el
      ?.querySelector('.v-list-item--active')
      ?.scrollIntoView({ block: 'nearest' })
  })
}

// Dispatch a chosen dropdown row by its type.
function onPickRow (row) {
  if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
  else { onCreate() }
}

function onCreate () {
  const name = parsedName.value
  const kind = parsedKind.value
  if (kind === 'character') {
    pendingNewName = name
    fandomDialog.value = true
    return
  }
  // Pass explicit kind here; the backend accepts both shapes. Passing
  // it makes the parsed kind preview match the actual server outcome
  // for users who didn't use a prefix (general goes through cleanly).
  emit('pick-new', { name, kind, fandom_id: null })
  reset()
}

// fandom is null when the user picked "No fandom" — characters don't all
// belong to a fandom. The backend already accepts fandom_id: null for the
// character kind (tag.kind check + nullable fandom_id), and a fandom can be
// assigned later from the chip kebab's "Set fandom…".
function onFandomChosen (fandom) {
  fandomDialog.value = false
  emit('pick-new', {
    name: pendingNewName, kind: 'character', fandom_id: fandom ? fandom.id : null,
  })
  pendingNewName = null
  reset()
  // Return focus to the tag input so the keyboard flow continues straight into
  // the next tag instead of dropping to <body> (operator-flagged 2026-06-07).
  focusInput()
}

// Cancelling the fandom dialog drops the pending character and hands focus back
// to the tag input, same as a successful pick.
function onFandomCancel () {
  fandomDialog.value = false
  pendingNewName = null
  focusInput()
}

function onEnter () {
  const row = rows.value[highlight.value]
  if (row) onPickRow(row)
}

// B5 (2026-06-07): when the dropdown is open, Tab accepts the highlighted row
// (standard autocomplete convention) instead of leaving the field. With the
// list closed it falls through to normal focus traversal.
function onTab (e) {
  if (rows.value.length) {
    e.preventDefault()
    onEnter()
  }
}

function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
</script>

<style scoped>
.fc-tag-autocomplete__list {
  margin-top: 4px;
  max-height: 240px;
  overflow-y: auto;
  background: rgb(var(--v-theme-surface));
  border: 1px solid rgb(var(--v-theme-surface-light));
  border-radius: 6px;
}
/* The model-confidence badge on a row whose tag the model also scored. Accent
   when above the head's suggest threshold; muted when below (a low-confidence
   match you can still type + pick). */
.fc-tag-autocomplete__sugg-tag {
  display: inline-flex; align-items: center; gap: 2px;
  font-size: 11px;
  font-family: 'JetBrains Mono', monospace;
  color: rgb(var(--v-theme-accent));
}
.fc-tag-autocomplete__sugg-tag--below {
  color: rgb(var(--v-theme-on-surface-variant));
}
</style>
