import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { eq, sql } from 'drizzle-orm'
import type { Querier } from '@platform-modules/db'
import { makePgHarness } from './pg-harness.js'
import { menusMigrationSql } from './schema.js'
import { menuItems, menus, type MenusSchema } from './schema.js'
import { createMenu, deleteMenu, updateMenu } from './menus.js'
import {
  addMenuItem,
  isForeignKeyViolation,
  moveMenuItem,
  removeMenuItem,
  reorderMenuItems,
  updateMenuItem,
} from './items.js'
import { MAX_ITEMS_PER_MENU, MAX_MENU_DEPTH } from './limits.js'
import {
  isMenuAuthorizationError,
  isMenuCycleError,
  isMenuDepthExceededError,
  isMenuLimitExceededError,
  isMenuNotFoundError,
  isMenuUrlSchemeError,
  isMenuValidationError,
} from './errors.js'

const admin = { id: 'admin', canManageMenus: true }
const h = await makePgHarness()

beforeAll(async () => {
  for (const s of menusMigrationSql()
    .split(';')
    .map((x) => x.trim())
    .filter(Boolean)) {
    await h.db.execute(sql.raw(s))
  }
})
afterAll(async () => {
  await h.teardown()
})

describe('createMenu', () => {
  it('creates a menu with a valid key', async () => {
    const menu = await createMenu(h.db, admin, { key: 'primary', label: 'Primary' })
    expect(menu.key).toBe('primary')
    expect(menu.label).toBe('Primary')
  })

  it('rejects duplicate keys', async () => {
    await expect(createMenu(h.db, admin, { key: 'primary', label: 'Dup' })).rejects.toThrow()
  })

  it('rejects invalid keys via MENU_KEY_RE', async () => {
    await expect(createMenu(h.db, admin, { key: 'Bad Key', label: 'X' })).rejects.toSatisfy(
      isMenuValidationError,
    )
  })

  it('requires canManageMenus before any write', async () => {
    await expect(createMenu(h.db, { id: 'x' }, { key: 'footer-nav', label: 'Footer' })).rejects.toSatisfy(
      isMenuAuthorizationError,
    )
  })
})

describe('updateMenu + deleteMenu', () => {
  it('updates label and deletes menu', async () => {
    const menu = await createMenu(h.db, admin, { key: 'side', label: 'Side' })
    const updated = await updateMenu(h.db, admin, menu.id, { label: 'Sidebar' })
    expect(updated.label).toBe('Sidebar')
    await deleteMenu(h.db, admin, menu.id)
    const rows = await h.db.select().from(menus).where(eq(menus.id, menu.id))
    expect(rows.length).toBe(0)
  })
})

