import { and, asc, eq, isNull, sql } from 'drizzle-orm'
import type { Querier } from '@platform-modules/db'
import type { Actor } from './authz.js'
import { assertCanManageMenus } from './authz.js'
import {
  MenuCycleError,
  MenuDepthExceededError,
  MenuLimitExceededError,
  MenuNotFoundError,
  MenuValidationError,
} from './errors.js'
import { MAX_ITEMS_PER_MENU, MAX_MENU_DEPTH } from './limits.js'
import type { MenuItemRow, MenuTarget } from './model.js'
import { menuItems, menus, type MenusSchema } from './schema.js'
import { assertAllowedMenuUrl } from './url-scheme.js'

function nowMs(): number {
  return Date.now()
}

function newId(): string {
  return crypto.randomUUID()
}

function rowToItem(row: typeof menuItems.$inferSelect): MenuItemRow {
  return {
    id: row.id,
    menuId: row.menuId,
    parentId: row.parentId,
    position: row.position,
    depth: row.depth,
    label: row.label,
    targetKind: row.targetKind as 'url' | 'entity',
    url: row.url,
    targetEntityType: row.targetEntityType,
    targetEntityId: row.targetEntityId,
    openInNew: row.openInNew,
    createdAtMs: row.createdAtMs,
    updatedAtMs: row.updatedAtMs,
  }
}

function validateTarget(target: MenuTarget): void {
  if (target.kind === 'url') {
    if (!target.url) throw new MenuValidationError('url', 'url target requires url')
    target.url = assertAllowedMenuUrl(target.url)
    return
  }
  if (!target.entityType || !target.entityId) {
    throw new MenuValidationError('target', 'entity target requires entityType and entityId')
  }
}

function targetColumns(target: MenuTarget, openInNew?: boolean) {
  if (target.kind === 'url') {
    const url = assertAllowedMenuUrl(target.url!)
    return {
      targetKind: 'url' as const,
      url,
      targetEntityType: null,
      targetEntityId: null,
      openInNew: target.openInNew ?? openInNew ?? false,
    }
  }
  return {
    targetKind: 'entity' as const,
    url: null,
    targetEntityType: target.entityType,
    targetEntityId: target.entityId,
    openInNew: target.openInNew ?? openInNew ?? false,
  }
}

async function getItem(db: Querier<MenusSchema>, itemId: string) {
  const [row] = await db.select().from(menuItems).where(eq(menuItems.id, itemId)).limit(1)
  if (!row) throw new MenuNotFoundError('item', itemId)
  return row
}

async function assertMenuExists(db: Querier<MenusSchema>, menuId: string): Promise<void> {
  const [row] = await db.select({ id: menus.id }).from(menus).where(eq(menus.id, menuId)).limit(1)
  if (!row) throw new MenuNotFoundError('menu', menuId)
}

async function countItemsInMenu(db: Querier<MenusSchema>, menuId: string): Promise<number> {
  const [row] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(menuItems)
    .where(eq(menuItems.menuId, menuId))
  return row?.count ?? 0
}

async function getSiblings(
  db: Querier<MenusSchema>,
  menuId: string,
  parentId: string | null,
  excludeId?: string,
) {
  const rows = await db
    .select()
    .from(menuItems)
    .where(
      and(
        eq(menuItems.menuId, menuId),
        parentId === null ? isNull(menuItems.parentId) : eq(menuItems.parentId, parentId),
      ),
    )
    .orderBy(asc(menuItems.position))
  return excludeId ? rows.filter((r) => r.id !== excludeId) : rows
}

async function resequenceSiblings(
  db: Querier<MenusSchema>,
  menuId: string,
  parentId: string | null,
  orderedIds: string[],
): Promise<void> {
  const ts = nowMs()
  for (let position = 0; position < orderedIds.length; position++) {
    await db
      .update(menuItems)
      .set({ position, updatedAtMs: ts })
      .where(and(eq(menuItems.id, orderedIds[position]!), eq(menuItems.menuId, menuId)))
  }
}

