import { PGlite } from '@electric-sql/pglite'
import { drizzle } from 'drizzle-orm/pglite'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Querier } from '@platform-modules/db'
import {
  CommentAuthzError,
  CommentModerationError,
  CommentNotFoundError,
  CommentSanitizationError,
  isCommentError,
} from './errors.js'
import {
  aiModerator,
  CLASSIFY_TIMEOUT_MS,
  moderate,
  postModerated,
  type CommentModerator,
} from './moderation.js'
import { getById, post } from './store.js'
import { commentsSchema, commentsTableSql, type CommentsSchema } from './schema.js'
import type { Actor, CommentTarget } from './types.js'

type Db = Querier<CommentsSchema>

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 moderatorActor: 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 }
}

function fixedModerator(status: 'published' | 'pending' | 'spam'): CommentModerator {
  return { classify: async () => ({ status }) }
}

let db: Db

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

afterEach(() => {
  vi.useRealTimers()
  vi.restoreAllMocks()
})

describe('postModerated', () => {
  it('spam verdict → row stored with status spam', async () => {
    const comment = await postModerated(
      db,
      { target, author: { kind: 'guest', name: 'Spammer' }, body: 'buy pills' },
      null,
      sanitize,
      fixedModerator('spam'),
    )
    expect(comment.status).toBe('spam')
    expect(comment.author).toEqual({ kind: 'guest', name: 'Spammer' })
  })

  it('published verdict → row stored with status published', async () => {
    const comment = await postModerated(
      db,
      { target, author: { kind: 'user', userId: 'u1' }, body: 'nice post' },
      { id: 'u1' },
      sanitize,
      fixedModerator('published'),
    )
    expect(comment.status).toBe('published')
  })

  it('pending verdict → row stored with status pending', async () => {
    const comment = await postModerated(
      db,
      { target, author: { kind: 'user', userId: 'u1' }, body: 'maybe' },
      { id: 'u1' },
      sanitize,
      fixedModerator('pending'),
    )
    expect(comment.status).toBe('pending')
  })

  it('actor=null guest path works without canModerate', async () => {
    const comment = await postModerated(
      db,
      { target, author: { kind: 'guest', name: 'Alice', email: 'a@ex.com' }, body: 'hello' },
      null,
      sanitize,
      fixedModerator('published'),
    )
    expect(comment.status).toBe('published')
    expect(comment.author).toEqual({ kind: 'guest', name: 'Alice', email: 'a@ex.com' })
  })

  it('moderator throw → fail-safe pending (never published)', async () => {
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
    const throwing: CommentModerator = {
      classify: async () => {
        throw new Error('classifier_down')
      },
    }
    const comment = await postModerated(
      db,
      { target, author: { kind: 'guest', name: 'Bob' }, body: 'hi' },
      null,
      sanitize,
      throwing,
    )
    expect(comment.status).toBe('pending')
    expect(warnSpy).toHaveBeenCalledWith(
      expect.objectContaining({ event: 'comment_moderation_failed', reason: 'classifier_down' }),
    )
  })

  it('hung classifier → timeout → pending', async () => {
    vi.useFakeTimers()
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
    const hung: CommentModerator = {
      classify: () => new Promise(() => {}),
    }
    const pending = postModerated(
      db,
      { target, author: { kind: 'guest', name: 'Slow' }, body: 'wait' },
      null,
      sanitize,
      hung,
    )
    await vi.advanceTimersByTimeAsync(CLASSIFY_TIMEOUT_MS)
    const comment = await pending
    expect(comment.status).toBe('pending')
    expect(warnSpy).toHaveBeenCalledWith(
      expect.objectContaining({ event: 'comment_moderation_failed', reason: 'classify_timeout' }),
    )
  })

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

  it('poster-supplied initialStatus is IGNORED — status comes only from the moderator verdict', async () => {
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
    // A hostile guest tries to smuggle initialStatus:'published' while the verdict is 'spam'.
    const comment = await postModerated(
      db,
      { target, author: { kind: 'guest', name: 'X' }, body: 'evil', initialStatus: 'published' } as never,
      null,
      sanitize,
      fixedModerator('spam'),
    )
    expect(comment.status).toBe('spam')
    expect(warnSpy).not.toHaveBeenCalled()
  })

  it('FAIL-SAFE: moderator resolves with out-of-contract status → pending (not written verbatim)', async () => {
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
    for (const bad of ['approved', 'trashed', undefined, null, 'PUBLISHED', 123]) {
      const comment = await postModerated(
        db,
        { target, author: { kind: 'guest', name: 'X' }, body: 'b' },
        null,
        sanitize,
        { classify: async () => ({ status: bad }) } as never,
      )
      expect(comment.status).toBe('pending')
    }
    expect(warnSpy).toHaveBeenCalledWith(
      expect.objectContaining({ event: 'comment_moderation_failed' }),
    )
  })

  it('FAIL-SAFE: aiModerator with malformed spamScore → pending, NEVER published', async () => {
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
    for (const resp of [{}, { spamScore: undefined }, { spamScore: null }, { spamScore: NaN }, { error: 'rate_limited' }]) {
      const comment = await postModerated(
        db,
        { target, author: { kind: 'guest', name: 'X' }, body: 'b' },
        null,
        sanitize,
        aiModerator({ classify: async () => resp as never }),
      )
      expect(comment.status).toBe('pending')
      expect(comment.status).not.toBe('published')
    }
    expect(warnSpy).toHaveBeenCalledWith(
      expect.objectContaining({ event: 'comment_moderation_failed' }),
    )
  })
})

