import { and, eq, gt, lt, or, sql } from 'drizzle-orm'
import type { Querier } from '@platform-modules/db'
import { assertCanModerate, assertCanModify } from './authz.js'
import { clampLimit, decodeCursor, encodeCursor, type Keyset } from './cursor.js'
import { deriveCreatedAt } from './derive.js'
import {
  CommentAuthzError,
  CommentNotFoundError,
  CommentSanitizationError,
  CommentValidationError,
} from './errors.js'
import { normalizeCommentInput, MAX_BODY_LEN, type NormalizedCommentInput } from './model.js'
import { comments, type CommentsSchema } from './schema.js'
import type { Actor, Comment, CommentAuthor, CommentInput, CommentStatus, CommentTarget, EntityRef, ListQuery, Page, StoreOpts } from './types.js'

/** Maximum thread depth (spec §3 — FZ depth-cap=5). */
const MAX_DEPTH = 5

type Row = typeof comments.$inferSelect

/**
 * keysetFilter — the strict-ordered cursor predicate over NUMERIC (createdAtMs, seq).
 * Shared by list (target-scoped) and listForModeration (cross-target); both rely on
 * (createdAtMs, seq) being a TOTAL order — seq is a per-store bigserial, so this holds
 * globally (regardless of target).
 */
function keysetFilter(order: 'oldest' | 'newest', key: Keyset) {
  if (order === 'oldest') {
    return or(
      gt(comments.createdAtMs, key.createdAtMs),
      and(eq(comments.createdAtMs, key.createdAtMs), gt(comments.seq, key.seq))!,
    )!
  }
  return or(
    lt(comments.createdAtMs, key.createdAtMs),
    and(eq(comments.createdAtMs, key.createdAtMs), lt(comments.seq, key.seq))!,
  )!
}

/**
 * Map a DB row to a Comment — viewer-aware (spec §3 info-disclosure: guest email redacted
 * unless actor.canModerate).
 */
function toComment(row: Row, canModerate = false): Comment {
  let author: CommentAuthor
  if (row.authorKind === 'user') {
    author = { kind: 'user', userId: row.authorUserId ?? '' }
  } else {
    // guest — conditionally redact email; url is public (standard comment behavior)
    author = {
      kind: 'guest',
      name: row.authorName ?? '',
      ...(canModerate && row.authorEmail ? { email: row.authorEmail } : {}),
      ...(row.authorUrl ? { url: row.authorUrl } : {}),
    }
  }

  return {
    id: row.id,
    target: { type: row.targetType, id: row.targetId },
    parentId: row.parentId ?? null,
    author,
    body: row.body,
    bodyHtml: row.bodyHtml,
    status: row.status,
    depth: row.depth,
    createdAt: row.createdAt,
    editedAt: row.editedAt ?? null,
  }
}

export function assertSanitize(sanitize: unknown, verb: string): asserts sanitize is (raw: string) => string {
  if (typeof sanitize !== 'function') {
    throw new CommentSanitizationError(`${verb}: sanitize must be a function, got ${typeof sanitize}`)
  }
}

/**
 * list — flat paginated Page<Comment> (spec §3).
 * Default: status='published', order='oldest', limit=20.
 * Non-published statuses require actor.canModerate → CommentAuthzError.
 * Keyset: NUMERIC (createdAtMs, seq) — collation-safe (spec §3 ordering floor).
 */
