/**
 * Pages service — thin client over the existing REST endpoints, shaped
 * around the data the Pages admin area needs.
 *
 * Data model (v1):
 *   - Pages are documents in the `pages` collection.
 *       · `data.path`        — full URL path, e.g. "/about/story" (falls
 *                              back to "/" + slug)
 *       · `data.tags`        — string[] of tag slugs
 *       · `data.primaryTag`  — tag slug used for "Group by tag"
 *       · `data.seoScore` / `data.metaTitle` / `data.metaDescription`
 *   - Tags are documents in the hidden `page-tags` collection.
 *       · title = tag name, slug = tag slug
 *       · `data.color` / `data.description` / `data.sortOrder` / `data.active`
 *
 * Why DB-backed tags (not code-defined like Post Types)?
 *   Tags are editorial data that teams create/rename/merge/delete at
 *   runtime. Post Types are content *models* (schema) and must be
 *   code-defined; tags are *labels* and belong in the database. Storing
 *   them as documents reuses the existing collection CRUD + access
 *   control with zero new endpoints.
 *
 * Filtering/sorting/grouping happen client-side over the fetched set
 * (the seed is 13 rows). Promote to a server-side `/api/cms/pages`
 * aggregate when the collection grows past ~5k rows or page-load
 * latency exceeds 300ms — only this file changes; callers keep the same
 * `Page[]` / `PageTag[]` shapes.
 */
import { cmsApi } from './api.js'

export type PageStatus = 'DRAFT' | 'PUBLISHED' | 'SCHEDULED' | 'ARCHIVED'

export const PAGES_COLLECTION = 'pages'
export const PAGE_TAGS_COLLECTION = 'page-tags'

export interface PageTag {
  id: string // == slug (stable join key; pages store slugs in data.tags)
  docId: string // underlying document id — required for tag CRUD
  name: string
  slug: string
  description: string | null
  color: string | null
  pageCount: number
  sortOrder: number
  active: boolean
  createdAt: string | null
  updatedAt: string | null
}

export interface PageAuthor {
  id: string
  name: string
  initials: string
}

export interface Page {
  id: string
  title: string
  path: string
  slug: string
  parentPageId: string | null
  tags: string[] // tag slugs
  primaryTagId: string | null // tag slug
  authorId: string
  authorName: string
  authorInitials: string
  status: PageStatus
  publishDate: string | null
  scheduledDate: string | null
  lastModified: string
  seoScore: number | null
  seoTitle: string | null
  seoDescription: string | null
  templateId: string | null
  isHomepage: boolean
  isSystemPage: boolean
  createdAt: string
  updatedAt: string
}

export type PageGroupBy = 'none' | 'tag' | 'status' | 'author'

export interface PagesQuery {
  search?: string
  tagIds?: string[] // tag slugs; OR semantics
  status?: PageStatus | 'all'
  authorId?: string | 'all'
  groupBy?: PageGroupBy
  sortBy?: 'title' | 'author' | 'status' | 'modified' | 'seo'
  sortDirection?: 'asc' | 'desc'
  page?: number
  pageSize?: number
}

export interface PagesResult {
  pages: Page[]
  /** number of pages matching the active query (post-filter) */
  total: number
  /** total pages in the collection, ignoring the active query/filters */
  grandTotal: number
  page: number
  pageSize: number
  tags: PageTag[]
  authors: PageAuthor[]
}

/**
 * The full, unfiltered page set plus its tags and authors. The list view
 * fetches this once and applies search / sort / group / paging in memory
 * (see `applyPagesQuery` + `groupPages`), so typing in the search box or
 * changing the sort never re-downloads the dataset.
 */
export interface PagesDataset {
  pages: Page[]
  tags: PageTag[]
  authors: PageAuthor[]
}

// ─── API row shapes ──────────────────────────────────────────────────

interface DocumentApiRow {
  id: string
  collection: string
  title: string | null
  slug: string | null
  status: PageStatus
  createdById: string | null
  updatedById: string | null
  publishedAt: string | null
  scheduledAt: string | null
  createdAt: string
  updatedAt: string
  data: Record<string, unknown> | null
}

interface ListEnvelope {
  docs: DocumentApiRow[]
  total: number
}

interface UserApiRow {
  id: string
  name?: string | null
  email: string
}

// ─── Helpers ─────────────────────────────────────────────────────────

