import { and, eq, inArray, sql } from 'drizzle-orm'
import type { Querier, TransactionalDatabase } from '@platform-modules/db'
import { contentEntries, type ContentSchema } from './schema.js'
import { termsForEntry } from './taxonomy.js'
import { ContentAuthzError, type Actor } from './authz.js'
import {
  ContentValidationError,
  normalizeContentInput,
  type ContentEntry,
  type ContentInput,
  type ContentStatus,
  type ContentVisibility,
  type Sanitize,
} from './model.js'

// the migrate subpath is self-contained: a consumer types `importContent`'s `opts` from here alone —
// `actor` (authz) and `sanitize` (model) are both re-exported so the seam is callable from types only (agent-legibility).
export type { Actor } from './authz.js'
export type { Sanitize } from './model.js'

export interface ContentArchive {
  version: 1
  exportedAt: string
  entries: ContentEntry[]
  settings?: Record<string, unknown>
}

export class ContentMigrateError extends Error {
  override readonly name = 'ContentMigrateError'
  constructor(readonly detail: string) {
    super(`content migrate: ${detail}`)
  }
}

// Floors: status must be a known literal (normalize does NOT check it); id must be a real UUID before it
// lands in the uuid PK; body + entry count are DoS-capped. Authz: import is admin-only (canEditAny), fail-closed.
const STATUS_LITERALS: ContentStatus[] = ['draft', 'scheduled', 'published', 'trashed']
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const MAX_BODY_LENGTH = 1_000_000
const MAX_ENTRIES = 100_000

type Row = typeof contentEntries.$inferSelect

async function toEntry(db: Querier<ContentSchema>, row: Row): Promise<ContentEntry> {
  return {
    id: row.id,
    slug: row.slug,
    type: row.type,
    title: row.title,
    body: row.body,
    status: row.status,
    visibility: row.visibility,
    publishedAt: row.publishedAt ?? null,
    author: row.author,
    terms: await termsForEntry(db, row.id),
    createdAt: row.createdAt,
    updatedAt: row.updatedAt,
  }
}

function assertSanitize(sanitize: unknown): asserts sanitize is Sanitize {
  if (typeof sanitize !== 'function') {
    throw new ContentMigrateError('sanitize required')
  }
}

// Import writes authz-bearing fields (status/author/visibility/publishedAt) from an untrusted archive at
// full fidelity — so the CALLER is gated, not the fields stripped. Fail-closed before any db access.
// Requires BOTH canEditAny (overwrites others' rows) AND canPublish (writes status:'published' — the
// publish-level state store.publish gates behind canPublish; the two are independent booleans, so
// canEditAny alone would let an edit-any-without-publish actor force-publish via restore).
function assertCanImport(actor: Actor | undefined): void {
  if (actor?.canEditAny !== true || actor?.canPublish !== true) {
    throw new ContentAuthzError('update', actor?.id ?? '<anonymous>', 'import requires canEditAny and canPublish')
  }
}

// Export is a FULL unfiltered dump (all statuses + all visibilities) — it bypasses the per-viewer
// visibility floor store.list/getBySlug enforce. So gate the caller: a read-all capability is required.
// canEditAny only (export writes nothing — demanding canPublish would be semantically wrong). Fail-closed.
function assertCanExport(actor: Actor | undefined): void {
  if (actor?.canEditAny !== true) {
    throw new ContentAuthzError('read', actor?.id ?? '<anonymous>', 'export requires canEditAny')
  }
}

function assertTransactional(
  db: Querier<ContentSchema> | TransactionalDatabase<ContentSchema>,
): asserts db is TransactionalDatabase<ContentSchema> {
  if (typeof (db as { transaction?: unknown }).transaction !== 'function') {
    throw new ContentMigrateError('replace mode requires a TransactionalDatabase')
  }
}

function isUniqueViolation(e: unknown): boolean {
  let cur: unknown = e
  while (cur) {
    const code = (cur as { code?: unknown })?.code
    const msg = cur instanceof Error ? cur.message : String(cur)
    if (code === '23505' || /content_entries_type_slug_uq|duplicate key|unique constraint/i.test(msg)) return true
    cur = cur instanceof Error ? (cur as Error & { cause?: unknown }).cause : undefined
  }
  return false
}

type PreparedEntry = {
  source: ContentEntry
  normalized: ReturnType<typeof normalizeContentInput>
  safeBody: string
  safeStatus: ContentStatus
  safeVisibility: ContentVisibility
}

function toContentInput(entry: ContentEntry): ContentInput {
  return {
    id: entry.id,
    slug: entry.slug,
    type: entry.type,
    title: entry.title,
    body: entry.body,
    visibility: entry.visibility,
    termIds: entry.terms.map((t) => t.id),
  }
}

