import { PGlite } from '@electric-sql/pglite'
import { drizzle } from 'drizzle-orm/pglite'
import { beforeEach, describe, expect, it } from 'vitest'
import type { Querier } from '@platform-modules/db'
import { CommentAuthzError, CommentSanitizationError, CommentValidationError } from './errors.js'
import { post, list, listForModeration, getById, edit, setStatus, remove, count } from './store.js'
import { commentsSchema, commentsTableSql, type CommentsSchema } from './schema.js'
import type { Actor, Comment, CommentTarget, Page } from './types.js'

type Db = Querier<CommentsSchema>

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

const target: CommentTarget = { type: 'post', id: 'p1' }
const author: Actor = { id: 'u1' }
const moderator: Actor = { id: 'mod', canModerate: true }
const stranger: Actor = { id: 'u2' }

async function createDb(): Promise<{ db: Db; client: PGlite }> {
  const client = new PGlite()
  const db = drizzle(client, { schema: commentsSchema }) as unknown as Db
  await client.exec(commentsTableSql())
  return { db, client }
}

let db: Db
let client: PGlite

beforeEach(async () => {
  ;({ db, client } = await createDb())
})

describe('store — full Gate-3 behavioral contract', () => {
  // ── post → list round-trip ──────────────────────────────────────────────
  it('post→list round-trip with stored depth (top-level depth=0)', async () => {
    const ref = await post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'hello' }, author, sanitize)
    expect(ref.id).toBeTruthy()
    expect(ref.target).toEqual(target)

    // default list: published only — comment is pending; list as moderator to see it
    const page = await list(db, target, { status: 'pending' }, moderator)
    expect(page.items).toHaveLength(1)
    expect(page.items[0]!.depth).toBe(0)
    expect(page.items[0]!.body).toBe('hello')
    expect(page.items[0]!.status).toBe('pending')
  })

  // ── thread nesting + depth-cap ─────────────────────────────────────────
  it('thread nesting stores depth at insert; depth capped at 5', async () => {
    let parentId: string | null = null
    const ids: string[] = []
    // Create 7 levels deep; depth should cap at 5
    for (let i = 0; i < 7; i++) {
      const ref = await post(
        db,
        { target, author: { kind: 'user', userId: 'u1' }, body: `level ${i}`, parentId: parentId ?? undefined },
        author,
        sanitize,
      )
      ids.push(ref.id)
      parentId = ref.id
    }

    const page = await list(db, target, { status: 'pending' }, moderator)
    const depths = page.items.map((c) => c.depth)
    expect(depths[0]).toBe(0)
    expect(depths[1]).toBe(1)
    expect(depths[2]).toBe(2)
    expect(depths[3]).toBe(3)
    expect(depths[4]).toBe(4)
    expect(depths[5]).toBe(5) // capped
    expect(depths[6]).toBe(5) // capped
  })

  // ── moderation default = pending ───────────────────────────────────────
  it('new comment defaults to pending even without initialStatus', async () => {
    const ref = await post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'hi' }, author, sanitize)
    const c = await getById(db, ref.id)
    expect(c!.status).toBe('pending')
  })

  it('non-moderator passing initialStatus=published → CommentAuthzError', async () => {
    await expect(
      post(
        db,
        { target, author: { kind: 'user', userId: 'u1' }, body: 'hi', initialStatus: 'published' },
        author,
        sanitize,
      ),
    ).rejects.toThrow(CommentAuthzError)
  })

  it('moderator can set initialStatus=published', async () => {
    const ref = await post(
      db,
      { target, author: { kind: 'user', userId: 'mod' }, body: 'hi', initialStatus: 'published' },
      moderator,
      sanitize,
    )
    const c = await getById(db, ref.id)
    expect(c!.status).toBe('published')
  })

  // ── setStatus gated on canModerate ────────────────────────────────────
  it('setStatus approve/spam/trash gated on canModerate', async () => {
    const ref = await post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'hi' }, author, sanitize)
    await expect(setStatus(db, ref.id, 'published', author)).rejects.toThrow(CommentAuthzError)
    await setStatus(db, ref.id, 'published', moderator)
    const c = await getById(db, ref.id)
    expect(c!.status).toBe('published')
  })

  // ── IDOR: non-author non-moderator cannot edit/remove ─────────────────
  it('non-author non-moderator cannot edit', async () => {
    const ref = await post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'mine' }, author, sanitize)
    await expect(edit(db, ref.id, 'hacked', stranger, sanitize)).rejects.toThrow(CommentAuthzError)
  })

  it('non-author non-moderator cannot remove', async () => {
    const ref = await post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'mine' }, author, sanitize)
    await expect(remove(db, ref.id, stranger)).rejects.toThrow(CommentAuthzError)
  })

  it('guest-authored comment: only moderator can edit/remove', async () => {
    const ref = await post(
      db,
      { target, author: { kind: 'guest', name: 'Alice', email: 'a@ex.com' }, body: 'guest post' },
      null,
      sanitize,
    )
    // Even the same-id actor cannot modify a guest comment (no identity match)
    await expect(edit(db, ref.id, 'hack', author, sanitize)).rejects.toThrow(CommentAuthzError)
    await expect(remove(db, ref.id, author)).rejects.toThrow(CommentAuthzError)
    // Moderator can
    await expect(edit(db, ref.id, 'mod edit', moderator, sanitize)).resolves.not.toThrow()
  })

  // ── stored-XSS: sanitize is mandatory and effective ───────────────────
  it('XSS: <script> body returns sanitized bodyHtml', async () => {
    const ref = await post(
      db,
      { target, author: { kind: 'user', userId: 'u1' }, body: 'hi<script>alert(1)</script>' },
      author,
      sanitize,
    )
    const c = await getById(db, ref.id)
    expect(c!.bodyHtml).not.toContain('<script>')
    expect(c!.body).toContain('<script>') // raw body preserved for re-edit
  })

  it('missing sanitize → CommentSanitizationError', async () => {
    await expect(
      post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'hi' }, author, undefined),
    ).rejects.toThrow(CommentSanitizationError)
  })

  it('non-function sanitize → CommentSanitizationError', async () => {
    await expect(
      post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'hi' }, author, 'not-a-fn' as never),
    ).rejects.toThrow(CommentSanitizationError)
  })

  // ── edit re-sanitizes + bumps editedAt ────────────────────────────────
  it('edit re-sanitizes bodyHtml and bumps editedAt', async () => {
    const ref = await post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'original' }, author, sanitize)
    const before = await getById(db, ref.id)
    expect(before!.editedAt).toBeNull()

    await edit(db, ref.id, 'updated<script>evil</script>', author, sanitize)
    const after = await getById(db, ref.id)
    expect(after!.body).toBe('updated<script>evil</script>')
    expect(after!.bodyHtml).not.toContain('<script>')
    expect(after!.editedAt).not.toBeNull()
  })

  // ── guest-email redaction ─────────────────────────────────────────────
  it('guest email redacted from public read; url retained', async () => {
    const ref = await post(
      db,
      {
        target,
        author: { kind: 'guest', name: 'Bob', email: 'bob@secret.com', url: 'https://bob.example' },
        body: 'hey',
      },
      null,
      sanitize,
    )
    // Public read (no actor)
    const pub = await getById(db, ref.id)
    expect(pub!.author).toEqual({ kind: 'guest', name: 'Bob', url: 'https://bob.example' })
    expect(JSON.stringify(pub!.author)).not.toContain('bob@secret.com')

    // Moderator read has email
    const mod = await getById(db, ref.id, moderator)
    expect((mod!.author as { email?: string }).email).toBe('bob@secret.com')
  })

  // ── delete-parent-with-children TOMBSTONES ─────────────────────────────
  it('delete parent with children tombstones; child still resolves parentId', async () => {
    const parent = await post(
      db,
      { target, author: { kind: 'user', userId: 'u1' }, body: 'parent' },
      author,
      sanitize,
    )
    await post(
      db,
      { target, author: { kind: 'user', userId: 'u2' }, body: 'child', parentId: parent.id },
      { id: 'u2' },
      sanitize,
    )

    // Publish parent so author can remove (author does the remove — still authz'd)
    await setStatus(db, parent.id, 'published', moderator)
    await remove(db, parent.id, author)

    // Parent tombstoned
    const tombstone = await getById(db, parent.id)
    expect(tombstone).not.toBeNull()
    expect(tombstone!.body).toBe('')
    expect(tombstone!.bodyHtml).toBe('')
    expect(tombstone!.status).toBe('trashed')

    // Child still resolves parentId
    const page = await list(db, target, { status: 'pending' }, moderator)
    const child = page.items.find((c) => c.parentId === parent.id)
    expect(child).toBeDefined()
    expect(child!.body).toBe('child')
  })

  it('delete leaf hard-deletes (no tombstone)', async () => {
    const ref = await post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'leaf' }, author, sanitize)
    await setStatus(db, ref.id, 'published', moderator)
    await remove(db, ref.id, author)
    const c = await getById(db, ref.id)
    expect(c).toBeNull()
  })

  // ── body-length validation ─────────────────────────────────────────────
  it('body > MAX_BODY_LEN → CommentValidationError', async () => {
    const longBody = 'x'.repeat(10_001)
    await expect(
      post(db, { target, author: { kind: 'user', userId: 'u1' }, body: longBody }, author, sanitize),
    ).rejects.toThrow(CommentValidationError)
  })

  // ── keyset pagination item parity ─────────────────────────────────────
  it('cursor-walked multi-page list deep-equals single limit=all list', async () => {
    // Insert 15 published comments (moderator initialStatus)
    for (let i = 0; i < 15; i++) {
      await post(
        db,
        { target, author: { kind: 'user', userId: 'u1' }, body: `comment ${i}`, initialStatus: 'published' },
        moderator,
        sanitize,
        {},
      )
    }

    // Get all published in one shot (limit=200)
    const allPage = await list(db, target, { limit: 200, status: 'published' }, moderator)
    const allItems = allPage.items
    expect(allItems.length).toBe(15)

    // Walk pages with limit=5
    const walked: typeof allItems = []
    let cursor: string | null = null
    do {
      const page = await list(
        db,
        target,
        { limit: 5, status: 'published', ...(cursor ? { cursor } : {}) },
        moderator,
      )
      walked.push(...page.items)
      cursor = page.nextCursor
    } while (cursor)

    // Deep item-parity (spec §4) — NOT just id/nextCursor equality.
    expect(walked).toEqual(allItems)
  })

  // ── count ──────────────────────────────────────────────────────────────
  it('count returns published-only count', async () => {
    const ref1 = await post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'a', initialStatus: 'published' }, moderator, sanitize)
    await post(db, { target, author: { kind: 'user', userId: 'u1' }, body: 'b' }, author, sanitize)
    const n = await count(db, target)
    expect(n).toBe(1)
  })

  // ── cross-target parent validation ─────────────────────────────────────
  it('parent from different target → CommentValidationError', async () => {
    const other: CommentTarget = { type: 'page', id: 'pg1' }
    const ref = await post(db, { target: other, author: { kind: 'user', userId: 'u1' }, body: 'other' }, author, sanitize)
    await expect(
      post(db, { target, parentId: ref.id, author: { kind: 'user', userId: 'u1' }, body: 'child' }, author, sanitize),
    ).rejects.toThrow(CommentValidationError)
  })

  // ── getById returns null for missing ──────────────────────────────────
  it('getById returns null for non-existent id', async () => {
    expect(await getById(db, 'no-such-id')).toBeNull()
  })

  // ── list: default path returns published-only; guest-email redaction in list ──
  it("list default (no status/actor) returns published-only, guest-email redacted from list", async () => {
    // Post a published guest comment with email+url
    await post(db, { target, author: { kind: "guest", name: "Alice", email: "alice@secret.com", url: "https://alice.dev" }, body: "pub", initialStatus: "published" }, moderator, sanitize)
    // Post a pending comment — must NOT appear in default list
    await post(db, { target, author: { kind: "user", userId: "u1" }, body: "pend" }, author, sanitize)

    // No actor, no status — defaults to published
    const page = await list(db, target)
    expect(page.items).toHaveLength(1)
    const c = page.items[0]!
    // guest email redacted even with no actor
    expect(JSON.stringify(c.author)).not.toContain("alice@secret.com")
    // url is public
    expect(JSON.stringify(c.author)).toContain("https://alice.dev")
  })

  it("list status:[] (degenerate) never widens visibility — public sees published-only", async () => {
    // Seed one published + one pending under the same target.
    await post(db, { target, author: { kind: "user", userId: "u1" }, body: "pub", initialStatus: "published" }, moderator, sanitize)
    await post(db, { target, author: { kind: "user", userId: "u1" }, body: "pend" }, author, sanitize)

    // A non-moderator forwarding an empty status array must NOT receive pending/trashed rows
    // and must NOT throw — it collapses to the safe public default.
    const page = await list(db, target, { status: [] })
    expect(page.items).toHaveLength(1)
    expect(page.items[0]!.status).toBe("published")
    expect(page.items.some((c) => c.status === "pending")).toBe(false)
  })

  it("list non-published status without canModerate → CommentAuthzError", async () => {
    await expect(
      list(db, target, { status: "pending" }, author),
    ).rejects.toThrow(CommentAuthzError)
    // no actor at all also rejects
    await expect(
      list(db, target, { status: "pending" }),
    ).rejects.toThrow(CommentAuthzError)
  })
})