export async function list(
  db: Querier<CommentsSchema>,
  target: CommentTarget,
  query: ListQuery = {},
  actor?: Actor,
  _opts: StoreOpts = {},
): Promise<Page<Comment>> {
  const order = query.order ?? 'oldest'
  const limit = clampLimit(query.limit)

  // Resolve statuses to filter on
  let requestedStatuses: CommentStatus[] = query.status
    ? Array.isArray(query.status) ? query.status : [query.status]
    : ['published']

  // Degenerate empty array (e.g. an untrusted `status: []` forwarded from a route)
  // must NEVER widen visibility — collapse to the safe public default (spec §3:
  // "DEFAULT 'published' only"). Without this, an empty list skips the canModerate
  // gate below AND degrades the WHERE to target-only → public disclosure of
  // pending/spam/trashed rows (hard-floor #3/#4).
  if (requestedStatuses.length === 0) requestedStatuses = ['published']

  // Non-published statuses require canModerate (spec §3)
  const nonPublished = requestedStatuses.filter((s) => s !== 'published')
  if (nonPublished.length > 0 && !actor?.canModerate) {
    throw new CommentAuthzError(
      'list',
      actor?.id ?? null,
      '(list)',
      `viewing non-published statuses [${nonPublished.join(',')}] requires canModerate`,
    )
  }

  // Base filters
  const filters = [
    eq(comments.targetType, target.type),
    eq(comments.targetId, target.id),
  ]

  // Status filter
  if (requestedStatuses.length === 1) {
    filters.push(eq(comments.status, requestedStatuses[0]!))
  } else {
    filters.push(
      or(...requestedStatuses.map((s) => eq(comments.status, s)))!,
    )
  }

  // Keyset cursor predicate
  if (query.cursor) {
    filters.push(keysetFilter(order, decodeCursor(query.cursor)))
  }

  // Fetch limit+1 to detect next page
  const rows = await db
    .select()
    .from(comments)
    .where(and(...filters))
    .orderBy(
      ...(order === 'oldest'
        ? [comments.createdAtMs, comments.seq]
        : [sql`${comments.createdAtMs} desc`, sql`${comments.seq} desc`]),
    )
    .limit(limit + 1)

  const hasMore = rows.length > limit
  const pageRows = hasMore ? rows.slice(0, limit) : rows

  const canMod = actor?.canModerate ?? false
  const items = pageRows.map((r) => toComment(r, canMod))

  let nextCursor: string | null = null
  if (hasMore && pageRows.length > 0) {
    const last = pageRows[pageRows.length - 1]!
    nextCursor = encodeCursor({ createdAtMs: last.createdAtMs, seq: last.seq })
  }

  return { items, nextCursor }
}

/**
 * getById — returns Comment or null; same guest-email redaction as list (spec §3).
 * Returns tombstoned rows (trashed rows are still valid Comment shapes).
 */
export async function getById(
  db: Querier<CommentsSchema>,
  id: string,
  actor?: Actor,
  _opts: StoreOpts = {},
): Promise<Comment | null> {
  const [row] = await db.select().from(comments).where(eq(comments.id, id)).limit(1)
  if (!row) return null
  return toComment(row, actor?.canModerate ?? false)
}

async function getRow(db: Querier<CommentsSchema>, id: string): Promise<Row> {
  const [row] = await db.select().from(comments).where(eq(comments.id, id)).limit(1)
  if (!row) throw new CommentNotFoundError(`id=${id}`)
  return row
}

/**
 * insertComment — internal create core (subpath-private; NOT barrelled).
 * Parent validation + depth + sanitize + author-mapping + insert with explicit status.
 */
export async function insertComment(
  db: Querier<CommentsSchema>,
  normalized: NormalizedCommentInput,
  status: CommentStatus,
  sanitize: (raw: string) => string,
  _opts: StoreOpts = {},
): Promise<EntityRef> {
  let depth = 0
  if (normalized.parentId) {
    const parent = await db
      .select()
      .from(comments)
      .where(eq(comments.id, normalized.parentId))
      .limit(1)
    if (!parent[0]) {
      throw new CommentValidationError('parentId', `parent comment not found: ${normalized.parentId}`)
    }
    if (
      parent[0].targetType !== normalized.target.type ||
      parent[0].targetId !== normalized.target.id
    ) {
      throw new CommentValidationError('parentId', 'parent comment belongs to a different target')
    }
    depth = Math.min(parent[0].depth + 1, MAX_DEPTH)
  }

  const { createdAtMs, createdAt } = deriveCreatedAt(() => new Date().toISOString())
  const bodyHtml = sanitize(normalized.body)
  const id = crypto.randomUUID()

  const authorKind = normalized.author.kind
  const authorUserId = authorKind === 'user' ? normalized.author.userId : null
  const authorName = authorKind === 'guest' ? normalized.author.name : null
  const authorEmail = authorKind === 'guest' ? (normalized.author.email ?? null) : null
  const authorUrl = authorKind === 'guest' ? (normalized.author.url ?? null) : null

  await db.insert(comments).values({
    id,
    targetType: normalized.target.type,
    targetId: normalized.target.id,
    parentId: normalized.parentId ?? null,
    depth,
    authorKind,
    authorUserId,
    authorName,
    authorEmail,
    authorUrl,
    body: normalized.body,
    bodyHtml,
    status,
    createdAtMs,
    createdAt,
    editedAt: null,
  })

  return { id, target: normalized.target }
}

