import { sql } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { sanitizeTsquery } from '@platform-modules/util/fts/sanitize-tsquery'
import { searchEntities } from '@platform-modules/search'
import type { Querier } from '@platform-modules/db'
import { type Actor } from './authz.js'
import { type ContentInput } from './model.js'
import { type ContentSchema } from './schema.js'
import { publish, put as putRaw } from './store.js'
import { startPg } from './pg-harness.js'
import { applyContentSearchMigration, setupSearchDb } from './search.test-helpers.js'
import { contentSearchMigrationSql, createContentSearchProvider } from './search.js'

describe('contentSearchMigrationSql', () => {
  it('returns a 2-statement idempotent DDL with weighted title/body tsvector + GIN index', () => {
    const ddl = contentSearchMigrationSql()
    const stmts = ddl.split(';').map((s) => s.trim()).filter(Boolean)
    expect(stmts).toHaveLength(2)
    expect(ddl).toContain('ADD COLUMN IF NOT EXISTS search_vector tsvector')
    expect(ddl).toContain('GENERATED ALWAYS AS')
    expect(ddl).toContain("setweight(to_tsvector('english', coalesce(title, '')), 'A')")
    expect(ddl).toContain(
      "setweight(to_tsvector('english', regexp_replace(coalesce(body, ''), '<[^>]*>', ' ', 'g')), 'B')",
    )
    expect(ddl).toContain('STORED')
    expect(ddl).toContain('CREATE INDEX IF NOT EXISTS content_entries_search_idx')
    expect(ddl).toContain('USING GIN (search_vector)')
  })

  it('parameterizes regconfig in both to_tsvector calls', () => {
    const ddl = contentSearchMigrationSql('cms_posts', 'simple')
    expect(ddl).toContain("to_tsvector('simple', coalesce(title, '')")
    expect(ddl).toContain("to_tsvector('simple', regexp_replace(coalesce(body, '')")
    expect(ddl).toContain('ALTER TABLE cms_posts')
  })
})

function sanitize(raw: string): string {
  return raw
}

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

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

async function seedPublished(
  d: Querier<ContentSchema>,
  slug: string,
  over: {
    title?: string
    body?: string
    visibility?: 'public' | 'private' | 'members'
    status?: 'draft' | 'published'
  } = {},
) {
  const e = await put(
    d,
    {
      slug,
      type: 'post',
      title: over.title ?? slug,
      body: over.body ?? 'body',
      visibility: over.visibility,
    },
    author,
  )
  if (over.status === 'draft') return e
  await publish(d, e.id, editor)
  return e
}

describe('contentSearchMigrationSql on real PG', () => {
  let db: Querier<ContentSchema>
  let stop: () => Promise<void>

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

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

  it('applies generated search_vector column + GIN index idempotently', async () => {
    const cols = await db.execute(sql`
      SELECT column_name FROM information_schema.columns
      WHERE table_name = 'content_entries' AND column_name = 'search_vector'
    `)
    const colRows = Array.isArray(cols) ? cols : (cols as { rows?: { column_name: string }[] }).rows ?? []
    expect(colRows[0]?.column_name).toBe('search_vector')

    const idx = await db.execute(sql`
      SELECT indexname FROM pg_indexes WHERE indexname = 'content_entries_search_idx'
    `)
    const idxRows = Array.isArray(idx) ? idx : (idx as { rows?: { indexname: string }[] }).rows ?? []
    expect(idxRows[0]?.indexname).toBe('content_entries_search_idx')

    await applyContentSearchMigration(db)
    await applyContentSearchMigration(db)
  })
})

describe('createContentSearchProvider trust boundary (real PG)', () => {
  let db: Querier<ContentSchema>
  let stop: () => Promise<void>
  const provider = createContentSearchProvider()

  beforeAll(async () => {
    const pg = await startPg()
    db = pg.db
    stop = pg.stop
    await setupSearchDb(db)

    await seedPublished(db, 'pub-alpha', { title: 'alpha public', body: 'alpha body' })
    await put(db, { slug: 'draft-alpha', type: 'post', title: 'alpha draft', body: 'alpha' }, author)
    await seedPublished(db, 'priv-alpha', {
      title: 'alpha private',
      body: 'alpha',
      visibility: 'private',
    })
    await seedPublished(db, 'mem-alpha', {
      title: 'alpha members',
      body: 'alpha',
      visibility: 'members',
    })
  }, 45_000)

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

  it('anonymous viewer sees only published+public matches', async () => {
    const { hits, total } = await provider('alpha:*', { db, viewer: null }, { limit: 20, offset: 0 })
    expect(total).toBe(1)
    expect(hits).toHaveLength(1)
    expect(hits[0]?.title).toBe('alpha public')
  })

  it('canViewMembers viewer includes members entry, not private or others draft', async () => {
    const { hits, total } = await provider('alpha:*', { db, viewer: member }, { limit: 20, offset: 0 })
    expect(total).toBe(2)
    expect(hits.map((h) => h.title).sort()).toEqual(['alpha members', 'alpha public'])
  })

  it('canEditAny viewer matches all statuses and visibilities', async () => {
    const { hits, total } = await provider('alpha:*', { db, viewer: editor }, { limit: 20, offset: 0 })
    expect(total).toBe(4)
    expect(hits).toHaveLength(4)
  })
})