describe('listForModeration — cross-target moderation queue', () => {
  it('returns pending comments across ALL targets (cross-target)', async () => {
    await post(db, { target: { type: 't', id: 'A' }, author: { kind: 'guest', name: 'a' }, body: 'on A' }, null, sanitize)
    await post(db, { target: { type: 't', id: 'B' }, author: { kind: 'guest', name: 'b' }, body: 'on B' }, null, sanitize)
    const page = await listForModeration(db, {}, moderator)
    const ids = page.items.map((c) => `${c.target.id}`)
    expect(ids.sort()).toEqual(['A', 'B'])
  })

  it('defaults to status=pending — published comments do NOT appear', async () => {
    await post(db, { target: { type: 't', id: 'A' }, author: { kind: 'user', userId: 'm' }, body: 'pub', initialStatus: 'published' }, moderator, sanitize)
    await post(db, { target: { type: 't', id: 'B' }, author: { kind: 'guest', name: 'g' }, body: 'pend' }, null, sanitize)
    const page = await listForModeration(db, {}, moderator)
    expect(page.items).toHaveLength(1)
    expect(page.items[0]!.status).toBe('pending')
  })

  it('filters by an explicit status across targets (spam)', async () => {
    await post(db, { target: { type: 't', id: 'A' }, author: { kind: 'user', userId: 'm' }, body: 's', initialStatus: 'spam' }, moderator, sanitize)
    await post(db, { target: { type: 't', id: 'B' }, author: { kind: 'guest', name: 'g' }, body: 'p' }, null, sanitize)
    const page = await listForModeration(db, { status: 'spam' }, moderator)
    expect(page.items.every((c) => c.status === 'spam')).toBe(true)
    expect(page.items).toHaveLength(1)
  })

  it('FAIL-CLOSED: a non-moderator actor throws CommentAuthzError (even though pending exist)', async () => {
    await post(db, { target: { type: 't', id: 'A' }, author: { kind: 'guest', name: 'g' }, body: 'x' }, null, sanitize)
    await expect(listForModeration(db, {}, author)).rejects.toBeInstanceOf(CommentAuthzError)
  })

  it('FAIL-CLOSED: even a published-only query requires canModerate', async () => {
    await expect(listForModeration(db, { status: 'published' }, author)).rejects.toBeInstanceOf(CommentAuthzError)
  })

  it('empty status array collapses to pending (never widens)', async () => {
    await post(db, { target: { type: 't', id: 'A' }, author: { kind: 'user', userId: 'm' }, body: 'pub', initialStatus: 'published' }, moderator, sanitize)
    await post(db, { target: { type: 't', id: 'B' }, author: { kind: 'guest', name: 'g' }, body: 'pend' }, null, sanitize)
    const page = await listForModeration(db, { status: [] }, moderator)
    expect(page.items.every((c) => c.status === 'pending')).toBe(true)
    expect(page.items).toHaveLength(1)
  })

  it('keyset-paginates cross-target without skip/dupe (newest-first default)', async () => {
    await post(db, { target: { type: 't', id: 'A' }, author: { kind: 'guest', name: '1' }, body: '1' }, null, sanitize)
    await post(db, { target: { type: 't', id: 'B' }, author: { kind: 'guest', name: '2' }, body: '2' }, null, sanitize)
    await post(db, { target: { type: 't', id: 'A' }, author: { kind: 'guest', name: '3' }, body: '3' }, null, sanitize)
    const seen: string[] = []
    let cursor: string | null | undefined
    do {
      const page: Page<Comment> = await listForModeration(db, { limit: 1, cursor: cursor ?? undefined }, moderator)
      seen.push(...page.items.map((c) => c.body))
      cursor = page.nextCursor
    } while (cursor)
    expect(seen).toEqual(['3', '2', '1'])
    expect(new Set(seen).size).toBe(3)
  })

  it('exposes guest email to the moderator (canModerate by construction)', async () => {
    await post(db, { target: { type: 't', id: 'A' }, author: { kind: 'guest', name: 'g', email: 'g@x.io' }, body: 'x' }, null, sanitize)
    const page = await listForModeration(db, {}, moderator)
    const a = page.items[0]!.author
    expect(a.kind === 'guest' && a.email).toBe('g@x.io')
  })
})