function initialsFromName(name: string | null | undefined, email: string): string {
  if (name && name.trim().length > 0) {
    const parts = name.trim().split(/\s+/)
    const first = parts[0]?.[0] ?? ''
    const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : ''
    return (first + last).toUpperCase() || (email[0] ?? '?').toUpperCase()
  }
  return (email[0] ?? '?').toUpperCase()
}

async function fetchAuthorsMap(): Promise<Map<string, PageAuthor>> {
  const res = await cmsApi<{ users: UserApiRow[] } | UserApiRow[]>('/users?pageSize=200')
  const list: UserApiRow[] = Array.isArray(res.data)
    ? res.data
    : ((res.data as { users?: UserApiRow[] } | null)?.users ?? [])
  const map = new Map<string, PageAuthor>()
  for (const u of list) {
    map.set(u.id, {
      id: u.id,
      name: u.name?.trim() || u.email,
      initials: initialsFromName(u.name, u.email),
    })
  }
  return map
}

function asString(v: unknown): string | null {
  return typeof v === 'string' && v.length > 0 ? v : null
}

function asStringArray(v: unknown): string[] {
  if (!Array.isArray(v)) return []
  return v.filter((x): x is string => typeof x === 'string' && x.length > 0)
}

function extractSeoScore(data: Record<string, unknown> | null): number | null {
  if (!data) return null
  for (const k of ['seoScore', 'seo_score', 'metaScore']) {
    const val = data[k]
    if (typeof val === 'number' && Number.isFinite(val)) return val
  }
  return null
}

function pageDisplayDate(d: DocumentApiRow): string {
  if (d.status === 'SCHEDULED' && d.scheduledAt) return d.scheduledAt
  if (d.status === 'PUBLISHED' && d.publishedAt) return d.publishedAt
  return d.updatedAt
}

// ─── Tags ────────────────────────────────────────────────────────────

function docToTag(d: DocumentApiRow, pageCount: number): PageTag {
  const data = d.data ?? {}
  return {
    id: d.slug ?? d.id,
    docId: d.id,
    name: d.title ?? d.slug ?? '(untitled tag)',
    slug: d.slug ?? d.id,
    description: asString(data.description),
    color: asString(data.color),
    pageCount,
    sortOrder: typeof data.sortOrder === 'number' ? data.sortOrder : 0,
    active: data.active !== false,
    createdAt: d.createdAt ?? null,
    updatedAt: d.updatedAt ?? null,
  }
}

async function fetchTagDocs(): Promise<DocumentApiRow[]> {
  const res = await cmsApi<ListEnvelope>(
    `/collections/${PAGE_TAGS_COLLECTION}?pageSize=500&sort=updatedAt&order=desc`,
  )
  return (res.data?.docs ?? []) as DocumentApiRow[]
}

/**
 * Fetch all tags. `pageCounts` (slug → count) is optional so the tag
 * manager / pickers can render before page data is loaded.
 */
export async function fetchPageTags(pageCounts?: Map<string, number>): Promise<PageTag[]> {
  const docs = await fetchTagDocs()
  const tags = docs.map((d) => docToTag(d, pageCounts?.get(d.slug ?? d.id) ?? 0))
  tags.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
  return tags
}

export async function fetchAuthors(): Promise<PageAuthor[]> {
  const map = await fetchAuthorsMap()
  return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
}

// ─── Pages ───────────────────────────────────────────────────────────

function docToPage(d: DocumentApiRow, authors: Map<string, PageAuthor>): Page {
  const data = d.data ?? {}
  const slug = d.slug ?? asString(data.slug) ?? ''
  const path = asString(data.path) ?? (slug ? `/${slug}`.replace(/\/+/g, '/') : '/')
  const author = (d.createdById && authors.get(d.createdById)) ||
    (d.updatedById && authors.get(d.updatedById)) || { id: '', name: 'Unknown', initials: '??' }
  const tags = asStringArray(data.tags)
  const primaryTagId = asString(data.primaryTag) ?? tags[0] ?? null

  return {
    id: d.id,
    title: d.title ?? asString(data.title) ?? '(untitled)',
    path,
    slug,
    parentPageId: asString(data.parentPageId),
    tags,
    primaryTagId,
    authorId: author.id,
    authorName: author.name,
    authorInitials: author.initials,
    status: d.status,
    publishDate: d.publishedAt ?? null,
    scheduledDate: d.scheduledAt ?? null,
    lastModified: pageDisplayDate(d),
    seoScore: extractSeoScore(data),
    seoTitle: asString(data.metaTitle) ?? asString(data.seoTitle),
    seoDescription: asString(data.metaDescription) ?? asString(data.seoDescription),
    templateId: asString(data.templateId),
    isHomepage: path === '/' || data.isHomepage === true,
    isSystemPage: data.isSystemPage === true,
    createdAt: d.createdAt,
    updatedAt: d.updatedAt,
  }
}

