import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js'
import { computed, ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'

// Category display order: system (hygiene flags) first for quick review, then
// people, general last. 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01)
// retired. 'system' groups the wip/banner/editor hygiene tags apart from content
// tags (they're kind=general on the backend but surface under their own category).
export const CATEGORY_ORDER = ['system', 'character', 'general']
export const CATEGORY_LABELS = {
  system: 'System',
  character: 'Character',
  general: 'General'
}

export const useSuggestionsStore = defineStore('suggestions', () => {
  const api = useApi()
  // ONE source of truth per image: every trained head scored for this image
  // (min=0), each row carrying above_threshold. The Suggestions PANEL renders
  // aboveByCategory; the typed tag-input dropdown reads the full set and
  // annotates matching DB-tag rows with their score. A single fetch backs both —
  // every suggestion is a canonical tag now (tagging-v2, #114), so there is no
  // raw-prediction / create-new / alias-remap path to reconcile.
  const byCategory = ref({})
  const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
  let currentImageId = null
  // Audit 2026-06-02: without an inflight guard a late /suggestions response from
  // a prior image could overwrite byCategory while currentImageId points at a new
  // one, and accept() could apply image A's tag to image B. Both guarded below by
  // capturing imageId at call-time and gating writes on the token.
  const inflight = useInflightToken()

  // The curated above-threshold subset the Suggestions panel shows. A rejected
  // row that still scores above its cut stays (flagged) so the rejection is
  // visible + reversible; below-threshold rows live only in the dropdown.
  const aboveByCategory = computed(() => {
    const out = {}
    for (const [cat, list] of Object.entries(byCategory.value)) {
      const kept = (list || []).filter(s => s.above_threshold)
      if (kept.length) out[cat] = kept
    }
    return out
  })

  async function load(imageId) {
    // Cancel any in-flight load from the previous image so its late response
    // can't overwrite this image's list.
    inflight.cancel()
    currentImageId = imageId
    byCategory.value = {}  // cleared upfront so it stays empty on error
    const t = inflight.claim()
    await run(async () => {
      // min=0 → every head, each flagged above_threshold; the panel + the typed
      // dropdown are both derived from this one payload.
      const body = await api.get(`/api/images/${imageId}/suggestions`, {
        params: { min: 0 },
      })
      if (!t.isCurrent()) return
      byCategory.value = body.by_category || {}
    })
  }

  // A stable identity for a suggestion across the panel + dropdown surfaces:
  // its canonical tag id (every suggestion has one now).
  function _keyOf(s) {
    return `id:${s.canonical_tag_id}`
  }

  // Drop an accepted suggestion from the list so it can't reappear.
  function _dropEverywhere(suggestion) {
    const key = _keyOf(suggestion)
    const list = byCategory.value[suggestion.category]
    if (list) byCategory.value[suggestion.category] = list.filter(s => _keyOf(s) !== key)
  }

  // Flip the `rejected` flag on the matching suggestion in place, so a
  // reject/un-reject shows immediately without dropping the row (visible,
  // reversible rejection — misclick recovery, operator-asked 2026-06-27).
  function _setRejectedEverywhere(suggestion, value) {
    const key = _keyOf(suggestion)
    for (const s of byCategory.value[suggestion.category] || []) {
      if (_keyOf(s) === key) s.rejected = value
    }
  }

  // opts.tagId: the tag's known id when the caller already resolved it (TagPanel's
  // manual-add-matches-suggestion path). Passed separately — NOT spread onto the
  // suggestion — because _dropEverywhere keys off the suggestion object.
  async function accept(suggestion, { tagId: knownTagId = null } = {}) {
    // Capture imageId so a mid-flight prev/next can't reroute the accept POST to
    // a different image.
    const imageId = currentImageId
    if (imageId == null) return
    const tagId = knownTagId ?? suggestion.canonical_tag_id
    await api.post(`/api/images/${imageId}/suggestions/accept`, {
      body: { tag_id: tagId }
    })
    // Only drop from THIS image's list — if the user navigated, the new image
    // has its own suggestions and this drop would corrupt them.
    if (currentImageId === imageId) {
      _dropEverywhere(suggestion)
    }
    _acceptToast('Tagged', suggestion.display_name)
  }

  // One non-blocking toast for accept. The accepted tag is applied to this image
  // and feeds head training; head auto-apply handles propagation (earned).
  function _acceptToast(verb, displayName) {
    toast({ text: `${verb}: ${displayName}`, type: 'success' })
  }

  // Find a live (non-rejected) suggestion matching a manually-picked tag — by
  // canonical id when known, else by (kind, name). Manually adding a tag the
  // model also suggested must be indistinguishable from accepting the suggestion
  // (recorded + dropped from the panel), so TagPanel routes matches through
  // accept() (operator-asked 2026-07-03).
  function findPending(kind, name, tagId = null) {
    const lname = (name || '').toLowerCase()
    for (const list of Object.values(byCategory.value)) {
      for (const s of list || []) {
        if (s.rejected) continue
        if (tagId != null && s.canonical_tag_id === tagId) return s
        if (s.category === kind && (s.display_name || '').toLowerCase() === lname) return s
      }
    }
    return null
  }

  async function dismiss(suggestion) {
    const imageId = currentImageId
    if (imageId == null) return
    // Canonical tags persist a rejection and STAY in the list flagged rejected,
    // so the operator can see it and one-click un-reject (misclick recovery).
    await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
      body: { tag_id: suggestion.canonical_tag_id }
    })
    if (currentImageId === imageId) {
      _setRejectedEverywhere(suggestion, true)
    }
  }

  // Reject every still-unhandled VISIBLE (above-threshold) suggestion in a
  // category in one go ("confirm the good ones, reject the rest" — operator-asked
  // 2026-07-06). Only touches what the panel shows, not the low-confidence tail
  // the dropdown carries. Dispatched in parallel so a big section clears fast.
  async function dismissRemaining(category) {
    const imageId = currentImageId
    if (imageId == null) return
    const targets = (byCategory.value[category] || []).filter(
      (s) => s.above_threshold && !s.rejected
    )
    if (!targets.length) return
    await Promise.all(targets.map((s) =>
      api.post(`/api/images/${imageId}/suggestions/dismiss`, {
        body: { tag_id: s.canonical_tag_id },
      })
    ))
    if (currentImageId === imageId) {
      targets.forEach((s) => _setRejectedEverywhere(s, true))
    }
  }

  // Undo a per-image dismissal — the suggestion reverts to a live row.
  async function undismiss(suggestion) {
    const imageId = currentImageId
    if (imageId == null) return
    await api.post(`/api/images/${imageId}/suggestions/undismiss`, {
      body: { tag_id: suggestion.canonical_tag_id }
    })
    if (currentImageId === imageId) {
      _setRejectedEverywhere(suggestion, false)
    }
  }

  return {
    byCategory, aboveByCategory, loading, error,
    load, accept, dismiss, dismissRemaining, undismiss, findPending
  }
})
