// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
// Vite proxies to the Go server in dev and the binary serves directly in prod.

// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the
// backend TagColor* constants; unknown values render as rose.
export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey'

export interface Tag {
  id: string
  name: string
  color: TagColor
  doc_count?: number // present only in the tag-roster listing
}

export interface DocSummary {
  id: string
  title: string
  word_count: number
  updated_at: string
  tags: Tag[]
}

// One cross-document search hit. `snippet` is plain text with the matched span
// wrapped in the … sentinels (see splitSnippet) for highlighting.
export interface SearchResult {
  id: string
  title: string
  word_count: number
  updated_at: string
  snippet: string
  tags: Tag[]
}

export interface Document {
  id: string
  user_id: string
  title: string
  content: string // Tiptap JSON (stringified)
  content_text: string // flattened plain text
  tone: string // target writing tone; steers LLM advice
  word_count: number
  created_at: string
  updated_at: string
}

// Fields the editor sends on auto-save. All optional so a rename can send title
// alone; the editor sends the full set.
export interface DocUpdate {
  title?: string
  content?: string
  content_text?: string
  tone?: string
  word_count?: number
}

// One sense of a word from the offline dictionary.
export interface WordMeaning {
  part_of_speech: string
  definition: string
  example?: string
}

// The offline lookup for one word: a Chinese gloss, a few definition senses, and
// a list of synonyms. Any of these may be empty when the word isn't a headword.
export interface WordInfo {
  word: string
  gloss: string // Chinese translation; '' when the word isn't in the gloss set
  phonetic: string // IPA for the English word; '' when absent
  definitions: WordMeaning[]
  synonyms: string[]
}

// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
export interface Gloss {
  word: string
  gloss: string
}

export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation'

// One word in the vocabulary garden: a looked-up word with its gloss/phonetic,
// the sentence it was met in, and its spaced-repetition state. `reps` drives how
// "bloomed" its blossom looks; `due_at` decides when it next surfaces for review.
export interface VocabWord {
  id: string
  word: string
  gloss: string
  definition: string // English fallback meaning shown in review when there's no gloss
  phonetic: string
  example: string
  doc_id: string | null
  due_at: string
  interval_days: number
  ease: number
  reps: number
  lapses: number
  last_reviewed: string | null
  created_at: string
}

// The self-assessment grades a flashcard review can record.
export type VocabGrade = 'again' | 'good' | 'easy'

// A point-in-time snapshot of a document. List responses omit the heavy
// content/content_text fields; they arrive only on getVersion (preview/restore).
export interface DocumentVersion {
  id: string
  doc_id: string
  title: string
  content?: string
  content_text?: string
  word_count: number
  kind: 'auto' | 'manual' | 'pre_restore'
  created_at: string
}

// Downloadable export formats. PDF is handled client-side via the browser's
// print dialog (CJK-safe, no server-side font embedding).
export type ExportFormat = 'md' | 'html' | 'txt' | 'docx'

// A single LLM-proposed edit. `original` is the source of truth for placement —
// the editor re-anchors by matching this string in the live document (spec
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
// empty for voice flags (awareness-only, no correction to apply).
export interface Suggestion {
  id: string
  doc_id: string
  from_pos: number
  to_pos: number
  original: string
  replacement: string
  explanation: string
  type: SuggestionType
  status: 'pending' | 'accepted' | 'rejected'
  created_at: string
}

async function req<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`/api${path}`, {
    headers: { 'Content-Type': 'application/json' },
    ...init,
  })
  if (!res.ok) {
    const detail = await res.text().catch(() => '')
    throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
  }
  if (res.status === 204) return undefined as T
  return res.json() as Promise<T>
}