async function assertNoCycle(
  db: Querier<MenusSchema>,
  movedItemId: string,
  newParentId: string | null,
): Promise<void> {
  if (newParentId === null) return
  if (newParentId === movedItemId) throw new MenuCycleError(movedItemId)
  let current: string | null = newParentId
  const visited = new Set<string>()
  while (current) {
    if (current === movedItemId) throw new MenuCycleError(movedItemId)
    if (visited.has(current)) break
    visited.add(current)
    const [parent] = await db.select().from(menuItems).where(eq(menuItems.id, current)).limit(1)
    current = parent?.parentId ?? null
  }
}

async function maxDescendantDepth(db: Querier<MenusSchema>, itemId: string): Promise<number> {
  const item = await getItem(db, itemId)
  const all = await db.select().from(menuItems).where(eq(menuItems.menuId, item.menuId))
  const byParent = new Map<string | null, typeof all>()
  for (const row of all) {
    const key = row.parentId
    const list = byParent.get(key) ?? []
    list.push(row)
    byParent.set(key, list)
  }
  let maxExtra = 0
  const walk = (id: string, extra: number) => {
    maxExtra = Math.max(maxExtra, extra)
    for (const child of byParent.get(id) ?? []) walk(child.id, extra + 1)
  }
  walk(itemId, 0)
  return maxExtra
}

function assertDepthAllowed(depth: number): void {
  if (depth >= MAX_MENU_DEPTH) throw new MenuDepthExceededError(MAX_MENU_DEPTH)
}

function hasPgSqlState(err: unknown, code: string): boolean {
  if (typeof err !== 'object' || err === null) return false
  const rec = err as { code?: unknown; cause?: unknown }
  if (rec.code === code) return true
  if (rec.cause !== undefined) return hasPgSqlState(rec.cause, code)
  return false
}

/** True when `err` (or a nested `cause`) carries Postgres SQLSTATE `23503` (FK violation). */
export function isForeignKeyViolation(err: unknown): boolean {
  return hasPgSqlState(err, '23503')
}

export async function addMenuItem(
  db: Querier<MenusSchema>,
  actor: Actor,
  menuId: string,
  input: { parentId?: string; label: string; target: MenuTarget },
): Promise<MenuItemRow> {
  assertCanManageMenus(actor)
  validateTarget(input.target)
  await assertMenuExists(db, menuId)
  const count = await countItemsInMenu(db, menuId)
  if (count >= MAX_ITEMS_PER_MENU) throw new MenuLimitExceededError(MAX_ITEMS_PER_MENU)

  let depth = 0
  const parentId = input.parentId ?? null
  if (parentId) {
    const parent = await getItem(db, parentId)
    if (parent.menuId !== menuId) throw new MenuNotFoundError('item', parentId)
    depth = parent.depth + 1
  }
  assertDepthAllowed(depth)

  const siblings = await getSiblings(db, menuId, parentId)
  const position = siblings.length
  const ts = nowMs()
  const row = {
    id: newId(),
    menuId,
    parentId,
    position,
    depth,
    label: input.label,
    ...targetColumns(input.target),
    createdAtMs: ts,
    updatedAtMs: ts,
  }
  try {
    await db.insert(menuItems).values(row)
  } catch (err) {
    if (isForeignKeyViolation(err)) throw new MenuNotFoundError('menu', menuId)
    throw err
  }
  return rowToItem(row)
}

export async function updateMenuItem(
  db: Querier<MenusSchema>,
  actor: Actor,
  itemId: string,
  patch: { label?: string; target?: MenuTarget; openInNew?: boolean },
): Promise<MenuItemRow> {
  assertCanManageMenus(actor)
  if (patch.target) validateTarget(patch.target)
  const existing = await getItem(db, itemId)
  const updatedAtMs = nowMs()
  const next = {
    label: patch.label ?? existing.label,
    ...(patch.target ? targetColumns(patch.target, patch.openInNew) : { openInNew: patch.openInNew ?? existing.openInNew }),
    updatedAtMs,
  }
  await db.update(menuItems).set(next).where(eq(menuItems.id, itemId))
  return rowToItem({ ...existing, ...next })
}

