import { sql, eq } from 'drizzle-orm'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import type { Querier } from '@platform-modules/db'
import { createPgliteClient } from '../../db/src/postgres/pglite.js'
import { contentSchema, contentRevisions, type ContentSchema } from './schema.js'
import { ContentAuthzError, type Actor } from './authz.js'
import { type ContentInput } from './model.js'
import { put as putRaw, publish, getById, remove, ContentConflictError, ContentNotFoundError } from './store.js'
import { setupTaxonomyDb } from './taxonomy.test-helpers.js'
import { createTerm } from './taxonomy.js'
import { startPg } from './pg-harness.js'
import {
  contentRevisionsMigrationSql,
  getRevision,
  listRevisions,
  restoreRevision,
  snapshotRevision,
} from './revisions.js'

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

function put(d: Querier<ContentSchema>, raw: ContentInput, actor: Actor) {
  return putRaw(d, raw, actor, sanitize)
}

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 author: Actor = { id: 'author-1' }
const editor: Actor = { id: 'editor-1', canEditAny: true, canPublish: true, canManageTaxonomy: true }
const admin: Actor = { id: 'admin', canEditAny: true, canPublish: true }
const stranger: Actor = { id: 'stranger' }

async function db(): Promise<Querier<ContentSchema>> {
  const handle = createPgliteClient({ schema: contentSchema })
  await setupTaxonomyDb(handle)
  return handle as unknown as Querier<ContentSchema>
}

async function expectAuthzDenied(promise: Promise<unknown>, action: 'read' | 'update') {
  try {
    await promise
    throw new Error('expected throw')
  } catch (e) {
    expect(e).toBeInstanceOf(ContentAuthzError)
    expect((e as ContentAuthzError).action).toBe(action)
  }
}

describe('contentRevisionsMigrationSql (spec §8 additive DDL)', () => {
  it('emits idempotent CREATE TABLE + FK cascade + keyset index', () => {
    const ddl = contentRevisionsMigrationSql()
    expect(ddl).toContain('CREATE TABLE IF NOT EXISTS content_revisions')
    expect(ddl).toContain('REFERENCES content_entries(id) ON DELETE CASCADE')
    expect(ddl).toContain('term_ids   jsonb NOT NULL DEFAULT')
    expect(ddl).toContain('CREATE INDEX IF NOT EXISTS content_revisions_entry_seq_idx')
  })
})

describe('snapshotRevision (spec §4.1)', () => {
  let d: Querier<ContentSchema>
  beforeEach(async () => {
    d = await db()
  })

  it('captures the exact content tuple from the live entry and sets editor to actor.id', async () => {
    const cat = await createTerm(d, { taxonomy: 'category', slug: 'news', name: 'News' }, editor)
    const tag = await createTerm(d, { taxonomy: 'tag', slug: 'a', name: 'A' }, editor)
    const entry = await put(
      d,
      {
        slug: 'rev-1',
        type: 'post',
        title: 'Original title',
        body: 'original body',
        termIds: [cat.id, tag.id],
      },
      author,
    )
    const rev = await snapshotRevision(d, entry.id, editor)
    expect(rev.entryId).toBe(entry.id)
    expect(rev.title).toBe('Original title')
    expect(rev.body).toBe('original body')
    expect(rev.slug).toBe('rev-1')
    expect(rev.type).toBe('post')
    expect(rev.termIds.sort()).toEqual([cat.id, tag.id].sort())
    expect(rev.editor).toBe('editor-1')
    expect(rev.seq).toBeGreaterThan(0)
    expect(rev.createdAt).toBeInstanceOf(Date)
  })

  it('appends — a second snapshot has a higher seq', async () => {
    const entry = await put(d, { slug: 'rev-2', type: 'post', title: 'Two', body: 'b' }, author)
    const first = await snapshotRevision(d, entry.id, author)
    const second = await snapshotRevision(d, entry.id, author)
    expect(second.seq).toBeGreaterThan(first.seq)
  })

  it('denies a non-author without canEditAny (IDOR floor)', async () => {
    const entry = await put(d, { slug: 'rev-3', type: 'post', title: 'Three', body: 'b' }, author)
    await expectAuthzDenied(snapshotRevision(d, entry.id, stranger), 'update')
  })

  it('throws ContentNotFoundError when the entry is absent', async () => {
    await expect(snapshotRevision(d, '00000000-0000-4000-8000-000000000001', author)).rejects.toBeInstanceOf(
      ContentNotFoundError,
    )
  })
})

