// Shared text helpers for the knowledge wiki (Knowledge.svelte and its
// components). Markdown rendering, slug auto-linking, folder/grouping
// derivation, and a small line diff for the History view — split out from
// any one component since WikiReader and WikiContextRail both need the
// rendering/linking half, and WikiTree needs the grouping half.
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { KnowledgeListItem } from '$lib/api'

// Matches a bare entity/knowledge slug like `lxc:gitea` or
// `document:containers/101-jellyfin` — real examples pulled straight from
// the data (`grep`-confirmed: operators and Nomos both write bare slugs
// throughout note bodies today). The `[[wiki-link]]` bracket syntax some
// wikis use was considered and dropped: only one note in the live DB
// contains "[[" at all, and it's an HTML comment, not a link — building
// bracket-syntax parsing would add real complexity (nesting, alias syntax,
// double-substitution risk with this very regex) for a feature nobody
// writes.
//
// Anchored to start with a lowercase letter specifically to reject
// clock-times like "10:08" or "20:40" that are common in this dataset's
// investigation titles/bodies (digits don't match `[a-z]`) and to reject
// "https://..." (the char after ':' there is '/', which fails the
// alnum-first requirement on the right-hand side).
const SLUG_PATTERN = `\\b([a-z][a-z0-9-]{1,30}:[a-zA-Z0-9][a-zA-Z0-9\\-/._]*)\\b`

// A fenced code block (```...```, across lines) or an inline code span
// (`...`, single line) OR a bare slug — tried in that order at every
// position. Fenced/inline code always wins the match when present, so a
// slug-shaped token *inside* a code example (a runbook's shell snippet
// referencing e.g. `host:strong/some-path`) is consumed whole as code and
// never reaches the slug branch. Without this, linkifySlugs ran the slug
// regex over raw markdown with no idea code existed, rewrote the slug
// inside the span to `[slug](#kb:slug)`, and `marked` then rendered that
// literal bracket/paren syntax as text inside the <code> tag instead of
// treating it as code. Doesn't handle every markdown code-span edge case
// (double-backtick escaping for spans containing a literal backtick, `~~~`
// fences) — just the two forms actually used in this corpus.
const TOKEN_PATTERN = new RegExp('(```[\\s\\S]*?```)|(`[^`\\n]+`)|(' + SLUG_PATTERN + ')', 'g')

// Wraps every bare slug in `text` with a placeholder markdown link
// (`[slug](#kb:slug)`) before it reaches `marked`, so the renderer emits a
// real `<a>` that the reader's click handler (see WikiReader.svelte) can
// intercept. The `#kb:` prefix is never a real anchor on this page — it's
// just a tag so the click handler can tell "one of ours" apart from a
// legitimate external link without inspecting every href.
//
// Trailing punctuation immediately after a slug (a period ending a
// sentence, a comma, a closing paren) is peeled off and left outside the
// link — "see host:strong." must not swallow the sentence's full stop into
// the link target.
function linkifySlugs(text: string): string {
  return text.replace(TOKEN_PATTERN, (match, fence, inlineCode) => {
    if (fence || inlineCode) return match // code — leave untouched, see TOKEN_PATTERN's comment
    const trailing = match.match(/[.,;:)]+$/)?.[0] ?? ''
    const slug = trailing ? match.slice(0, -trailing.length) : match
    if (!slug.includes(':')) return match // shouldn't happen given the pattern, but stay safe
    return `[${slug}](#kb:${encodeURIComponent(slug)})${trailing}`
  })
}

// Full markdown render for the reader pane: linkify first (plain text, so
// the regex never sees HTML), then render, then sanitize. Mirrors
// EntityDetailContent.svelte's renderMarkdown (marked + DOMPurify, no tag
// restriction) rather than Knowledge.svelte's old snippet-only sanitize
// (which allowlisted only `<b>` for ts_headline output) — this renders a
// full note body, not a search snippet.
export function renderWikiMarkdown(text: string): string {
  const linked = linkifySlugs(text)
  return DOMPurify.sanitize(marked.parse(linked, { async: false }) as string)
}