function prepareEntries(
  archive: ContentArchive,
  sanitize: Sanitize,
): { prepared: PreparedEntry[]; skipped: number; conflicts: string[] } {
  const prepared: PreparedEntry[] = []
  const conflicts: string[] = []
  let skipped = 0

  for (const entry of archive.entries) {
    const ref = `${entry?.type ?? '?'}/${entry?.slug ?? '?'}`
    try {
      // A PRESENT id must be a real UUID before it lands in the uuid PK (normalize does not check it).
      // An ABSENT id is allowed — the row is a create and the DB generates the uuid. Malformed → skip+report.
      if (entry.id !== undefined && entry.id !== null && (typeof entry.id !== 'string' || !UUID_RE.test(entry.id))) {
        conflicts.push(`${ref}: id — must be a valid UUID`)
        skipped++
        continue
      }
      if (!STATUS_LITERALS.includes(entry.status)) {
        conflicts.push(`${ref}: status — must be draft|scheduled|published|trashed`)
        skipped++
        continue
      }
      // DoS floor: reject an oversize body BEFORE normalize+sanitize (sanitizing a huge string is the attack).
      if (typeof entry.body === 'string' && entry.body.length > MAX_BODY_LENGTH) {
        conflicts.push(`${ref}: body — exceeds ${MAX_BODY_LENGTH} chars`)
        skipped++
        continue
      }
      const normalized = normalizeContentInput(toContentInput(entry))
      const safeBody = sanitize(normalized.body)
      const safeVisibility = normalized.visibility ?? 'private' // validated by normalize; private = least-exposure fallback
      prepared.push({ source: entry, normalized, safeBody, safeStatus: entry.status, safeVisibility })
    } catch (e) {
      if (e instanceof ContentValidationError) {
        conflicts.push(`${ref}: ${e.field} — ${e.detail}`)
        skipped++
        continue
      }
      // generic — never echo a raw driver/parse error (info-disclosure floor, same as §1 aggregateHealth).
      throw new ContentMigrateError('validation failed')
    }
  }

  return { prepared, skipped, conflicts }
}

function insertValues(p: PreparedEntry): typeof contentEntries.$inferInsert {
  const { source, normalized, safeBody, safeStatus, safeVisibility } = p
  return {
    id: source.id,
    slug: normalized.slug,
    type: normalized.type,
    title: normalized.title,
    body: safeBody,
    status: safeStatus,
    visibility: safeVisibility,
    publishedAt: source.publishedAt,
    author: source.author,
    createdAt: source.createdAt,
    updatedAt: source.updatedAt,
  }
}

export async function exportContent(
  db: Querier<ContentSchema>,
  opts: { actor: Actor; types?: string[]; includeSettings?: boolean },
): Promise<ContentArchive> {
  assertCanExport(opts?.actor) // admin-only full dump, fail-closed BEFORE any db read (typed error even if opts omitted)
  const rows =
    opts?.types?.length ?
      await db.select().from(contentEntries).where(inArray(contentEntries.type, opts.types))
    : await db.select().from(contentEntries)

  const archive: ContentArchive = {
    version: 1,
    exportedAt: new Date().toISOString(),
    entries: await Promise.all(rows.map((row) => toEntry(db, row))),
  }

  if (opts?.includeSettings) {
    archive.settings = {}
  }

  return archive
}

async function writePrepared(
  db: Querier<ContentSchema>,
  prepared: PreparedEntry[],
  mode: 'merge' | 'replace',
): Promise<{ imported: number; skipped: number }> {
  let imported = 0
  let skipped = 0

  for (const p of prepared) {
  if (mode === 'merge') {
      const [existing] = await db
        .select()
        .from(contentEntries)
        .where(and(eq(contentEntries.type, p.normalized.type), eq(contentEntries.slug, p.normalized.slug)))
        .limit(1)
      if (existing && p.source.updatedAt <= existing.updatedAt) {
        skipped++
        continue
      }
    }

    try {
      await db
        .insert(contentEntries)
        .values(insertValues(p))
        .onConflictDoUpdate({
          target: [contentEntries.type, contentEntries.slug],
          set: {
            title: p.normalized.title,
            body: p.safeBody,
            status: p.safeStatus,
            visibility: p.safeVisibility,
            publishedAt: p.source.publishedAt,
            author: p.source.author,
            updatedAt: p.source.updatedAt,
          },
          where: sql`${contentEntries.updatedAt} < excluded.updated_at`,
        })
      imported++
    } catch (e) {
      if (isUniqueViolation(e)) {
        skipped++
        continue
      }
      // generic — never echo a raw driver error (info-disclosure floor).
      throw new ContentMigrateError('write failed')
    }
  }

  return { imported, skipped }
}

export async function importContent(
  db: Querier<ContentSchema> | TransactionalDatabase<ContentSchema>,
  archive: ContentArchive,
  opts: { actor: Actor; mode: 'merge' | 'replace'; sanitize: Sanitize },
): Promise<{ imported: number; skipped: number; conflicts: string[] }> {
  assertCanImport(opts?.actor) // admin-only, fail-closed BEFORE any db access (typed error even if opts omitted)
  assertSanitize(opts.sanitize)

  if (!archive || !Array.isArray(archive.entries)) {
    throw new ContentMigrateError('archive.entries must be an array')
  }
  if (archive.entries.length > MAX_ENTRIES) {
    throw new ContentMigrateError(`archive exceeds ${MAX_ENTRIES} entries`)
  }

  const { prepared, skipped: invalidSkipped, conflicts } = prepareEntries(archive, opts.sanitize)

  if (opts.mode === 'replace') {
    assertTransactional(db)
    // replace wipes first → fail-closed on ANY invalid entry; never wipe-then-silently-drop-rows (data-loss floor).
    if (conflicts.length > 0) {
      throw new ContentMigrateError(`replace aborted: ${conflicts.length} invalid entries`)
    }
    let imported = 0
    await db.transaction(async (tx) => {
      await tx.delete(contentEntries)
      const result = await writePrepared(tx, prepared, 'replace')
      imported = result.imported
    })
    return { imported, skipped: invalidSkipped, conflicts }
  }

  const result = await writePrepared(db, prepared, 'merge')
  return {
    imported: result.imported,
    skipped: invalidSkipped + result.skipped,
    conflicts,
  }
}
