import { sql } from 'drizzle-orm'
import { beforeEach, describe, expect, it } from 'vitest'
import type { Querier, TransactionalDatabase } from '@platform-modules/db'
import { createPgliteClient } from '../../db/src/postgres/pglite.js'
import { ContentValidationError } from './model.js'
import { contentEntries, contentSchema, type ContentSchema } from './schema.js'
import { put as putRaw } from './store.js'
import { ContentAuthzError, type Actor } from './authz.js'
import { exportContent, importContent, ContentMigrateError } from './migrate.js'
import { setupTaxonomyDb } from './taxonomy.test-helpers.js'

const CREATE_TABLE = sql`
  CREATE TABLE content_entries (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    slug text NOT NULL,
    type text NOT NULL,
    title text NOT NULL,
    body text NOT NULL DEFAULT '',
    status text NOT NULL DEFAULT 'draft',
    visibility text NOT NULL DEFAULT 'public',
    published_at timestamptz(3),
    author text NOT NULL,
    created_at timestamptz(3) NOT NULL DEFAULT NOW(),
    updated_at timestamptz(3) NOT NULL DEFAULT NOW()
  )
`

const CREATE_INDEX = sql`
  CREATE UNIQUE INDEX content_entries_type_slug_uq ON content_entries (type, slug)
`

const editor: Actor = { id: 'editor-1', canEditAny: true, canPublish: true }

function sanitize(raw: string): string {
  return raw
    .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
}

async function db(): Promise<TransactionalDatabase<ContentSchema>> {
  return setupTaxonomyDb(createPgliteClient({ schema: contentSchema })) as Promise<TransactionalDatabase<ContentSchema>>
}

async function put(d: Querier<ContentSchema>, slug: string, over: { status?: string; type?: string; body?: string; updatedAt?: Date } = {}) {
  const e = await putRaw(
    d,
    { slug, type: over.type ?? 'post', title: slug, body: over.body ?? 'body' },
    editor,
    sanitize,
  )
  if (over.status === 'published') {
    await d.execute(sql`UPDATE content_entries SET status = 'published', published_at = NOW() WHERE id = ${e.id}`)
  }
  if (over.updatedAt) {
    await d.execute(sql`UPDATE content_entries SET updated_at = ${over.updatedAt} WHERE id = ${e.id}`)
  }
  return e
}