function comparePagesFor(by: NonNullable<PagesQuery['sortBy']>, a: Page, b: Page): number {
  switch (by) {
    case 'author':
      return a.authorName.localeCompare(b.authorName)
    case 'status':
      return a.status.localeCompare(b.status)
    case 'seo': {
      const av = a.seoScore ?? -1
      const bv = b.seoScore ?? -1
      return av - bv
    }
    case 'modified':
      return new Date(a.lastModified).getTime() - new Date(b.lastModified).getTime()
    case 'title':
    default:
      return a.title.localeCompare(b.title)
  }
}

/**
 * Pure filter+sort over an in-memory page set. Exported for unit tests
 * (keeps filtering logic decoupled from the network).
 */
export function applyPagesQuery(all: Page[], query: PagesQuery): Page[] {
  const search = (query.search ?? '').trim().toLowerCase()
  const tagFilter = query.tagIds && query.tagIds.length > 0 ? new Set(query.tagIds) : null

  const filtered = all.filter((p) => {
    if (query.status && query.status !== 'all' && p.status !== query.status) return false
    if (query.authorId && query.authorId !== 'all' && p.authorId !== query.authorId) return false
    // Tag chips use OR semantics: keep a page if it has any selected tag.
    if (tagFilter && !p.tags.some((t) => tagFilter.has(t))) return false
    if (search) {
      const haystack = [
        p.title,
        p.path,
        p.slug,
        p.authorName,
        p.status,
        p.seoTitle ?? '',
        p.seoDescription ?? '',
        p.tags.join(' '),
      ]
        .join(' ')
        .toLowerCase()
      if (!haystack.includes(search)) return false
    }
    return true
  })

  const sortBy = query.sortBy ?? 'title'
  const dir = query.sortDirection ?? 'asc'
  filtered.sort((a, b) => {
    const cmp = comparePagesFor(sortBy, a, b)
    return dir === 'asc' ? cmp : -cmp
  })
  return filtered
}

export interface PageGroup {
  key: string
  label: string
  color: string | null
  pages: Page[]
}

/**
 * Group an already-filtered/sorted page list. A page appears in exactly
 * ONE group (no duplicates): for `tag`, its primary tag (or first tag,
 * or "Untagged"). Returns a single "All pages" group for `none`.
 */
export function groupPages(pages: Page[], groupBy: PageGroupBy, tags: PageTag[]): PageGroup[] {
  if (groupBy === 'none') {
    return [{ key: 'all', label: 'All pages', color: null, pages }]
  }

  const tagBySlug = new Map(tags.map((t) => [t.slug, t]))
  const buckets = new Map<string, PageGroup>()
  const order: string[] = []

  const ensure = (key: string, label: string, color: string | null) => {
    if (!buckets.has(key)) {
      buckets.set(key, { key, label, color, pages: [] })
      order.push(key)
    }
    return buckets.get(key)!
  }

  for (const p of pages) {
    if (groupBy === 'tag') {
      const slug = p.primaryTagId ?? p.tags[0] ?? null
      if (!slug) {
        ensure('__untagged', 'Untagged', null).pages.push(p)
      } else {
        const tag = tagBySlug.get(slug)
        ensure(slug, tag?.name ?? slug, tag?.color ?? null).pages.push(p)
      }
    } else if (groupBy === 'status') {
      ensure(p.status, statusLabel(p.status), null).pages.push(p)
    } else if (groupBy === 'author') {
      ensure(p.authorId || '__unknown', p.authorName, null).pages.push(p)
    }
  }

  return order.map((k) => buckets.get(k)!)
}

function statusLabel(s: PageStatus): string {
  return s.charAt(0) + s.slice(1).toLowerCase()
}

