import { Prisma, type PrismaClient } from "@prisma/client";
import { PrismaPrincipalResolver } from "./permission.js";

const DEFAULT_PAGE_SIZE = 8;
const MAX_PAGE_SIZE = 10;

export interface ProjectDiscoveryItem {
  readonly projectId: string;
  readonly name: string;
  readonly breadcrumb: string;
}

export interface ProjectDiscoveryPage {
  readonly query: string;
  readonly page: number;
  readonly pageSize: number;
  readonly totalItems: number;
  readonly totalPages: number;
  readonly items: readonly ProjectDiscoveryItem[];
}

export interface ProjectFolderPage {
  readonly folderId: string | null;
  readonly parentFolderId: string | null;
  readonly breadcrumb: string;
  readonly page: number;
  readonly pageSize: number;
  readonly totalPages: number;
  readonly totalFolders: number;
  readonly totalProjects: number;
  readonly childFolders: readonly { readonly folderId: string; readonly name: string }[];
  readonly projects: readonly ProjectDiscoveryItem[];
}

interface DiscoveryCandidate extends ProjectDiscoveryItem {
  readonly updatedAt: Date;
}

export function normalizeProjectSearchQuery(value: string): string {
  return value.normalize("NFKC").toLocaleLowerCase("und").replace(/[\s_.:/\\-]+/gu, "");
}

export async function discoverBindableProjects(input: {
  readonly prisma: PrismaClient;
  readonly organizationId: string;
  readonly actorFeishuOpenId: string;
  readonly isOrgAdmin: boolean;
  readonly query: string;
  readonly page?: number | undefined;
  readonly pageSize?: number | undefined;
}): Promise<ProjectDiscoveryPage> {
  const query = input.query.trim().slice(0, 100);
  const normalizedQuery = normalizeProjectSearchQuery(query);
  const manageableIds = input.isOrgAdmin
    ? null
    : await listManageableProjectIds(input.prisma, input.organizationId, input.actorFeishuOpenId);
  const pageSize = normalizedPageSize(input.pageSize);
  const totalItems = await countSearchCandidates(
    input.prisma,
    input.organizationId,
    normalizedQuery,
    searchTokens(query),
    manageableIds,
  );
  const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
  const page = normalizedPage(input.page, totalPages);
  const items = await searchCandidates(
    input.prisma,
    input.organizationId,
    normalizedQuery,
    searchTokens(query),
    manageableIds,
    page,
    pageSize,
  );
  return { query, page, pageSize, totalItems, totalPages, items };
}