describe('addMenuItem', () => {
  const menuKey = 'items-menu'
  let menuId = ''

  beforeAll(async () => {
    const menu = await createMenu(h.db, admin, { key: menuKey, label: 'Items' })
    menuId = menu.id
  })

  it('stores depth for nested items', async () => {
    const root = await addMenuItem(h.db, admin, menuId, {
      label: 'Root',
      target: { kind: 'url', url: '/root' },
    })
    expect(root.depth).toBe(0)
    const child = await addMenuItem(h.db, admin, menuId, {
      parentId: root.id,
      label: 'Child',
      target: { kind: 'url', url: '/child' },
    })
    expect(child.depth).toBe(1)
  })

  it('accepts http/https/relative URLs and rejects dangerous schemes', async () => {
    await expect(
      addMenuItem(h.db, admin, menuId, {
        label: 'Http',
        target: { kind: 'url', url: 'https://example.com' },
      }),
    ).resolves.toBeDefined()
    await expect(
      addMenuItem(h.db, admin, menuId, {
        label: 'Relative',
        target: { kind: 'url', url: '/relative' },
      }),
    ).resolves.toBeDefined()
    await expect(
      addMenuItem(h.db, admin, menuId, {
        label: 'Bad',
        target: { kind: 'url', url: 'javascript:alert(1)' },
      }),
    ).rejects.toSatisfy(isMenuUrlSchemeError)
    await expect(
      addMenuItem(h.db, admin, menuId, {
        label: 'Data',
        target: { kind: 'url', url: 'data:text/html,hi' },
      }),
    ).rejects.toSatisfy(isMenuUrlSchemeError)
    await expect(
      addMenuItem(h.db, admin, menuId, {
        label: 'Mail',
        target: { kind: 'url', url: 'mailto:hi@example.com' },
      }),
    ).resolves.toBeDefined()
    await expect(
      addMenuItem(h.db, admin, menuId, {
        label: 'ProtoRel',
        target: { kind: 'url', url: '//evil.com' },
      }),
    ).rejects.toSatisfy(isMenuUrlSchemeError)
    await expect(
      addMenuItem(h.db, admin, menuId, {
        label: 'BackslashAuthority',
        target: { kind: 'url', url: '/\\evil.com' },
      }),
    ).rejects.toSatisfy(isMenuUrlSchemeError)
  })

  it('persists opaque entity targets without resolution', async () => {
    const item = await addMenuItem(h.db, admin, menuId, {
      label: 'Post',
      target: { kind: 'entity', entityType: 'content', entityId: 'entry-42' },
    })
    expect(item.targetKind).toBe('entity')
    expect(item.targetEntityType).toBe('content')
    expect(item.targetEntityId).toBe('entry-42')
    expect(item.url).toBeNull()
    const rows = await h.db.select().from(menuItems).where(eq(menuItems.id, item.id))
    expect(rows[0]?.targetEntityId).toBe('entry-42')
    expect(rows[0]?.url).toBeNull()
  })

  it(`rejects depth beyond MAX_MENU_DEPTH (${MAX_MENU_DEPTH})`, async () => {
    const depthMenu = await createMenu(h.db, admin, { key: 'depth-menu', label: 'Depth' })
    let parentId: string | null = null
    for (let d = 0; d < MAX_MENU_DEPTH; d++) {
      const item = await addMenuItem(h.db, admin, depthMenu.id, {
        parentId: parentId ?? undefined,
        label: `L${d}`,
        target: { kind: 'url', url: `/l${d}` },
      })
      parentId = item.id
    }
    await expect(
      addMenuItem(h.db, admin, depthMenu.id, {
        parentId: parentId!,
        label: 'Too deep',
        target: { kind: 'url', url: '/too-deep' },
      }),
    ).rejects.toSatisfy(isMenuDepthExceededError)
  })

  it(`rejects more than MAX_ITEMS_PER_MENU (${MAX_ITEMS_PER_MENU}) items`, async () => {
    const limitMenu = await createMenu(h.db, admin, { key: 'limit-menu', label: 'Limit' })
    for (let i = 0; i < MAX_ITEMS_PER_MENU; i++) {
      await addMenuItem(h.db, admin, limitMenu.id, {
        label: `Item ${i}`,
        target: { kind: 'url', url: `/i${i}` },
      })
    }
    await expect(
      addMenuItem(h.db, admin, limitMenu.id, {
        label: 'Overflow',
        target: { kind: 'url', url: '/overflow' },
      }),
    ).rejects.toSatisfy(isMenuLimitExceededError)
  })

  it('requires canManageMenus before any write', async () => {
    await expect(
      addMenuItem(h.db, { id: 'x' }, menuId, {
        label: 'Nope',
        target: { kind: 'url', url: '/nope' },
      }),
    ).rejects.toSatisfy(isMenuAuthorizationError)
  })

  it('throws MenuNotFoundError for a non-existent menuId', async () => {
    await expect(
      addMenuItem(h.db, admin, '00000000-0000-4000-8000-000000000000', {
        label: 'Orphan',
        target: { kind: 'url', url: '/orphan' },
      }),
    ).rejects.toSatisfy(isMenuNotFoundError)
  })

  it('stores canonical trimmed URL, not raw input', async () => {
    const item = await addMenuItem(h.db, admin, menuId, {
      label: 'Trimmed',
      target: { kind: 'url', url: '  https://example.com/path  ' },
    })
    expect(item.url).toBe('https://example.com/path')
    const rows = await h.db.select().from(menuItems).where(eq(menuItems.id, item.id))
    expect(rows[0]?.url).toBe('https://example.com/path')
  })
})