/**
 * Fetch the entire page dataset (unfiltered) plus tags and authors. This
 * is the single network read backing the list view; callers apply query
 * shaping in memory. Tag page counts and the author list are derived from
 * the full set so they reflect the dataset, not any active filter.
 */
export async function fetchPagesDataset(): Promise<PagesDataset> {
  const authorsMap = await fetchAuthorsMap()

  const [pagesRes, tagDocs] = await Promise.all([
    cmsApi<ListEnvelope>(`/collections/${PAGES_COLLECTION}?pageSize=500&sort=updatedAt&order=desc`),
    fetchTagDocs(),
  ])

  const allPages = ((pagesRes.data?.docs ?? []) as DocumentApiRow[]).map((d) =>
    docToPage(d, authorsMap),
  )

  const counts = new Map<string, number>()
  for (const p of allPages) {
    for (const slug of p.tags) counts.set(slug, (counts.get(slug) ?? 0) + 1)
  }
  const tags = tagDocs
    .map((d) => docToTag(d, counts.get(d.slug ?? d.id) ?? 0))
    .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))

  const seen = new Set<string>()
  const authors: PageAuthor[] = []
  for (const p of allPages) {
    if (p.authorId && !seen.has(p.authorId)) {
      seen.add(p.authorId)
      authors.push({ id: p.authorId, name: p.authorName, initials: p.authorInitials })
    }
  }
  authors.sort((a, b) => a.name.localeCompare(b.name))

  return { pages: allPages, tags, authors }
}

/**
 * Query a paginated, filtered page list. Retained for non-list callers
 * (tag detach/merge) and tests; the list view uses `fetchPagesDataset`
 * directly and shapes the result in memory.
 */
export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
  const { pages: allPages, tags, authors } = await fetchPagesDataset()

  const filtered = applyPagesQuery(allPages, query)

  // Pagination only applies to the flat (ungrouped) view; grouped views
  // render every match so the buckets stay complete.
  const grouped = query.groupBy && query.groupBy !== 'none'
  const page = Math.max(1, query.page ?? 1)
  const pageSize = Math.max(1, query.pageSize ?? 25)
  const paged = grouped ? filtered : filtered.slice((page - 1) * pageSize, page * pageSize)

  return {
    pages: paged,
    total: filtered.length,
    grandTotal: allPages.length,
    page,
    pageSize,
    tags,
    authors,
  }
}

export async function fetchPageById(pageId: string): Promise<Page | null> {
  const res = await cmsApi<DocumentApiRow>(
    `/collections/${PAGES_COLLECTION}/${encodeURIComponent(pageId)}`,
  )
  if (res.error || !res.data) return null
  const authors = await fetchAuthorsMap()
  return docToPage(res.data, authors)
}

// ─── Bulk page mutations ─────────────────────────────────────────────

export interface BulkResult {
  ok: number
  failed: number
}

/**
 * Run `task` over `items` with a bounded number of concurrent requests so
 * a large selection doesn't fire one-at-a-time (slow) or all-at-once
 * (overwhelms the API / rate limiter). Each task resolves to `true` on
 * success; failures are counted, never thrown, so one bad row can't abort
 * the batch.
 */
async function mapWithConcurrency<T>(
  items: T[],
  limit: number,
  task: (item: T) => Promise<boolean>,
): Promise<BulkResult> {
  let ok = 0
  let failed = 0
  let cursor = 0
  const workerCount = Math.max(1, Math.min(limit, items.length))
  const worker = async () => {
    while (cursor < items.length) {
      const item = items[cursor++]!
      const success = await task(item).catch(() => false)
      if (success) ok++
      else failed++
    }
  }
  await Promise.all(Array.from({ length: workerCount }, worker))
  return { ok, failed }
}

export async function bulkUpdatePages(
  ids: string[],
  payload: { status?: PageStatus; data?: Record<string, unknown> },
): Promise<BulkResult> {
  return mapWithConcurrency(ids, 5, async (id) => {
    const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
      method: 'PUT',
      body: JSON.stringify(payload),
    })
    return !res.error
  })
}

export async function bulkDeletePages(ids: string[]): Promise<BulkResult> {
  return mapWithConcurrency(ids, 5, async (id) => {
    const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
      method: 'DELETE',
    })
    return !res.error
  })
}

