import { PGlite } from '@electric-sql/pglite'
import { drizzle } from 'drizzle-orm/pglite'
import { sql } from 'drizzle-orm'
import { beforeEach, describe, expect, it } from 'vitest'
import type { Querier } from '@platform-modules/db'
import {
  contentEntries,
  contentSchema,
  getBySlug,
  list,
  publish,
  put as putRaw,
  remove,
  schedule,
  setVisibility,
  unpublish,
  ContentAuthzError,
  ContentValidationError,
  type Actor,
  type ContentInput,
  type ContentSchema,
  type StoreOpts,
} from '@platform-modules/content'
import { toFeed, type FeedMeta } from '@platform-modules/content/feed'
import { contentRevisionsMigrationSql } from '@platform-modules/content/revisions'
import { contentTaxonomyMigrationSql } from '@platform-modules/content/taxonomy'

type ContentDb = Querier<ContentSchema>

// Deterministic fake sanitizer (engine is host-owned; security-primitives spec §3).
function sanitize(raw: string): string {
  return raw
    .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
}

// Inject the host sanitizer so existing call sites read against the OLD arity unchanged.
function put(db: ContentDb, raw: ContentInput, actor: Actor, opts?: StoreOpts) {
  return putRaw(db, raw, actor, sanitize, opts)
}

const CREATE_DDL = `
  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,
    category jsonb NOT NULL DEFAULT '[]'::jsonb,
    tag jsonb NOT NULL DEFAULT '[]'::jsonb,
    created_at timestamptz(3) NOT NULL DEFAULT NOW(),
    updated_at timestamptz(3) NOT NULL DEFAULT NOW()
  );
  CREATE UNIQUE INDEX content_entries_type_slug_uq ON content_entries (type, slug);
`

/**
 * Gate-3: a REAL consumer reconstructs content_entries from the schema it owns and drives the
 * full write contract through the PUBLIC barrel only — proving exports/types/runtime integration,
 * the returned identities the P4 cache gate will consume, object-level authz, and clean removal.
 */
async function createContentDb(): Promise<{ db: ContentDb; client: PGlite }> {
  const client = new PGlite()
  const db = drizzle(client, { schema: contentSchema }) as unknown as ContentDb
  await client.exec(CREATE_DDL)
  // U2 revisions + U4 hierarchical taxonomy: store JOINs content_entry_terms→content_terms for
  // entry.terms; the taxonomy migration ALTERs content_revisions, so revisions must run first.
  await client.exec(contentRevisionsMigrationSql())
  await client.exec(contentTaxonomyMigrationSql())
  return { db, client }
}

const editor: Actor = { id: 'editor', canEditAny: true, canPublish: true }
const author: Actor = { id: 'author-1' }
const member: Actor = { id: 'member-1', canViewMembers: true }
const plain: Actor = { id: 'plain-1' }

const feedMeta: FeedMeta = {
  title: 'Site',
  siteUrl: 'https://x.test/',
  feedUrl: 'https://x.test/feed.xml',
}

async function seedPublished(
  db: ContentDb,
  slug: string,
  over: { visibility?: 'public' | 'private' | 'members'; status?: 'draft' | 'published'; author?: string } = {},
) {
  const who: Actor = over.author ? { id: over.author } : author
  const e = await put(db, { slug, type: 'post', title: slug, body: 'b', visibility: over.visibility }, who)
  if (over.status === 'draft') return e
  await publish(db, e.id, editor)
  return e
}

describe('content consumer fixture (Gate 3 — full write contract through the public barrel)', () => {
  let db: ContentDb
  let client: PGlite
  beforeEach(async () => {
    ;({ db, client } = await createContentDb())
  })

  it('drives put -> schedule -> publish -> unpublish -> remove, returning identities', async () => {
    const draft = await put(db, { slug: 'launch', type: 'post', title: 'Launch', body: '<p>hi</p>' }, editor)
    expect(draft.status).toBe('draft')

    const scheduled = await schedule(db, draft.id, new Date(Date.parse('2099-01-01T00:00:00.000Z')), editor)
    expect(scheduled).toEqual({ id: draft.id, slug: 'launch', type: 'post' })

    const published = await publish(db, draft.id, editor)
    expect(published).toEqual({ id: draft.id, slug: 'launch', type: 'post' })
    expect((await list(db, { status: 'published' }, editor)).map((e) => e.id)).toEqual([draft.id])

    const unpublished = await unpublish(db, draft.id, editor)
    expect(unpublished.id).toBe(draft.id)

    const removed = await remove(db, draft.id, editor)
    expect(removed).toEqual({ id: draft.id, slug: 'launch', type: 'post' })
    expect(await getBySlug(db, 'post', 'launch', editor)).toBeNull()
  })

  it('rejects a non-author non-privileged actor on publish AND remove (IDOR floor)', async () => {
    const owned = await put(db, { slug: 'mine', type: 'post', title: 'Mine', body: '' }, { id: 'author-9' })
    const stranger: Actor = { id: 'stranger' }
    await expect(publish(db, owned.id, stranger)).rejects.toBeInstanceOf(ContentAuthzError)
    await expect(remove(db, owned.id, stranger)).rejects.toBeInstanceOf(ContentAuthzError)
  })

  it('content_entries drops clean (forward migration is removable)', async () => {
    // CASCADE: content_entry_terms (U4) carries an FK to content_entries — removing the forward
    // migration drops its dependents too.
    await db.execute(sql`DROP TABLE content_entries CASCADE`)
    await expect(db.select().from(contentEntries).limit(1)).rejects.toThrow(/content_entries|exist/i)
  })
})