describe('moderate', () => {
  it('canModerate actor re-classifies → setStatus applied', async () => {
    const ref = await post(
      db,
      { target, author: { kind: 'user', userId: 'u1' }, body: 'review me' },
      { id: 'u1' },
      sanitize,
    )
    const updated = await moderate(db, ref.id, fixedModerator('spam'), moderatorActor)
    expect(updated.status).toBe('spam')
    expect(updated.body).toBe('review me')
  })

  it('non-moderator actor → CommentAuthzError', async () => {
    const ref = await post(
      db,
      { target, author: { kind: 'user', userId: 'u1' }, body: 'hi' },
      { id: 'u1' },
      sanitize,
    )
    await expect(moderate(db, ref.id, fixedModerator('spam'), stranger)).rejects.toThrow(CommentAuthzError)
  })

  it('missing comment → CommentNotFoundError', async () => {
    await expect(
      moderate(db, 'no-such-id', fixedModerator('spam'), moderatorActor),
    ).rejects.toThrow(CommentNotFoundError)
  })

  it('classifier throw propagates; comment status unchanged', async () => {
    const ref = await post(
      db,
      { target, author: { kind: 'user', userId: 'u1' }, body: 'stable' },
      { id: 'u1' },
      sanitize,
    )
    const before = await getById(db, ref.id, moderatorActor)
    expect(before!.status).toBe('pending')

    const throwing: CommentModerator = {
      classify: async () => {
        throw new Error('transient')
      },
    }
    await expect(moderate(db, ref.id, throwing, moderatorActor)).rejects.toThrow('transient')

    const after = await getById(db, ref.id, moderatorActor)
    expect(after!.status).toBe('pending')
  })

  it('invalid verdict status → rejects; existing row status unchanged', async () => {
    const ref = await post(
      db,
      { target, author: { kind: 'user', userId: 'u1' }, body: 'stable' },
      { id: 'u1' },
      sanitize,
    )
    const bogus: CommentModerator = { classify: async () => ({ status: 'approved' }) } as never
    await expect(moderate(db, ref.id, bogus, moderatorActor)).rejects.toThrow(CommentModerationError)
    // Typed-error contract: structural guard recognizes it (no bare throw).
    await moderate(db, ref.id, bogus, moderatorActor).catch((e) => expect(isCommentError(e)).toBe(true))
    const after = await getById(db, ref.id, moderatorActor)
    expect(after!.status).toBe('pending')
  })
})

describe('aiModerator', () => {
  it('spamScore above threshold → spam', async () => {
    const mod = aiModerator({ classify: async () => ({ spamScore: 0.9 }) })
    const verdict = await mod.classify({
      body: 'spam',
      author: { kind: 'guest', name: 'x' },
      target,
    })
    expect(verdict.status).toBe('spam')
  })

  it('spamScore below threshold → published', async () => {
    const mod = aiModerator({ classify: async () => ({ spamScore: 0.1 }) })
    const verdict = await mod.classify({
      body: 'ham',
      author: { kind: 'guest', name: 'x' },
      target,
    })
    expect(verdict.status).toBe('published')
  })

  it('threshold override respected', async () => {
    const mod = aiModerator({ classify: async () => ({ spamScore: 0.6 }) }, { threshold: 0.7 })
    const verdict = await mod.classify({
      body: 'edge',
      author: { kind: 'guest', name: 'x' },
      target,
    })
    expect(verdict.status).toBe('published')
  })

  it('client error propagates (not swallowed)', async () => {
    const mod = aiModerator({
      classify: async () => {
        throw new Error('ai_unavailable')
      },
    })
    await expect(
      mod.classify({ body: 'x', author: { kind: 'guest', name: 'x' }, target }),
    ).rejects.toThrow('ai_unavailable')
  })

  it('malformed spamScore throws CommentModerationError (fail-closed, not silently published)', async () => {
    for (const resp of [{}, { spamScore: undefined }, { spamScore: null }, { spamScore: NaN }, { spamScore: '0.9' }]) {
      const mod = aiModerator({ classify: async () => resp as never })
      const err = await mod
        .classify({ body: 'x', author: { kind: 'guest', name: 'x' }, target })
        .then(() => null)
        .catch((e) => e)
      expect(err).toBeInstanceOf(CommentModerationError)
      expect(isCommentError(err)).toBe(true)
    }
  })
})