export async function browseBindableFolder(input: {
  readonly prisma: PrismaClient;
  readonly organizationId: string;
  readonly actorFeishuOpenId: string;
  readonly isOrgAdmin: boolean;
  readonly folderId: string | null;
  readonly page?: number | undefined;
  readonly pageSize?: number | undefined;
}): Promise<ProjectFolderPage> {
  const folder = input.folderId === null
    ? null
    : await input.prisma.folder.findFirst({
        where: { id: input.folderId, organizationId: input.organizationId, archivedAt: null },
        select: { id: true, parentId: true },
      });
  if (input.folderId !== null && folder === null) throw new Error(`folder not found: ${input.folderId}`);

  const manageableIds = input.isOrgAdmin
    ? null
    : await listManageableProjectIds(input.prisma, input.organizationId, input.actorFeishuOpenId);
  const inbox = input.folderId === null
    ? await input.prisma.folder.findFirst({
        where: { organizationId: input.organizationId, kind: "SYSTEM_INBOX", archivedAt: null },
        select: { id: true },
      })
    : null;
  const projectWhere: Prisma.ProjectSearchDocumentWhereInput = {
    organizationId: input.organizationId,
    ...(manageableIds === null ? {} : { projectId: { in: [...manageableIds] } }),
    project: {
      ...(input.folderId === null
        ? { OR: [{ folderId: null }, ...(inbox === null ? [] : [{ folderId: inbox.id }])] }
        : { folderId: input.folderId }),
      archivedAt: null,
      groupBindings: { none: { archivedAt: null } },
    },
  };
  const folderWhere: Prisma.FolderWhereInput = {
    organizationId: input.organizationId,
    parentId: input.folderId,
    archivedAt: null,
    ...(input.folderId === null ? { kind: { not: "SYSTEM_INBOX" } } : {}),
  };
  const [breadcrumb, totalFolders, totalProjects] = await Promise.all([
    input.folderId === null ? Promise.resolve("") : folderBreadcrumb(input.prisma, input.folderId),
    input.prisma.folder.count({ where: folderWhere }),
    input.prisma.projectSearchDocument.count({ where: projectWhere }),
  ]);
  const pageSize = normalizedPageSize(input.pageSize);
  const totalPages = Math.max(1, Math.ceil((totalFolders + totalProjects) / pageSize));
  const page = normalizedPage(input.page, totalPages);
  const offset = (page - 1) * pageSize;
  const folderSkip = Math.min(offset, totalFolders);
  const folderTake = Math.min(pageSize, Math.max(0, totalFolders - folderSkip));
  const projectSkip = Math.max(0, offset - totalFolders);
  const projectTake = pageSize - folderTake;
  const [childFolders, candidates] = await Promise.all([
    folderTake === 0 ? Promise.resolve([]) : input.prisma.folder.findMany({
      where: folderWhere,
      select: { id: true, name: true },
      orderBy: [{ sortKey: "asc" }, { name: "asc" }, { id: "asc" }],
      skip: folderSkip,
      take: folderTake,
    }),
    projectTake === 0 ? Promise.resolve([]) : input.prisma.projectSearchDocument.findMany({
      where: projectWhere,
      select: { projectId: true, name: true, breadcrumb: true },
      orderBy: [{ name: "asc" }, { projectId: "asc" }],
      skip: projectSkip,
      take: projectTake,
    }),
  ]);
  return {
    folderId: input.folderId,
    parentFolderId: folder?.parentId ?? null,
    breadcrumb,
    page,
    pageSize,
    totalPages,
    totalFolders,
    totalProjects,
    childFolders: childFolders.map((child) => ({ folderId: child.id, name: child.name })),
    projects: candidates,
  };
}

async function searchCandidates(
  prisma: PrismaClient,
  organizationId: string,
  normalizedQuery: string,
  tokens: readonly string[],
  manageableIds: readonly string[] | null,
  page: number,
  pageSize: number,
): Promise<readonly DiscoveryCandidate[]> {
  const access = accessPredicate(manageableIds);
  const offset = (page - 1) * pageSize;
  if (normalizedQuery === "") {
    return prisma.$queryRaw<DiscoveryCandidate[]>(Prisma.sql`
      SELECT d."projectId", d."name", d."breadcrumb", p."updatedAt"
      FROM "ProjectSearchDocument" d
      JOIN "Project" p ON p."id" = d."projectId"
      WHERE d."organizationId" = ${organizationId}
        AND p."archivedAt" IS NULL
        AND NOT EXISTS (
          SELECT 1 FROM "ProjectGroupBinding" b
          WHERE b."projectId" = p."id" AND b."archivedAt" IS NULL
        )
        ${access}
      ORDER BY p."updatedAt" DESC, p."id" DESC
      LIMIT ${pageSize} OFFSET ${offset}
    `);
  }
  const matches = searchPredicate(normalizedQuery, tokens);
  return prisma.$queryRaw<DiscoveryCandidate[]>(Prisma.sql`
    SELECT d."projectId", d."name", d."breadcrumb", p."updatedAt"
    FROM "ProjectSearchDocument" d
    JOIN "Project" p ON p."id" = d."projectId"
    WHERE d."organizationId" = ${organizationId}
      AND p."archivedAt" IS NULL
      AND NOT EXISTS (
        SELECT 1 FROM "ProjectGroupBinding" b
        WHERE b."projectId" = p."id" AND b."archivedAt" IS NULL
      )
      ${access}
      AND ${matches}
    ORDER BY
      CASE
        WHEN d."normalizedCode" = ${normalizedQuery} THEN 0
        WHEN d."normalizedName" = ${normalizedQuery} THEN 1
        WHEN strpos(d."normalizedCode", ${normalizedQuery}) = 1 THEN 2
        WHEN strpos(d."normalizedName", ${normalizedQuery}) = 1 THEN 3
        WHEN strpos(d."normalizedName", ${normalizedQuery}) > 0 THEN 4
        WHEN strpos(d."normalizedBreadcrumb", ${normalizedQuery}) > 0 THEN 5
        ELSE 6
      END,
      similarity(d."normalizedName", ${normalizedQuery}) DESC,
      p."updatedAt" DESC,
      p."id" ASC
    LIMIT ${pageSize} OFFSET ${offset}
  `);
}