export const api = {
  listDocs: () => req<DocSummary[]>('/docs'),
  createDoc: () => req<Document>('/docs', { method: 'POST' }),
  getDoc: (id: string) => req<Document>(`/docs/${id}`),
  updateDoc: (id: string, body: DocUpdate) =>
    req<Document>(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
  deleteDoc: (id: string) => req<void>(`/docs/${id}`, { method: 'DELETE' }),

  // Grammar checkpoint: runs an LLM pass and returns the fresh pending set.
  // Rate-limited per document server-side (returns the existing set if too soon).
  // Both passes return the UNIFIED pending set (grammar + voice), so the client
  // never drops one family's highlights when the other refreshes.
  checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }),
  // Voice-consistency pass: whole-document, explicit-action, slower. Returns the
  // unified pending set too. Rate-limited per document server-side.
  voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }),
  // Collocation coach: whole-document, explicit-action pass flagging non-native
  // word pairings ("do a decision" → "make a decision"). Returns the unified
  // pending set too. Rate-limited per document server-side.
  collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }),
  // Pending suggestions for a doc, loaded when the editor opens it.
  listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
  acceptSuggestion: (id: string) =>
    req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
  dismissSuggestion: (id: string) =>
    req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
  // Simplified-Chinese rendering of a suggestion's explanation, for the Ask Petal
  // opening bubble (the explanation itself stays English in the card body).
  translateSuggestion: (id: string) =>
    req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }),

  // Version history. listVersions returns metadata only (no bodies); getVersion
  // loads one full snapshot for preview; snapshotDoc takes an explicit restore
  // point; restoreVersion copies a snapshot back onto the live doc (capturing a
  // pre_restore safety copy server-side first) and returns the restored doc.
  listVersions: (id: string) => req<DocumentVersion[]>(`/docs/${id}/versions`),
  getVersion: (id: string, vid: string) =>
    req<DocumentVersion>(`/docs/${id}/versions/${vid}`),
  snapshotDoc: (id: string) =>
    req<DocumentVersion>(`/docs/${id}/versions`, { method: 'POST' }),
  restoreVersion: (id: string, vid: string) =>
    req<Document>(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }),

  // Download URL for an exported document (md/html/txt/docx). Used as an <a
  // href download> target so the browser handles the file save.
  exportUrl: (id: string, format: ExportFormat) =>
    `/api/docs/${id}/export?format=${format}`,

  // Download URL for a whole-corpus backup: a zip of every document rendered in
  // the given format. A one-click "download all my writing" safety net.
  exportAllUrl: (format: ExportFormat) => `/api/docs/export-all?format=${format}`,

  // Offline word lookup (gloss + definition + synonyms) for the right-click popover.
  lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
  // Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
  // and offline, so it fires on hover without spinning up the heavier lookup.
  glossWord: (word: string) => req<Gloss>(`/gloss/${encodeURIComponent(word)}`),
  // Tone-rewrite: rewrites a selected passage in the given style ('natural',
  // 'academic', …) and returns the rewritten text for an in-editor preview. Not
  // persisted — the editor applies it directly on accept.
  rewriteSelection: (docId: string, text: string, style: string) =>
    req<{ rewrite: string }>(`/docs/${docId}/rewrite`, {
      method: 'POST',
      body: JSON.stringify({ text, style }),
    }),

  // Cross-document full-text search (title + body). Returns hits with a
  // highlighted snippet. Empty query returns []. Encodes the term for the URL.
  search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),

  // Image upload: posts a single image file as multipart form data and returns
  // its served URL (e.g. /api/images/<hash>.png). Stored on disk by the binary,
  // so the document JSON keeps a small URL reference instead of inlined base64.
  // Doesn't go through req() because the body is FormData, not JSON.
  uploadImage: async (file: File): Promise<{ url: string }> => {
    const form = new FormData()
    form.append('image', file)
    const res = await fetch('/api/images', { method: 'POST', body: form })
    if (!res.ok) {
      const detail = await res.text().catch(() => '')
      throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
    }
    return res.json() as Promise<{ url: string }>
  },

  // Tags. listTags returns the roster with per-tag document counts; createTag is
  // idempotent on name (returns the existing tag if it already exists);
  // updateTag recolors/renames; deleteTag removes it (assignments cascade).
  // assignTag/unassignTag link a tag to one document.
  listTags: () => req<Tag[]>('/tags'),
  createTag: (name: string, color: TagColor) =>
    req<Tag>('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }),
  updateTag: (id: string, patch: { name?: string; color?: TagColor }) =>
    req<Tag>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
  deleteTag: (id: string) => req<void>(`/tags/${id}`, { method: 'DELETE' }),
  assignTag: (docId: string, tagId: string) =>
    req<void>(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }),
  unassignTag: (docId: string, tagId: string) =>
    req<void>(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }),

  // Vocabulary garden. recordVocab captures (or refreshes) a looked-up word —
  // idempotent per word, fired automatically on lookup. listVocab is the whole
  // garden; dueVocab is just the cards ready for review; reviewVocab grades one
  // card and returns its rescheduled state; deleteVocab removes a word.
  recordVocab: (body: {
    word: string
    gloss?: string
    definition?: string
    phonetic?: string
    example?: string
    doc_id?: string | null
  }) => req<VocabWord>('/vocab', { method: 'POST', body: JSON.stringify(body) }),
  listVocab: () => req<VocabWord[]>('/vocab'),
  dueVocab: () => req<VocabWord[]>('/vocab/due'),
  reviewVocab: (id: string, grade: VocabGrade) =>
    req<VocabWord>(`/vocab/${id}/review`, { method: 'POST', body: JSON.stringify({ grade }) }),
  deleteVocab: (id: string) => req<void>(`/vocab/${id}`, { method: 'DELETE' }),

  // Current deployed build id — changes whenever a new frontend ships. The
  // app polls this to offer a refresh. Bypasses any cache so the answer is live.
  version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
}