describe('moveMenuItem', () => {
  it('prevents moving a node under its own descendant', async () => {
    const menu = await createMenu(h.db, admin, { key: 'cycle-menu', label: 'Cycle' })
    const a = await addMenuItem(h.db, admin, menu.id, {
      label: 'A',
      target: { kind: 'url', url: '/a' },
    })
    const b = await addMenuItem(h.db, admin, menu.id, {
      parentId: a.id,
      label: 'B',
      target: { kind: 'url', url: '/b' },
    })
    const c = await addMenuItem(h.db, admin, menu.id, {
      parentId: b.id,
      label: 'C',
      target: { kind: 'url', url: '/c' },
    })
    await expect(
      moveMenuItem(h.db, admin, a.id, { newParentId: c.id, newPosition: 0 }),
    ).rejects.toSatisfy(isMenuCycleError)
  })

  it('resequences siblings when moving', async () => {
    const menu = await createMenu(h.db, admin, { key: 'move-menu', label: 'Move' })
    const a = await addMenuItem(h.db, admin, menu.id, {
      label: 'A',
      target: { kind: 'url', url: '/a' },
    })
    const b = await addMenuItem(h.db, admin, menu.id, {
      label: 'B',
      target: { kind: 'url', url: '/b' },
    })
    await moveMenuItem(h.db, admin, b.id, { newParentId: null, newPosition: 0 })
    const rows = await h.db
      .select()
      .from(menuItems)
      .where(eq(menuItems.menuId, menu.id))
    const root = rows.filter((r) => r.parentId === null).sort((x, y) => x.position - y.position)
    expect(root[0]?.id).toBe(b.id)
    expect(root[1]?.id).toBe(a.id)
  })
})

describe('reorderMenuItems + updateMenuItem + removeMenuItem', () => {
  it('reorders siblings, updates fields, and removes items', async () => {
    const menu = await createMenu(h.db, admin, { key: 'ops-menu', label: 'Ops' })
    const a = await addMenuItem(h.db, admin, menu.id, {
      label: 'A',
      target: { kind: 'url', url: '/a' },
    })
    const b = await addMenuItem(h.db, admin, menu.id, {
      label: 'B',
      target: { kind: 'url', url: '/b' },
    })
    await reorderMenuItems(h.db, admin, menu.id, null, [b.id, a.id])
    const reordered = await h.db
      .select()
      .from(menuItems)
      .where(eq(menuItems.menuId, menu.id))
    const roots = reordered.filter((r) => r.parentId === null).sort((x, y) => x.position - y.position)
    expect(roots.map((r) => r.id)).toEqual([b.id, a.id])

    const updated = await updateMenuItem(h.db, admin, a.id, {
      label: 'A2',
      target: { kind: 'url', url: 'http://example.com/a2' },
    })
    expect(updated.label).toBe('A2')
    expect(updated.url).toBe('http://example.com/a2')

    await removeMenuItem(h.db, admin, b.id)
    const afterRemove = await h.db.select().from(menuItems).where(eq(menuItems.id, b.id))
    expect(afterRemove.length).toBe(0)
  })

  it('rejects duplicate sibling ids in reorderMenuItems', async () => {
    const menu = await createMenu(h.db, admin, { key: 'dup-reorder-menu', label: 'Dup reorder' })
    const a = await addMenuItem(h.db, admin, menu.id, {
      label: 'A',
      target: { kind: 'url', url: '/a' },
    })
    const b = await addMenuItem(h.db, admin, menu.id, {
      label: 'B',
      target: { kind: 'url', url: '/b' },
    })
    await expect(
      reorderMenuItems(h.db, admin, menu.id, null, [a.id, a.id]),
    ).rejects.toSatisfy(isMenuValidationError)
    const rows = await h.db
      .select()
      .from(menuItems)
      .where(eq(menuItems.menuId, menu.id))
    const roots = rows.filter((r) => r.parentId === null).sort((x, y) => x.position - y.position)
    expect(roots.map((r) => r.id)).toEqual([a.id, b.id])
  })

  it('rejects a foreign (non-sibling) id of equal length in reorderMenuItems', async () => {
    const menu = await createMenu(h.db, admin, { key: 'foreign-reorder-menu', label: 'Foreign reorder' })
    const a = await addMenuItem(h.db, admin, menu.id, {
      label: 'A',
      target: { kind: 'url', url: '/a' },
    })
    const b = await addMenuItem(h.db, admin, menu.id, {
      label: 'B',
      target: { kind: 'url', url: '/b' },
    })
    const foreign = crypto.randomUUID()
    await expect(
      reorderMenuItems(h.db, admin, menu.id, null, [a.id, foreign]),
    ).rejects.toSatisfy(isMenuValidationError)
    const rows = await h.db
      .select()
      .from(menuItems)
      .where(eq(menuItems.menuId, menu.id))
    const roots = rows.filter((r) => r.parentId === null).sort((x, y) => x.position - y.position)
    expect(roots.map((r) => r.id)).toEqual([a.id, b.id])
  })
})