/**
 * post — create a new comment (spec §3).
 * sanitize REQUIRED (hard-floor #1); missing/non-function → CommentSanitizationError.
 * initialStatus defaults to 'pending'; non-moderator passing non-pending → CommentAuthzError.
 * depth stored at insert: parent.depth+1 capped at MAX_DEPTH; 0 for top-level.
 * Validates parent exists + shares same target.
 */
export async function post(
  db: Querier<CommentsSchema>,
  input: CommentInput,
  actor: Actor | null,
  sanitize: unknown,
  opts: StoreOpts = {},
): Promise<EntityRef> {
  assertSanitize(sanitize, 'post')
  const normalized = normalizeCommentInput(input)

  // Resolve initial status — hard-floor #3
  let initialStatus: CommentStatus = 'pending'
  if (normalized.initialStatus && normalized.initialStatus !== 'pending') {
    if (!actor?.canModerate) {
      throw new CommentAuthzError(
        'post',
        actor?.id ?? null,
        '(new)',
        `non-moderator cannot set initialStatus to '${normalized.initialStatus}'`,
      )
    }
    initialStatus = normalized.initialStatus
  } else if (normalized.initialStatus === 'pending') {
    initialStatus = 'pending'
  }

  return insertComment(db, normalized, initialStatus, sanitize, opts)
}

/**
 * edit — update body + re-sanitize bodyHtml + bump editedAt (spec §3).
 * sanitize REQUIRED (hard-floor #1).
 * authz: author or canModerate (hard-floor #2).
 */
export async function edit(
  db: Querier<CommentsSchema>,
  id: string,
  body: string,
  actor: Actor,
  sanitize: unknown,
  _opts: StoreOpts = {},
): Promise<EntityRef> {
  assertSanitize(sanitize, 'edit')
  const row = await getRow(db, id)
  const owner = rowToAuthor(row)
  assertCanModify(actor, 'edit', owner, id)

  // Validate body
  if (typeof body !== 'string' || !body.trim()) {
    throw new CommentValidationError('body', 'must not be empty')
  }
  if (body.length > MAX_BODY_LEN) {
    throw new CommentValidationError('body', `exceeds maximum length of ${MAX_BODY_LEN} characters`)
  }

  const bodyHtml = sanitize(body)
  const editedAt = new Date().toISOString()

  await db.update(comments).set({ body, bodyHtml, editedAt }).where(eq(comments.id, id))

  return { id, target: { type: row.targetType, id: row.targetId } }
}

/**
 * setStatus — moderation transition (spec §3).
 * Requires actor.canModerate (hard-floor #2).
 */
export async function setStatus(
  db: Querier<CommentsSchema>,
  id: string,
  status: CommentStatus,
  actor: Actor,
  _opts: StoreOpts = {},
): Promise<EntityRef> {
  assertCanModerate(actor, 'setStatus', id)
  const row = await getRow(db, id)
  await db.update(comments).set({ status }).where(eq(comments.id, id))
  return { id, target: { type: row.targetType, id: row.targetId } }
}

/**
 * remove — tombstone if children exist, else hard-delete (spec §3 deletion floor).
 * authz: author or canModerate (hard-floor #2).
 */