// The sentinel characters the server wraps a search match in (\x01 … \x02). Used
// by splitSnippet to render the matched span highlighted.
export const SNIPPET_HL_START = String.fromCharCode(1)
export const SNIPPET_HL_END = String.fromCharCode(2)

// splitSnippet breaks a server snippet into ordered { text, hit } segments so the
// UI can bold the matched span(s) without dangerously setting innerHTML.
export function splitSnippet(snippet: string): { text: string; hit: boolean }[] {
  const out: { text: string; hit: boolean }[] = []
  let rest = snippet
  for (;;) {
    const start = rest.indexOf(SNIPPET_HL_START)
    if (start < 0) {
      if (rest) out.push({ text: rest, hit: false })
      break
    }
    if (start > 0) out.push({ text: rest.slice(0, start), hit: false })
    const end = rest.indexOf(SNIPPET_HL_END, start + 1)
    if (end < 0) {
      // Unterminated (shouldn't happen) — emit the remainder plainly.
      out.push({ text: rest.slice(start + 1), hit: false })
      break
    }
    out.push({ text: rest.slice(start + 1, end), hit: true })
    rest = rest.slice(end + 1)
  }
  return out
}

// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall
// back to the rose accent, matching the backend's coercion.
export function tagColorVar(color: TagColor | string): string {
  switch (color) {
    case 'mint':
      return 'var(--color-mint)'
    case 'peach':
      return 'var(--color-peach)'
    case 'lavender':
      return 'var(--color-lavender)'
    case 'sky':
      return 'var(--color-sky)'
    case 'honey':
      return 'var(--color-honey)'
    default:
      return 'var(--color-accent)'
  }
}

// One turn in an Ask Petal conversation. History lives only in the component —
// the server is stateless and re-injects the suggestion context every request.
export interface ChatMessage {
  role: 'user' | 'assistant'
  content: string
}

// streamSuggestionChat POSTs the conversation to the SSE chat endpoint and
// invokes onToken for each text chunk as it arrives. It uses fetch + a
// ReadableStream reader (not EventSource, which can't POST) and resolves when
// the stream ends. Abort via the optional signal to cancel mid-response.
export async function streamSuggestionChat(
  suggestionId: string,
  messages: ChatMessage[],
  onToken: (text: string) => void,
  signal?: AbortSignal,
): Promise<void> {
  const res = await fetch(`/api/suggestions/${suggestionId}/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages }),
    signal,
  })
  if (!res.ok || !res.body) {
    const detail = await res.text().catch(() => '')
    throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
  }

  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  let buf = ''
  for (;;) {
    const { done, value } = await reader.read()
    if (done) break
    buf += decoder.decode(value, { stream: true })
    // SSE events are separated by a blank line. Process every complete one and
    // keep the trailing partial in the buffer.
    let sep: number
    while ((sep = buf.indexOf('\n\n')) >= 0) {
      const event = parseSSE(buf.slice(0, sep))
      buf = buf.slice(sep + 2)
      if (event.name === 'done') return
      if (event.name === 'token' && event.data) {
        const text = (JSON.parse(event.data) as { text: string }).text
        if (text) onToken(text)
      }
    }
  }
}

// parseSSE pulls the event name and data payload out of one raw SSE frame.
function parseSSE(frame: string): { name: string; data: string } {
  let name = 'message'
  const dataLines: string[] = []
  for (const line of frame.split('\n')) {
    if (line.startsWith('event:')) name = line.slice(6).trim()
    else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
  }
  return { name, data: dataLines.join('\n') }
}