describe('createContentSearchProvider ranking + prefix + snippet (real PG)', () => {
  let db: Querier<ContentSchema>
  let stop: () => Promise<void>
  const provider = createContentSearchProvider()

  beforeAll(async () => {
    const pg = await startPg()
    db = pg.db
    stop = pg.stop
    await setupSearchDb(db)

    await seedPublished(db, 'title-zeta', { title: 'zeta uniqueword', body: 'plain body' })
    await seedPublished(db, 'body-zeta', { title: 'plain title', body: 'zeta uniqueword in body' })

    await seedPublished(db, 'foobar-hit', { title: 'foobar prefix test', body: 'other' })

    await seedPublished(db, 'html-snippet', {
      title: 'snippet test',
      body: '<p>alpha bravo charlie</p><script>x</script>',
    })
  }, 45_000)

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

  it('title match outranks body-only match for the same term', async () => {
    const { hits } = await provider('zeta:* & uniqueword:*', { db, viewer: null }, { limit: 20, offset: 0 })
    expect(hits[0]?.title).toBe('zeta uniqueword')
  })

  it('prefix query matches a longer token in the title', async () => {
    const registry = { content: provider }
    const result = await searchEntities(registry, {
      query: 'foo',
      ctx: { db, viewer: null },
      entityTypes: ['content'],
      limit: 20,
    })
    expect(result.groups[0]?.hits.some((h) => h.title === 'foobar prefix test')).toBe(true)
  })

  it('snippetHtml has match markers and no HTML tags from the source body', async () => {
    const { hits } = await provider('alpha:* & bravo:*', { db, viewer: null }, { limit: 20, offset: 0 })
    const hit = hits.find((h) => h.title === 'snippet test')
    expect(hit?.snippetHtml).toBeTruthy()
    expect(hit?.snippetHtml).toMatch(/[«»]/)
    expect(hit?.snippetHtml).not.toMatch(/<[^>]+>/)
  })
})

describe('createContentSearchProvider empty + pagination + injection (real PG)', () => {
  let db: Querier<ContentSchema>
  let stop: () => Promise<void>
  const provider = createContentSearchProvider()

  beforeAll(async () => {
    const pg = await startPg()
    db = pg.db
    stop = pg.stop
    await setupSearchDb(db)

    for (let i = 0; i < 25; i++) {
      await seedPublished(db, `paginate-${i}`, {
        title: `paginate omega ${i}`,
        body: 'paginate omega body',
      })
    }
  }, 60_000)

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

  it('empty or whitespace query returns no hits without throwing', async () => {
    await expect(provider('', { db, viewer: null }, { limit: 20, offset: 0 })).resolves.toEqual({
      hits: [],
      total: 0,
    })
    await expect(provider('   ', { db, viewer: null }, { limit: 10, offset: 0 })).resolves.toEqual({
      hits: [],
      total: 0,
    })
  })

  it('total reflects full match count and offset shifts the page', async () => {
    const page0 = await provider('paginate:* & omega:*', { db, viewer: null }, { limit: 10, offset: 0 })
    const page1 = await provider('paginate:* & omega:*', { db, viewer: null }, { limit: 10, offset: 10 })
    expect(page0.total).toBe(25)
    expect(page0.hits).toHaveLength(10)
    expect(page1.total).toBe(25)
    expect(page1.hits).toHaveLength(10)
    const ids0 = new Set(page0.hits.map((h) => h.id))
    for (const h of page1.hits) expect(ids0.has(h.id)).toBe(false)
  })

  it('sanitized metachar query binds safely without SQL error', async () => {
    const cleaned = sanitizeTsquery('a & b | c:*')
    await expect(provider(cleaned, { db, viewer: null }, { limit: 5, offset: 0 })).resolves.toMatchObject({
      hits: expect.any(Array),
      total: expect.any(Number),
    })
  })
})

describe('createContentSearchProvider type allowlist (real PG)', () => {
  let db: Querier<ContentSchema>
  let stop: () => Promise<void>

  beforeAll(async () => {
    const pg = await startPg()
    db = pg.db
    stop = pg.stop
    await setupSearchDb(db)

    // A published+public post AND a published+public page both matching the term.
    await seedPublished(db, 'post-kappa', { title: 'kappa post', body: 'kappa' })
    const page = await put(
      db,
      { slug: 'page-kappa', type: 'page', title: 'kappa page', body: 'kappa' },
      author,
    )
    await publish(db, page.id, editor)
  }, 45_000)

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

  it('omitted types → searches all types (post + page)', async () => {
    const all = createContentSearchProvider()
    const { total, hits } = await all('kappa:*', { db, viewer: null }, { limit: 20, offset: 0 })
    expect(total).toBe(2)
    expect(hits.map((h) => h.title).sort()).toEqual(['kappa page', 'kappa post'])
  })

  it('types:[post] → SQL-level filter excludes the page, total reflects the filter', async () => {
    const postsOnly = createContentSearchProvider({ types: ['post'] })
    const { total, hits } = await postsOnly('kappa:*', { db, viewer: null }, { limit: 20, offset: 0 })
    expect(total).toBe(1)
    expect(hits).toHaveLength(1)
    expect(hits[0]?.title).toBe('kappa post')
  })
})