describe('exportContent', () => {
  let d: Querier<ContentSchema>

  beforeEach(async () => {
    d = await db()
  })

  it('exports every status without a visibility filter', async () => {
    await put(d, 'draft-one', { status: 'draft' })
    await put(d, 'pub-one', { status: 'published' })
    await put(d, 'private-one', { status: 'published' })
    await d.execute(sql`UPDATE content_entries SET visibility = 'private' WHERE slug = 'private-one'`)

    const archive = await exportContent(d, { actor: editor })
    expect(archive.version).toBe(1)
    expect(archive.exportedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
    expect(archive.entries.map((e) => e.slug).sort()).toEqual(['draft-one', 'private-one', 'pub-one'].sort())
    expect(archive.settings).toBeUndefined()
  })

  it('filters by types when opts.types is set', async () => {
    await put(d, 'page-a', { type: 'page' })
    await put(d, 'post-a', { type: 'post' })
    const archive = await exportContent(d, { actor: editor, types: ['page'] })
    expect(archive.entries.map((e) => e.slug)).toEqual(['page-a'])
  })

  // --- security-guard finding (2026-06-19): export is admin-only at the seam ---

  it('rejects a non-admin actor and returns ZERO rows (info-disclosure floor)', async () => {
    await put(d, 'secret', { status: 'draft' })
    await d.execute(sql`UPDATE content_entries SET visibility = 'private' WHERE slug = 'secret'`)
    const nonAdmin: Actor = { id: 'u', canEditAny: false }
    await expect(exportContent(d, { actor: nonAdmin })).rejects.toBeInstanceOf(ContentAuthzError)
  })

  it('rejects export when actor is missing entirely (fail-closed)', async () => {
    await expect(exportContent(d, { actor: undefined as never })).rejects.toBeInstanceOf(ContentAuthzError)
  })

  it('throws a TYPED ContentAuthzError (not TypeError) when opts is omitted by a raw-JS caller', async () => {
    await expect(exportContent(d, undefined as never)).rejects.toBeInstanceOf(ContentAuthzError)
  })
})

describe('importContent', () => {
  let d: TransactionalDatabase<ContentSchema>

  beforeEach(async () => {
    d = await db()
  })

  it('round-trips export then merge import', async () => {
    await put(d, 'rt-1')
    await put(d, 'rt-2', { type: 'page' })
    const archive = await exportContent(d, { actor: editor })
    await d.delete(contentEntries)
    const result = await importContent(d, archive, { actor: editor, mode: 'merge', sanitize })
    expect(result.imported).toBe(2)
    expect(result.skipped).toBe(0)
    expect(result.conflicts).toEqual([])
    const rows = await d.select().from(contentEntries)
    expect(rows).toHaveLength(2)
  })

  it('merge keeps the newer row and skips an older archive entry', async () => {
    const newer = new Date('2099-01-01T00:00:00.000Z')
    const older = new Date('2000-01-01T00:00:00.000Z')
    await put(d, 'merge-slug', { updatedAt: newer })
    const archive = await exportContent(d, { actor: editor })
    archive.entries[0]!.updatedAt = older
    archive.entries[0]!.title = 'stale title'
    const result = await importContent(d, archive, { actor: editor, mode: 'merge', sanitize })
    expect(result.skipped).toBeGreaterThanOrEqual(1)
    const [row] = await d.select().from(contentEntries).where(sql`slug = 'merge-slug'`)
    expect(row).toBeDefined()
    expect(row!.title).not.toBe('stale title')
  })

  it('merge skips invalid entries and reports conflicts', async () => {
    const archive = await exportContent(d, { actor: editor })
    archive.entries.push({
      id: '00000000-0000-4000-8000-000000000099',
      slug: 'BAD SLUG',
      type: 'post',
      title: 'x',
      body: '',
      status: 'draft',
      visibility: 'public',
      publishedAt: null,
      author: 'a',
      terms: [],
      createdAt: new Date(),
      updatedAt: new Date(),
    })
    const result = await importContent(d, archive, { actor: editor, mode: 'merge', sanitize })
    expect(result.conflicts.some((c) => c.includes('BAD SLUG') || c.includes('post'))).toBe(true)
    expect(result.skipped).toBeGreaterThanOrEqual(1)
    const bad = await d.select().from(contentEntries).where(sql`slug = 'BAD SLUG'`)
    expect(bad).toHaveLength(0)
  })

  it('importContent sanitizes body on restore (never stores raw script)', async () => {
    const calls: string[] = []
    const sanitizeSpy = (raw: string) => {
      calls.push(raw)
      return raw.replace(/<script[\s\S]*?<\/script>/gi, '')
    }
    const archive = {
      version: 1 as const,
      exportedAt: '2026-06-18T00:00:00Z',
      entries: [
        {
          id: undefined as never,
          slug: 'evil',
          type: 'page',
          title: 'x',
          body: '<script>alert(1)</script>safe',
          status: 'draft' as const,
          visibility: 'public' as const,
          publishedAt: null,
          author: 'a',
          terms: [] as import('./model.js').TermRef[],
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ],
    }
    await importContent(d, archive, { actor: editor, mode: 'merge', sanitize: sanitizeSpy })
    const [row] = await d.select().from(contentEntries)
    expect(row).toBeDefined()
    expect(calls.length).toBe(1)
    expect(row!.body).not.toContain('<script>')
  })

  it('replace mode rejects a non-transactional handle (no partial wipe)', async () => {
    const archive = await exportContent(d, { actor: editor })
    const querierWithoutTransaction = {
      select: d.select.bind(d),
      insert: d.insert.bind(d),
      update: d.update.bind(d),
      delete: d.delete.bind(d),
      execute: d.execute.bind(d),
    } as Querier<ContentSchema>
    await expect(
      importContent(querierWithoutTransaction, archive, { actor: editor, mode: 'replace', sanitize: (s) => s }),
    ).rejects.toThrow(/TransactionalDatabase/)
  })

  it('replace mode atomically replaces all entries', async () => {
    await put(d, 'old-a')
    await put(d, 'old-b')
    const archive = {
      version: 1 as const,
      exportedAt: new Date().toISOString(),
      entries: [
        {
          id: '00000000-0000-4000-8000-000000000001',
          slug: 'new-only',
          type: 'post',
          title: 'New',
          body: 'clean',
          status: 'draft' as const,
          visibility: 'public' as const,
          publishedAt: null,
          author: 'a',
          terms: [] as import('./model.js').TermRef[],
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ],
    }
    const result = await importContent(d, archive, { actor: editor, mode: 'replace', sanitize })
    expect(result.imported).toBe(1)
    const rows = await d.select().from(contentEntries)
    expect(rows).toHaveLength(1)
    expect(rows[0]?.slug).toBe('new-only')
  })

  it('fails closed when sanitize is missing', async () => {
    const archive = await exportContent(d, { actor: editor })
    await expect(
      importContent(d, archive, { actor: editor, mode: 'merge', sanitize: undefined as never }),
    ).rejects.toBeInstanceOf(ContentMigrateError)
  })

  it('maps validation failures to conflicts, not bare throws', async () => {
    const archive = await exportContent(d, { actor: editor })
    archive.entries = [{ ...archive.entries[0]!, slug: '' }]
    const result = await importContent(d, archive, { actor: editor, mode: 'merge', sanitize })
    expect(result.conflicts.length).toBeGreaterThan(0)
    expect(result.skipped).toBe(1)
  })

  // --- security-guard P1 (2026-06-18): import is admin-only at the seam ---

  it('rejects a non-admin actor and writes ZERO rows (authz floor)', async () => {
    await put(d, 'seed-a')
    const archive = await exportContent(d, { actor: editor })
    await d.delete(contentEntries)
    const nonAdmin: Actor = { id: 'u', canEditAny: false }
    await expect(
      importContent(d, archive, { actor: nonAdmin, mode: 'merge', sanitize }),
    ).rejects.toBeInstanceOf(ContentAuthzError)
    const rows = await d.select().from(contentEntries)
    expect(rows).toHaveLength(0)
  })

  it('rejects import when actor is missing entirely (fail-closed)', async () => {
    const archive = await exportContent(d, { actor: editor })
    await expect(
      importContent(d, archive, { actor: undefined as never, mode: 'merge', sanitize }),
    ).rejects.toBeInstanceOf(ContentAuthzError)
  })

  it('throws a TYPED ContentAuthzError (not TypeError) when opts is omitted by a raw-JS caller', async () => {
    const archive = await exportContent(d, { actor: editor })
    await expect(importContent(d, archive, undefined as never)).rejects.toBeInstanceOf(ContentAuthzError)
  })

  it('rejects canEditAny-without-canPublish (no force-publish via restore — escalation floor)', async () => {
    await put(d, 'seed-x')
    const archive = await exportContent(d, { actor: editor })
    await d.delete(contentEntries)
    const editAnyNoPublish: Actor = { id: 'u', canEditAny: true, canPublish: false }
    await expect(
      importContent(d, archive, { actor: editAnyNoPublish, mode: 'merge', sanitize }),
    ).rejects.toBeInstanceOf(ContentAuthzError)
    const rows = await d.select().from(contentEntries)
    expect(rows).toHaveLength(0)
  })

  it('admin restore preserves full-fidelity status + author (gate the caller, not the fields)', async () => {
    const archive = {
      version: 1 as const,
      exportedAt: new Date().toISOString(),
      entries: [
        {
          id: '00000000-0000-4000-8000-0000000000aa',
          slug: 'fidelity',
          type: 'post',
          title: 'Kept',
          body: 'clean',
          status: 'published' as const,
          visibility: 'public' as const,
          publishedAt: new Date('2030-01-01T00:00:00.000Z'),
          author: 'original-author',
          terms: [] as import('./model.js').TermRef[],
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ],
    }
    const result = await importContent(d, archive, { actor: editor, mode: 'merge', sanitize })
    expect(result.imported).toBe(1)
    const [row] = await d.select().from(contentEntries).where(sql`slug = 'fidelity'`)
    expect(row!.status).toBe('published')
    expect(row!.author).toBe('original-author')
  })

  it('accepts status trashed on import (round-trip validation floor)', async () => {
    const archive = {
      version: 1 as const,
      exportedAt: new Date().toISOString(),
      entries: [
        {
          id: '00000000-0000-4000-8000-0000000000dd',
          slug: 'trashed-one',
          type: 'post',
          title: 'Trashed',
          body: 'clean',
          status: 'trashed' as const,
          visibility: 'public' as const,
          publishedAt: null,
          author: 'a',
          terms: [] as import('./model.js').TermRef[],
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ],
    }
    const result = await importContent(d, archive, { actor: editor, mode: 'merge', sanitize })
    expect(result.imported).toBe(1)
    expect(result.conflicts).toEqual([])
    const [row] = await d.select().from(contentEntries).where(sql`slug = 'trashed-one'`)
    expect(row!.status).toBe('trashed')
  })

  it('skips an entry with an unknown status literal (validation floor)', async () => {
    const archive = await exportContent(d, { actor: editor })
    archive.entries.push({
      id: '00000000-0000-4000-8000-0000000000bb',
      slug: 'bad-status',
      type: 'post',
      title: 'x',
      body: '',
      status: 'leaked' as never,
      visibility: 'public',
      publishedAt: null,
      author: 'a',
      terms: [],
      createdAt: new Date(),
      updatedAt: new Date(),
    })
    const result = await importContent(d, archive, { actor: editor, mode: 'merge', sanitize })
    expect(result.conflicts.some((c) => c.includes('status'))).toBe(true)
    const rows = await d.select().from(contentEntries).where(sql`slug = 'bad-status'`)
    expect(rows).toHaveLength(0)
  })

  it('skips an entry with a malformed (non-UUID) id', async () => {
    const archive = await exportContent(d, { actor: editor })
    archive.entries.push({
      id: 'not-a-uuid',
      slug: 'bad-id',
      type: 'post',
      title: 'x',
      body: '',
      status: 'draft',
      visibility: 'public',
      publishedAt: null,
      author: 'a',
      terms: [],
      createdAt: new Date(),
      updatedAt: new Date(),
    })
    const result = await importContent(d, archive, { actor: editor, mode: 'merge', sanitize })
    expect(result.conflicts.some((c) => c.includes('id'))).toBe(true)
    const rows = await d.select().from(contentEntries).where(sql`slug = 'bad-id'`)
    expect(rows).toHaveLength(0)
  })

  it('replace aborts (no wipe) when ANY entry is invalid — data-loss floor', async () => {
    await put(d, 'keep-me')
    const archive = await exportContent(d, { actor: editor })
    archive.entries.push({
      id: '00000000-0000-4000-8000-0000000000cc',
      slug: 'BAD SLUG',
      type: 'post',
      title: 'x',
      body: '',
      status: 'draft',
      visibility: 'public',
      publishedAt: null,
      author: 'a',
      terms: [],
      createdAt: new Date(),
      updatedAt: new Date(),
    })
    await expect(
      importContent(d, archive, { actor: editor, mode: 'replace', sanitize }),
    ).rejects.toBeInstanceOf(ContentMigrateError)
    // the pre-existing row must survive — replace must NOT wipe-then-fail.
    const rows = await d.select().from(contentEntries).where(sql`slug = 'keep-me'`)
    expect(rows).toHaveLength(1)
  })

  it('rejects an archive whose entry count exceeds the cap (DoS floor)', async () => {
    const bloated = {
      version: 1 as const,
      exportedAt: new Date().toISOString(),
      entries: new Array(100_001) as never, // sparse: Array.isArray true, exercises the count cap (not the shape guard)
    }
    await expect(
      importContent(d, bloated, { actor: editor, mode: 'merge', sanitize }),
    ).rejects.toBeInstanceOf(ContentMigrateError)
  })
})

describe('ContentMigrateError', () => {
  it('has a stable name and detail', () => {
    const err = new ContentMigrateError('sanitize required')
    expect(err.name).toBe('ContentMigrateError')
    expect(err.detail).toBe('sanitize required')
    expect(err.message).toContain('sanitize required')
  })
})

describe('ContentValidationError re-export path', () => {
  it('is the error class importContent relies on for invalid slugs', () => {
    expect(() => {
      throw new ContentValidationError('slug', 'bad')
    }).toThrow(ContentValidationError)
  })
})