export async function removeMenuItem(db: Querier<MenusSchema>, actor: Actor, itemId: string): Promise<void> {
  assertCanManageMenus(actor)
  const existing = await getItem(db, itemId)
  await db.delete(menuItems).where(eq(menuItems.id, itemId))
  const siblings = await getSiblings(db, existing.menuId, existing.parentId)
  await resequenceSiblings(
    db,
    existing.menuId,
    existing.parentId,
    siblings.map((s) => s.id),
  )
}

export async function moveMenuItem(
  db: Querier<MenusSchema>,
  actor: Actor,
  itemId: string,
  input: { newParentId: string | null; newPosition: number },
): Promise<MenuItemRow> {
  assertCanManageMenus(actor)
  const item = await getItem(db, itemId)
  const { newParentId, newPosition } = input

  if (newParentId) {
    const parent = await getItem(db, newParentId)
    if (parent.menuId !== item.menuId) throw new MenuNotFoundError('item', newParentId)
  }

  await assertNoCycle(db, itemId, newParentId)

  const subtreeExtra = await maxDescendantDepth(db, itemId)
  const newDepth = newParentId ? (await getItem(db, newParentId)).depth + 1 : 0
  assertDepthAllowed(newDepth)
  if (newDepth + subtreeExtra >= MAX_MENU_DEPTH) throw new MenuDepthExceededError(MAX_MENU_DEPTH)

  const oldParentId = item.parentId
  const oldSiblings = (await getSiblings(db, item.menuId, oldParentId, itemId)).map((s) => s.id)
  await resequenceSiblings(db, item.menuId, oldParentId, oldSiblings)

  const newSiblings = (await getSiblings(db, item.menuId, newParentId, itemId)).map((s) => s.id)
  const clamped = Math.max(0, Math.min(newPosition, newSiblings.length))
  newSiblings.splice(clamped, 0, itemId)
  await resequenceSiblings(db, item.menuId, newParentId, newSiblings)

  const depthDelta = newDepth - item.depth
  const ts = nowMs()
  await db
    .update(menuItems)
    .set({ parentId: newParentId, depth: newDepth, position: clamped, updatedAtMs: ts })
    .where(eq(menuItems.id, itemId))

  if (depthDelta !== 0) {
    const all = await db.select().from(menuItems).where(eq(menuItems.menuId, item.menuId))
    const childrenByParent = new Map<string, typeof all>()
    for (const row of all) {
      if (row.parentId) {
        const list = childrenByParent.get(row.parentId) ?? []
        list.push(row)
        childrenByParent.set(row.parentId, list)
      }
    }
    const stack = [...(childrenByParent.get(itemId) ?? [])]
    while (stack.length) {
      const current = stack.pop()!
      const nextDepth = current.depth + depthDelta
      assertDepthAllowed(nextDepth)
      await db.update(menuItems).set({ depth: nextDepth, updatedAtMs: ts }).where(eq(menuItems.id, current.id))
      stack.push(...(childrenByParent.get(current.id) ?? []))
    }
  }

  return rowToItem({ ...item, parentId: newParentId, depth: newDepth, position: clamped, updatedAtMs: ts })
}

export async function reorderMenuItems(
  db: Querier<MenusSchema>,
  actor: Actor,
  menuId: string,
  parentId: string | null,
  orderedIds: string[],
): Promise<void> {
  assertCanManageMenus(actor)
  const siblings = await getSiblings(db, menuId, parentId)
  const siblingIds = new Set(siblings.map((s) => s.id))
  if (
    orderedIds.length !== siblingIds.size ||
    new Set(orderedIds).size !== orderedIds.length
  ) {
    throw new MenuValidationError('orderedIds', 'must include every sibling exactly once')
  }
  for (const id of orderedIds) {
    if (!siblingIds.has(id)) throw new MenuValidationError('orderedIds', 'unknown sibling id')
  }
  await resequenceSiblings(db, menuId, parentId, orderedIds)
}