describe('content visibility Gate-3 harness (spec §8)', () => {
  let db: ContentDb
  beforeEach(async () => {
    ;({ db } = await createContentDb())
  })

  it('1 anonymous list => only published+public (not drafts/private/members)', async () => {
    await put(db, { slug: 'draft', type: 'post', title: 'draft', body: '' }, author)
    await seedPublished(db, 'pub', { visibility: 'public' })
    await seedPublished(db, 'priv', { visibility: 'private' })
    await seedPublished(db, 'mem', { visibility: 'members' })
    expect((await list(db)).map((e) => e.slug)).toEqual(['pub'])
  })

  it('2 author list(viewer=self) => sees own draft + own private', async () => {
    const draft = await put(db, { slug: 'my-draft', type: 'post', title: 'd', body: '' }, author)
    await seedPublished(db, 'my-private', { visibility: 'private' })
    const rows = await list(db, {}, author)
    expect(rows.map((e) => e.slug).sort()).toEqual(['my-draft', 'my-private'].sort())
    expect(rows.find((e) => e.id === draft.id)?.status).toBe('draft')
  })

  it('3 canEditAny viewer => sees everything', async () => {
    await put(db, { slug: 'd1', type: 'post', title: 'd', body: '' }, author)
    await seedPublished(db, 'p1', { visibility: 'private' })
    await seedPublished(db, 'm1', { visibility: 'members' })
    expect((await list(db, { limit: 100 }, editor)).map((e) => e.slug).sort()).toEqual(['d1', 'm1', 'p1'].sort())
  })

  it('4 canViewMembers viewer => published members + public, NOT private, NOT others drafts', async () => {
    await put(db, { slug: 'other-draft', type: 'post', title: 'd', body: '' }, { id: 'other' })
    await seedPublished(db, 'pub', { visibility: 'public' })
    await seedPublished(db, 'priv', { visibility: 'private' })
    await seedPublished(db, 'mem', { visibility: 'members' })
    expect((await list(db, { limit: 100 }, member)).map((e) => e.slug).sort()).toEqual(['mem', 'pub'].sort())
  })

  it('5 plain viewer (no caps) => public+published + own only', async () => {
    await put(db, { slug: 'mine-draft', type: 'post', title: 'd', body: '' }, plain)
    await seedPublished(db, 'pub', { visibility: 'public' })
    await seedPublished(db, 'priv', { visibility: 'private' })
    await seedPublished(db, 'mem', { visibility: 'members' })
    expect((await list(db, { limit: 100 }, plain)).map((e) => e.slug).sort()).toEqual(['mine-draft', 'pub'].sort())
  })

  it('6 getBySlug private as anonymous => null (no oracle)', async () => {
    await seedPublished(db, 'secret', { visibility: 'private' })
    expect(await getBySlug(db, 'post', 'secret')).toBeNull()
  })

  it('7 setVisibility author->members; anonymous getBySlug => null; member viewer => entry', async () => {
    const e = await put(db, { slug: 'flip', type: 'post', title: 'f', body: '' }, author)
    await publish(db, e.id, editor)
    await setVisibility(db, e.id, 'members', author)
    expect(await getBySlug(db, 'post', 'flip')).toBeNull()
    expect((await getBySlug(db, 'post', 'flip', member))?.slug).toBe('flip')
  })

  it('8 setVisibility by non-author non-editor => ContentAuthzError', async () => {
    const e = await put(db, { slug: 'owned', type: 'post', title: 'o', body: '' }, author)
    await expect(setVisibility(db, e.id, 'private', plain)).rejects.toBeInstanceOf(ContentAuthzError)
  })

  it('9 bad literal => ContentValidationError(visibility,...)', async () => {
    await expect(
      put(db, { slug: 'bad', type: 'post', title: 'b', body: '', visibility: 'secret' as never }, author),
    ).rejects.toBeInstanceOf(ContentValidationError)
    const e = await put(db, { slug: 'ok', type: 'post', title: 'o', body: '' }, author)
    await expect(setVisibility(db, e.id, 'nope' as never, author)).rejects.toBeInstanceOf(ContentValidationError)
  })

  it('10 backward-compat: existing row (no explicit visibility) reads as public', async () => {
    await db.execute(sql`
      INSERT INTO content_entries (slug, type, title, body, status, author)
      VALUES ('legacy', 'post', 'Legacy', '', 'published', 'legacy-author')
    `)
    expect((await getBySlug(db, 'post', 'legacy'))?.visibility).toBe('public')
  })

  it('11 feed floor: anonymous list + toFeed contains ONLY the public entry', async () => {
    await seedPublished(db, 'feed-public', { visibility: 'public' })
    await seedPublished(db, 'feed-private', { visibility: 'private' })
    await seedPublished(db, 'feed-members', { visibility: 'members' })
    const rss = toFeed(await list(db, { limit: 100 }, null), 'rss', feedMeta)
    expect(rss).toContain('feed-public')
    expect(rss).not.toContain('feed-private')
    expect(rss).not.toContain('feed-members')
  })
})