async function countSearchCandidates(
  prisma: PrismaClient,
  organizationId: string,
  normalizedQuery: string,
  tokens: readonly string[],
  manageableIds: readonly string[] | null,
): Promise<number> {
  const access = accessPredicate(manageableIds);
  const queryPredicate = normalizedQuery === "" ? Prisma.empty : Prisma.sql`AND ${searchPredicate(normalizedQuery, tokens)}`;
  const rows = await prisma.$queryRaw<Array<{ count: number }>>(Prisma.sql`
    SELECT count(*)::int AS count
    FROM "ProjectSearchDocument" d
    JOIN "Project" p ON p."id" = d."projectId"
    WHERE d."organizationId" = ${organizationId}
      AND p."archivedAt" IS NULL
      AND NOT EXISTS (
        SELECT 1 FROM "ProjectGroupBinding" b
        WHERE b."projectId" = p."id" AND b."archivedAt" IS NULL
      )
      ${access}
      ${queryPredicate}
  `);
  return rows[0]?.count ?? 0;
}

async function listManageableProjectIds(
  prisma: PrismaClient,
  organizationId: string,
  actorFeishuOpenId: string,
): Promise<readonly string[]> {
  const resolution = await new PrismaPrincipalResolver(prisma).resolveActor(
    { feishuOpenId: actorFeishuOpenId },
    { organizationId },
  );
  const grants = await prisma.permissionGrant.findMany({
    where: {
      resourceType: "PROJECT",
      role: "MANAGE",
      revokedAt: null,
      OR: resolution.principals.map((principal) => ({
        principalType: principal.type,
        principalId: principal.id,
      })),
    },
    select: { resourceId: true },
  });
  const projectIds = [...new Set(grants.map((grant) => grant.resourceId))];
  if (projectIds.length === 0) return [];
  const projects = await prisma.project.findMany({
    where: { id: { in: projectIds }, organizationId, archivedAt: null },
    select: { id: true },
  });
  return projects.map((project) => project.id);
}

function searchPredicate(normalizedQuery: string, tokens: readonly string[]): Prisma.Sql {
  const tokenMatch = tokens.length <= 1
    ? Prisma.sql`FALSE`
    : Prisma.sql`(${Prisma.join(tokens.map((token) => Prisma.sql`strpos(d."normalizedSearchText", ${token}) > 0`), " AND ")})`;
  return Prisma.sql`(
    strpos(d."normalizedCode", ${normalizedQuery}) > 0
    OR strpos(d."normalizedName", ${normalizedQuery}) > 0
    OR strpos(d."normalizedBreadcrumb", ${normalizedQuery}) > 0
    OR d."normalizedName" % ${normalizedQuery}
    OR ${tokenMatch}
  )`;
}

function accessPredicate(manageableIds: readonly string[] | null): Prisma.Sql {
  if (manageableIds === null) return Prisma.empty;
  if (manageableIds.length === 0) return Prisma.sql`AND FALSE`;
  return Prisma.sql`AND p."id" IN (${Prisma.join(manageableIds)})`;
}

function searchTokens(query: string): readonly string[] {
  return query.normalize("NFKC").trim().split(/\s+/u).map(normalizeProjectSearchQuery).filter(Boolean);
}

function normalizedPageSize(value = DEFAULT_PAGE_SIZE): number {
  return Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(value)));
}

function normalizedPage(value: number | undefined, totalPages: number): number {
  return Math.min(totalPages, Math.max(1, Math.trunc(value ?? 1)));
}

async function folderBreadcrumb(prisma: PrismaClient, folderId: string): Promise<string> {
  const rows = await prisma.$queryRaw<Array<{ breadcrumb: string }>>(Prisma.sql`
    SELECT cph_folder_breadcrumb(${folderId}) AS breadcrumb
  `);
  const breadcrumb = rows[0]?.breadcrumb;
  if (breadcrumb === undefined) throw new Error(`failed to resolve folder breadcrumb: ${folderId}`);
  return breadcrumb;
}