export async function remove(
  db: Querier<CommentsSchema>,
  id: string,
  actor: Actor,
  _opts: StoreOpts = {},
): Promise<void> {
  const row = await getRow(db, id)
  const owner = rowToAuthor(row)
  assertCanModify(actor, 'remove', owner, id)

  // Check for children
  const [child] = await db
    .select({ id: comments.id })
    .from(comments)
    .where(eq(comments.parentId, id))
    .limit(1)

  if (child) {
    // TOMBSTONE: keep row, blank body+bodyHtml, status=trashed, null PII (spec §3)
    await db
      .update(comments)
      .set({
        body: '',
        bodyHtml: '',
        status: 'trashed',
        authorName: null,
        authorEmail: null,
        authorUrl: null,
      })
      .where(eq(comments.id, id))
  } else {
    // Leaf — hard-delete
    await db.delete(comments).where(eq(comments.id, id))
  }
}

/**
 * count — published-only count for "N comments" display (spec §3).
 */
export async function count(
  db: Querier<CommentsSchema>,
  target: CommentTarget,
  _opts: StoreOpts = {},
): Promise<number> {
  const [row] = await db
    .select({ n: sql<number>`count(*)::int` })
    .from(comments)
    .where(
      and(
        eq(comments.targetType, target.type),
        eq(comments.targetId, target.id),
        eq(comments.status, 'published'),
      ),
    )
  return row?.n ?? 0
}

/**
 * listForModeration — CROSS-TARGET moderation queue (spec §3 hard-floor #5).
 * NO target filter — scans all rows, filtered by status only.
 * actor REQUIRED + canModerate UNCONDITIONAL (fail-closed) — cross-target enumeration is
 * privileged even for published rows. Default status=['pending'], default order='newest'.
 * Keyset (createdAtMs, seq) is globally sound (seq = per-store bigserial — total order
 * across all targets). Guest email VISIBLE (moderator-only by construction).
 */
export async function listForModeration(
  db: Querier<CommentsSchema>,
  query: ListQuery = {},
  actor: Actor,
  _opts: StoreOpts = {},
): Promise<Page<Comment>> {
  // Fail-closed: cross-target enumeration is moderator-only, UNCONDITIONALLY (hard-floor #5)
  if (!actor?.canModerate) {
    throw new CommentAuthzError(
      'listForModeration',
      actor?.id ?? null,
      '(cross-target)',
      'cross-target moderation listing requires canModerate',
    )
  }

  const order = query.order ?? 'newest'
  const limit = clampLimit(query.limit)

  // Resolve statuses — default to the moderation queue (pending); empty array never widens
  let requestedStatuses: CommentStatus[] = query.status
    ? Array.isArray(query.status) ? query.status : [query.status]
    : ['pending']
  if (requestedStatuses.length === 0) requestedStatuses = ['pending']

  // Status filter ONLY — no target predicate (cross-target)
  const filters = [
    requestedStatuses.length === 1
      ? eq(comments.status, requestedStatuses[0]!)
      : or(...requestedStatuses.map((s) => eq(comments.status, s)))!,
  ]

  if (query.cursor) {
    filters.push(keysetFilter(order, decodeCursor(query.cursor)))
  }

  const rows = await db
    .select()
    .from(comments)
    .where(and(...filters))
    .orderBy(
      ...(order === 'oldest'
        ? [comments.createdAtMs, comments.seq]
        : [sql`${comments.createdAtMs} desc`, sql`${comments.seq} desc`]),
    )
    .limit(limit + 1)

  const hasMore = rows.length > limit
  const pageRows = hasMore ? rows.slice(0, limit) : rows
  const items = pageRows.map((r) => toComment(r, true)) // canModerate by construction

  let nextCursor: string | null = null
  if (hasMore && pageRows.length > 0) {
    const last = pageRows[pageRows.length - 1]!
    nextCursor = encodeCursor({ createdAtMs: last.createdAtMs, seq: last.seq })
  }

  return { items, nextCursor }
}

// Helper: extract CommentAuthor from a row (used for authz checks)
function rowToAuthor(row: Row): CommentAuthor {
  if (row.authorKind === 'user') {
    return { kind: 'user', userId: row.authorUserId ?? '' }
  }
  return { kind: 'guest', name: row.authorName ?? '' }
}