// Parses a `#kb:<encoded-slug>` href back into the slug, or null if `href`
// isn't one of ours (a real external/relative link the browser should
// handle normally).
export function slugFromKbHref(href: string | null): string | null {
  if (!href || !href.startsWith('#kb:')) return null
  try {
    return decodeURIComponent(href.slice('#kb:'.length))
  } catch {
    return null
  }
}

// ─── Grouping (navigator tree) ─────────────────────────────────────────────

export type GroupBy = 'folder' | 'kind' | 'tag' | 'entity'

const UNGROUPED = '(ungrouped)'

// The slug format is `<kind>:<folder>/<name>` for namespaced notes (agent
// and seeded content) or plain `<kind>:<name>` for the flat runbooks
// (runbook:lifecycle-activate-node). The latter has no folder segment, so
// it groups under UNGROUPED rather than being silently dropped.
export function noteFolder(item: KnowledgeListItem): string {
  const afterColon = item.slug.slice(item.slug.indexOf(':') + 1)
  const idx = afterColon.lastIndexOf('/')
  return idx === -1 ? UNGROUPED : afterColon.slice(0, idx)
}

export interface WikiGroup {
  key: string
  label: string
  items: KnowledgeListItem[]
}

// Groups `items` by the chosen dimension. `tag` and `entity` are
// many-to-many — a note with three tags appears in three groups — which is
// deliberate: those two modes are for "show me everything touching X," not
// a strict partition like folder/kind are.
export function groupNotes(items: KnowledgeListItem[], by: GroupBy): WikiGroup[] {
  const groups = new Map<string, KnowledgeListItem[]>()
  const push = (key: string, item: KnowledgeListItem) => {
    const arr = groups.get(key)
    if (arr) arr.push(item)
    else groups.set(key, [item])
  }

  for (const item of items) {
    switch (by) {
      case 'folder':
        push(noteFolder(item), item)
        break
      case 'kind':
        push(item.kind, item)
        break
      case 'tag':
        if (item.tags.length === 0) push(UNGROUPED, item)
        else for (const t of item.tags) push(t, item)
        break
      case 'entity':
        if (item.about.length === 0) push(UNGROUPED, item)
        else for (const slug of item.about) push(slug, item)
        break
    }
  }

  const out: WikiGroup[] = [...groups.entries()].map(([key, groupItems]) => ({
    key,
    label: key,
    items: groupItems.sort((a, b) => a.title.localeCompare(b.title))
  }))

  // Ungrouped/misc always last; otherwise alphabetical, largest-first ties
  // broken by label so the ordering is stable across reloads.
  out.sort((a, b) => {
    if (a.key === UNGROUPED) return 1
    if (b.key === UNGROUPED) return -1
    return a.label.localeCompare(b.label)
  })
  return out
}

// ─── Line diff (History tab) ───────────────────────────────────────────────

export type DiffOp = { type: 'equal' | 'add' | 'remove'; line: string }

// Textbook O(n*m) LCS-based line diff. Notes in this system are small
// (the seed data averages ~1KB, agent-written investigations rarely exceed
// 2KB, so a few dozen lines at most) — the quadratic cost is invisible at
// this size and a full Myers-diff dependency would be a lot of code for a
// feature that only needs to render a readable before/after in the History
// tab, not power a merge tool.
export function diffLines(oldText: string, newText: string): DiffOp[] {
  const a = oldText.split('\n')
  const b = newText.split('\n')
  const n = a.length
  const m = b.length

  // lcs[i][j] = length of the LCS of a[i:] and b[j:]
  const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
  for (let i = n - 1; i >= 0; i--) {
    for (let j = m - 1; j >= 0; j--) {
      lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
    }
  }

  const ops: DiffOp[] = []
  let i = 0
  let j = 0
  while (i < n && j < m) {
    if (a[i] === b[j]) {
      ops.push({ type: 'equal', line: a[i] })
      i++
      j++
    } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
      ops.push({ type: 'remove', line: a[i] })
      i++
    } else {
      ops.push({ type: 'add', line: b[j] })
      j++
    }
  }
  while (i < n) ops.push({ type: 'remove', line: a[i++] })
  while (j < m) ops.push({ type: 'add', line: b[j++] })
  return ops
}