describe('listRevisions (spec §4.2)', () => {
  let d: Querier<ContentSchema>
  beforeEach(async () => {
    d = await db()
  })

  it('returns newest-first, pages with before cursor, and null nextCursor at end', async () => {
    const entry = await put(d, { slug: 'list-1', type: 'post', title: 'List', body: 'b' }, author)
    const revs: number[] = []
    for (let i = 0; i < 3; i++) {
      const rev = await snapshotRevision(d, entry.id, author)
      revs.push(rev.seq)
    }
    const page1 = await listRevisions(d, entry.id, author, { limit: 2 })
    expect(page1.revisions).toHaveLength(2)
    expect(page1.revisions[0]!.seq).toBe(revs[2])
    expect(page1.revisions[1]!.seq).toBe(revs[1])
    expect(page1.nextCursor).toBe(revs[1])

    const page2 = await listRevisions(d, entry.id, author, { before: page1.nextCursor!, limit: 2 })
    expect(page2.revisions).toHaveLength(1)
    expect(page2.revisions[0]!.seq).toBe(revs[0])
    expect(page2.nextCursor).toBeNull()
  })

  it('returns an empty list for an entry with no revisions', async () => {
    const entry = await put(d, { slug: 'list-empty', type: 'post', title: 'Empty', body: 'b' }, author)
    const page = await listRevisions(d, entry.id, author)
    expect(page.revisions).toEqual([])
    expect(page.nextCursor).toBeNull()
  })

  it('denies a foreign reader without canEditAny (canModify floor)', async () => {
    const entry = await put(d, { slug: 'list-authz', type: 'post', title: 'Authz', body: 'b' }, author)
    await snapshotRevision(d, entry.id, author)
    await expectAuthzDenied(listRevisions(d, entry.id, stranger), 'read')
  })
})

describe('getRevision (spec §4.3)', () => {
  let d: Querier<ContentSchema>
  beforeEach(async () => {
    d = await db()
  })

  it('returns the revision by id', async () => {
    const entry = await put(d, { slug: 'get-1', type: 'post', title: 'Get', body: 'body' }, author)
    const snap = await snapshotRevision(d, entry.id, author)
    const got = await getRevision(d, snap.id, author)
    expect(got).toEqual(snap)
  })

  it('authorizes against the parent entry author (IDOR)', async () => {
    const entry = await put(d, { slug: 'get-idor', type: 'post', title: 'IDOR', body: 'b' }, author)
    const snap = await snapshotRevision(d, entry.id, author)
    await expectAuthzDenied(getRevision(d, snap.id, stranger), 'read')
  })

  it('throws ContentNotFoundError when the revision is absent', async () => {
    await expect(getRevision(d, '00000000-0000-4000-8000-000000000099', author)).rejects.toBeInstanceOf(
      ContentNotFoundError,
    )
  })
})