describe('isForeignKeyViolation', () => {
  it('detects SQLSTATE 23503 on the error or nested cause', () => {
    expect(isForeignKeyViolation({ code: '23503' })).toBe(true)
    expect(isForeignKeyViolation({ cause: { code: '23503' } })).toBe(true)
    expect(isForeignKeyViolation({ code: '23505' })).toBe(false)
    expect(isForeignKeyViolation(new Error('plain'))).toBe(false)
  })
})

function makeInsertFailingDb(menuId: string, insertError: unknown): Querier<MenusSchema> {
  return {
    select: (cols?: unknown) => {
      if (cols && typeof cols === 'object' && 'count' in cols) {
        return {
          from: () => ({
            where: async () => [{ count: 0 }],
          }),
        }
      }
      return {
        from: () => ({
          where: () => ({
            limit: async () => [{ id: menuId }],
            orderBy: async () => [],
          }),
          orderBy: async () => [],
        }),
      }
    },
    insert: () => ({
      values: async () => {
        throw insertError
      },
    }),
  } as unknown as Querier<MenusSchema>
}

describe('addMenuItem insert FK mapping', () => {
  it('maps a concurrent menu-delete FK violation to MenuNotFoundError', async () => {
    const menuId = '00000000-0000-4000-8000-000000000001'
    const db = makeInsertFailingDb(menuId, { code: '23503' })
    await expect(
      addMenuItem(db, admin, menuId, {
        label: 'Late',
        target: { kind: 'url', url: '/late' },
      }),
    ).rejects.toSatisfy(isMenuNotFoundError)
  })

  it('rethrows non-FK insert errors unchanged', async () => {
    const menuId = '00000000-0000-4000-8000-000000000002'
    const uniqueViolation = { code: '23505' }
    const db = makeInsertFailingDb(menuId, uniqueViolation)
    await expect(
      addMenuItem(db, admin, menuId, {
        label: 'Dup',
        target: { kind: 'url', url: '/dup' },
      }),
    ).rejects.toBe(uniqueViolation)

    const plain = new Error('insert failed')
    const dbPlain = makeInsertFailingDb(menuId, plain)
    await expect(
      addMenuItem(dbPlain, admin, menuId, {
        label: 'Fail',
        target: { kind: 'url', url: '/fail' },
      }),
    ).rejects.toBe(plain)
  })
})

describe('FK cascade', () => {
  it('deletes items when menu is deleted', async () => {
    const menu = await createMenu(h.db, admin, { key: 'cascade-menu', label: 'Cascade' })
    const item = await addMenuItem(h.db, admin, menu.id, {
      label: 'X',
      target: { kind: 'url', url: '/x' },
    })
    await deleteMenu(h.db, admin, menu.id)
    const items = await h.db.select().from(menuItems).where(eq(menuItems.id, item.id))
    expect(items.length).toBe(0)
  })

  it('deletes children when parent is removed', async () => {
    const menu = await createMenu(h.db, admin, { key: 'parent-cascade', label: 'Parent' })
    const parent = await addMenuItem(h.db, admin, menu.id, {
      label: 'Parent',
      target: { kind: 'url', url: '/p' },
    })
    const child = await addMenuItem(h.db, admin, menu.id, {
      parentId: parent.id,
      label: 'Child',
      target: { kind: 'url', url: '/c' },
    })
    await removeMenuItem(h.db, admin, parent.id)
    const childRows = await h.db.select().from(menuItems).where(eq(menuItems.id, child.id))
    expect(childRows.length).toBe(0)
  })
})