/**
 * Add or remove tag slugs across many pages. We fetch each page's
 * current tags, merge/strip, then PUT the new `data.tags`. Done
 * per-page because tags live in the document JSON (no bulk-tag endpoint
 * yet). `primaryTag` is cleared if it was removed.
 */
export async function bulkChangePageTags(
  pages: Pick<Page, 'id' | 'tags' | 'primaryTagId'>[],
  change: { add?: string[]; remove?: string[] },
): Promise<BulkResult> {
  const add = new Set(change.add ?? [])
  const remove = new Set(change.remove ?? [])
  return mapWithConcurrency(pages, 5, async (p) => {
    const next = new Set(p.tags)
    for (const t of add) next.add(t)
    for (const t of remove) next.delete(t)
    const tags = [...next]
    const primaryTag =
      p.primaryTagId && next.has(p.primaryTagId) ? p.primaryTagId : (tags[0] ?? null)
    const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(p.id)}`, {
      method: 'PUT',
      body: JSON.stringify({ data: { tags, primaryTag } }),
    })
    return !res.error
  })
}

// ─── Tag CRUD ────────────────────────────────────────────────────────

export interface PageTagInput {
  name: string
  slug?: string
  color?: string
  description?: string
  sortOrder?: number
  active?: boolean
}

function slugify(input: string): string {
  return input
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
}

export async function createPageTag(input: PageTagInput): Promise<{ error?: string }> {
  const slug = input.slug?.trim() || slugify(input.name)
  if (!slug) return { error: 'Tag name is required' }
  const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}`, {
    method: 'POST',
    body: JSON.stringify({
      title: input.name,
      slug,
      status: 'PUBLISHED',
      data: {
        color: input.color ?? 'gray',
        description: input.description ?? '',
        sortOrder: input.sortOrder ?? 0,
        active: input.active !== false,
      },
    }),
  })
  return res.error ? { error: res.error } : {}
}

export async function updatePageTag(
  tagDocId: string,
  input: Partial<PageTagInput>,
): Promise<{ error?: string }> {
  const data: Record<string, unknown> = {}
  if (input.color !== undefined) data.color = input.color
  if (input.description !== undefined) data.description = input.description
  if (input.sortOrder !== undefined) data.sortOrder = input.sortOrder
  if (input.active !== undefined) data.active = input.active
  const body: Record<string, unknown> = { data }
  if (input.name !== undefined) body.title = input.name
  const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}/${encodeURIComponent(tagDocId)}`, {
    method: 'PUT',
    body: JSON.stringify(body),
  })
  return res.error ? { error: res.error } : {}
}

export async function deletePageTag(tagDocId: string): Promise<{ error?: string }> {
  const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}/${encodeURIComponent(tagDocId)}`, {
    method: 'DELETE',
  })
  return res.error ? { error: res.error } : {}
}

/**
 * Delete a tag and detach it from every page that uses it (so no
 * dangling slugs remain in page `data.tags`). Used by the tag manager's
 * "remove from all pages" deletion path.
 */
export async function deletePageTagWithDetach(tag: PageTag): Promise<{ error?: string }> {
  if (tag.pageCount > 0) {
    const res = await fetchPages({ tagIds: [tag.slug], pageSize: 1000 })
    if (res.pages.length > 0) {
      await bulkChangePageTags(
        res.pages.map((p) => ({ id: p.id, tags: p.tags, primaryTagId: p.primaryTagId })),
        { remove: [tag.slug] },
      )
    }
  }
  return deletePageTag(tag.docId)
}

/**
 * Merge `source` into `target`: every page tagged with the source slug
 * gets the target slug (primary tag re-pointed too), then the source
 * tag document is deleted.
 */
export async function mergePageTags(source: PageTag, target: PageTag): Promise<{ error?: string }> {
  if (source.slug === target.slug) return { error: 'Cannot merge a tag into itself' }
  const res = await fetchPages({ tagIds: [source.slug], pageSize: 1000 })
  for (const p of res.pages) {
    const next = new Set(p.tags)
    next.delete(source.slug)
    next.add(target.slug)
    const tags = [...next]
    const primaryTag = p.primaryTagId === source.slug ? target.slug : p.primaryTagId
    const put = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(p.id)}`, {
      method: 'PUT',
      body: JSON.stringify({ data: { tags, primaryTag } }),
    })
    if (put.error) return { error: put.error }
  }
  return deletePageTag(source.docId)
}