describe('restoreRevision (spec §4.4, §5)', () => {
  let d: Querier<ContentSchema>
  beforeEach(async () => {
    d = await db()
  })

  it('restores the content tuple while leaving status, visibility, publishedAt, and author unchanged', async () => {
    const t1 = await createTerm(d, { taxonomy: 'category', slug: 'c1', name: 'C1' }, editor)
    const t2 = await createTerm(d, { taxonomy: 'tag', slug: 't1', name: 'T1' }, editor)
    const t3 = await createTerm(d, { taxonomy: 'category', slug: 'c2', name: 'C2' }, editor)
    const t4 = await createTerm(d, { taxonomy: 'tag', slug: 't2', name: 'T2' }, editor)
    const entry = await put(
      d,
      {
        slug: 'restore-live',
        type: 'post',
        title: 'V1 title',
        body: 'v1 body',
        visibility: 'private',
        termIds: [t1.id, t2.id],
      },
      author,
    )
    await publish(d, entry.id, editor)
    const published = await getById(d, entry.id, editor)
    const publishedAt = published!.publishedAt

    const rev = await snapshotRevision(d, entry.id, author)
    await put(
      d,
      {
        id: entry.id,
        slug: 'restore-live',
        type: 'post',
        title: 'V2 title',
        body: 'v2 body',
        termIds: [t3.id, t4.id],
      },
      author,
    )

    const restored = await restoreRevision(d, rev.id, author, sanitize)
    expect(restored.title).toBe('V1 title')
    expect(restored.body).toBe('v1 body')
    expect(restored.slug).toBe('restore-live')
    expect(restored.type).toBe('post')
    expect(restored.terms.map((t) => t.id).sort()).toEqual([t1.id, t2.id].sort())
    expect(restored.status).toBe('published')
    expect(restored.visibility).toBe('private')
    expect(restored.publishedAt?.getTime()).toBe(publishedAt?.getTime())
    expect(restored.author).toBe('author-1')
  })

  it('snapshots current state first so restore is undoable', async () => {
    const entry = await put(d, { slug: 'undo', type: 'post', title: 'V1', body: 'b1' }, author)
    const rev = await snapshotRevision(d, entry.id, author)
    await put(d, { id: entry.id, slug: 'undo', type: 'post', title: 'V2', body: 'b2' }, author)
    const beforeCount = (await listRevisions(d, entry.id, author)).revisions.length
    await restoreRevision(d, rev.id, author, sanitize)
    const after = await listRevisions(d, entry.id, author)
    expect(after.revisions.length).toBe(beforeCount + 1)
    expect(after.revisions[0]!.title).toBe('V2')
  })

  it('surfaces ContentConflictError when the revision slug is taken by another entry', async () => {
    const a = await put(d, { slug: 'slug-a', type: 'post', title: 'A', body: 'a' }, author)
    const rev = await snapshotRevision(d, a.id, author)
    await put(d, { id: a.id, slug: 'slug-moved', type: 'post', title: 'B', body: 'b' }, author)
    await put(d, { slug: 'slug-a', type: 'post', title: 'Other', body: 'other' }, editor)
    await expect(restoreRevision(d, rev.id, author, sanitize)).rejects.toBeInstanceOf(ContentConflictError)
  })

  it('allows a canPublish:false editor with canModify to restore (no canPublish gate)', async () => {
    const editorActor: Actor = { id: 'editor', canEditAny: true, canPublish: false }
    const entry = await put(d, { slug: 'no-publish', type: 'post', title: 'V1', body: 'b' }, author)
    const rev = await snapshotRevision(d, entry.id, author)
    await put(d, { id: entry.id, slug: 'no-publish', type: 'post', title: 'V2', body: 'b2' }, author)
    const restored = await restoreRevision(d, rev.id, editorActor, sanitize)
    expect(restored.title).toBe('V1')
  })
})

describe('FK cascade on remove (real PG, spec §2 privacy floor)', () => {
  let pg: Querier<ContentSchema>
  let stop: () => Promise<void>

  beforeAll(async () => {
    const harness = await startPg()
    pg = harness.db
    stop = harness.stop
    await setupTaxonomyDb(pg)
  }, 45_000)

  afterAll(async () => {
    await stop?.()
  }, 15_000)

  it('purges all revisions when the parent entry is permanently removed', async () => {
    const entry = await put(pg, { slug: 'fk-purge', type: 'post', title: 'FK', body: 'b' }, author)
    await snapshotRevision(pg, entry.id, author)
    await remove(pg, entry.id, admin)
    const rows = await pg.select().from(contentRevisions).where(eq(contentRevisions.entryId, entry.id))
    expect(rows).toHaveLength(0)
  }, 45_000)
})
