import { Prisma } from "@capakraken/db";
import { dashboardLayoutSchema, normalizeDashboardLayout } from "@capakraken/shared/schemas";
import type { ColumnPreferences } from "@capakraken/shared/types";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import {
  BACKUP_CODE_COUNT,
  generatePlaintextBackupCodes,
  hashBackupCode,
} from "../lib/mfa-backup-codes.js";
import { consumeTotpWindow } from "../lib/totp-consume.js";
import { totpRateLimiter } from "../middleware/rate-limit.js";
import type { TRPCContext } from "../trpc.js";

export const SaveDashboardLayoutInputSchema = z.object({
  layout: dashboardLayoutSchema,
});

export const ToggleFavoriteProjectInputSchema = z.object({
  projectId: z.string(),
});

export const SetColumnPreferencesInputSchema = z.object({
  view: z.enum([
    "resources",
    "projects",
    "allocations",
    "vacations",
    "roles",
    "users",
    "blueprints",
  ]),
  visible: z.array(z.string()).optional(),
  sort: z
    .object({ field: z.string(), dir: z.enum(["asc", "desc"]) })
    .nullable()
    .optional(),
  rowOrder: z.array(z.string()).nullable().optional(),
});

export const VerifyAndEnableTotpInputSchema = z.object({
  token: z.string().length(6),
});

export const VerifyTotpInputSchema = z.object({
  userId: z.string(),
  token: z.string().length(6),
});

type UserSelfServiceContext = Pick<TRPCContext, "db" | "dbUser" | "session">;
type UserPublicContext = Pick<TRPCContext, "db" | "clientIp">;

export async function getCurrentUserProfile(ctx: UserSelfServiceContext) {
  return findUniqueOrThrow(
    ctx.db.user.findUnique({
      where: { id: ctx.dbUser!.id },
      select: {
        id: true,
        name: true,
        email: true,
        systemRole: true,
        permissionOverrides: true,
        createdAt: true,
      },
    }),
    "User",
  );
}

export async function getDashboardLayout(ctx: UserSelfServiceContext) {
  const user = await ctx.db.user.findUnique({
    where: { id: ctx.dbUser!.id },
    select: { dashboardLayout: true, updatedAt: true },
  });

  const normalized = user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null;
  return {
    layout: normalized?.widgets.length ? normalized : null,
    updatedAt: user?.updatedAt ?? null,
  };
}

export async function saveDashboardLayout(
  ctx: UserSelfServiceContext,
  input: z.infer<typeof SaveDashboardLayoutInputSchema>,
) {
  const updated = await ctx.db.user.update({
    where: { id: ctx.dbUser!.id },
    data: { dashboardLayout: input.layout as unknown as Prisma.InputJsonValue },
    select: { updatedAt: true },
  });

  return { updatedAt: updated.updatedAt };
}

export async function getFavoriteProjectIds(ctx: UserSelfServiceContext) {
  const user = await ctx.db.user.findUnique({
    where: { id: ctx.dbUser!.id },
    select: { favoriteProjectIds: true },
  });

  return ((user?.favoriteProjectIds as string[] | null) ?? []) as string[];
}

export async function toggleFavoriteProject(
  ctx: UserSelfServiceContext,
  input: z.infer<typeof ToggleFavoriteProjectInputSchema>,
) {
  const user = await ctx.db.user.findUnique({
    where: { id: ctx.dbUser!.id },
    select: { favoriteProjectIds: true },
  });
  const current = ((user?.favoriteProjectIds as string[] | null) ?? []) as string[];
  const next = current.includes(input.projectId)
    ? current.filter((id) => id !== input.projectId)
    : [...current, input.projectId];

  await ctx.db.user.update({
    where: { id: ctx.dbUser!.id },
    data: { favoriteProjectIds: next as unknown as Prisma.InputJsonValue },
  });

  return { favoriteProjectIds: next, added: !current.includes(input.projectId) };
}

export async function getColumnPreferences(ctx: UserSelfServiceContext) {
  const user = await ctx.db.user.findUnique({
    where: { id: ctx.dbUser!.id },
    select: { columnPreferences: true },
  });

  return (user?.columnPreferences ?? {}) as ColumnPreferences;
}

export async function setColumnPreferences(
  ctx: UserSelfServiceContext,
  input: z.infer<typeof SetColumnPreferencesInputSchema>,
) {
  const existing = await ctx.db.user.findUnique({
    where: { id: ctx.dbUser!.id },
    select: { columnPreferences: true },
  });
  const prefs = (existing?.columnPreferences ?? {}) as ColumnPreferences;
  const prev = (prefs[input.view] as import("@capakraken/shared").ViewPreferences | undefined) ?? {
    visible: [],
  };

  const merged: import("@capakraken/shared").ViewPreferences = {
    visible: input.visible ?? prev.visible,
  };
  if (input.sort !== null && input.sort !== undefined) {
    merged.sort = input.sort;
  } else if (input.sort === undefined && prev.sort != null) {
    merged.sort = prev.sort;
  }
  if (input.rowOrder !== null && input.rowOrder !== undefined) {
    merged.rowOrder = input.rowOrder;
  } else if (input.rowOrder === undefined && prev.rowOrder != null) {
    merged.rowOrder = prev.rowOrder;
  }

  prefs[input.view] = merged;
  await ctx.db.user.update({
    where: { id: ctx.dbUser!.id },
    data: { columnPreferences: prefs as Prisma.InputJsonValue },
  });

  return { ok: true };
}

export async function generateTotpSecret(ctx: UserSelfServiceContext) {
  const { TOTP, Secret } = await import("otpauth");
  const secret = new Secret({ size: 20 });
  const totp = new TOTP({
    issuer: "CapaKraken",
    label: ctx.session?.user?.email ?? ctx.dbUser!.id,
    algorithm: "SHA1",
    digits: 6,
    period: 30,
    secret,
  });

  await ctx.db.user.update({
    where: { id: ctx.dbUser!.id },
    data: { totpSecret: secret.base32 },
  });

  return { secret: secret.base32, uri: totp.toString() };
}

export async function verifyAndEnableTotp(
  ctx: UserSelfServiceContext,
  input: z.infer<typeof VerifyAndEnableTotpInputSchema>,
) {
  const user = await findUniqueOrThrow(
    ctx.db.user.findUnique({
      where: { id: ctx.dbUser!.id },
      select: {
        id: true,
        name: true,
        email: true,
        totpSecret: true,
        totpEnabled: true,
        lastTotpAt: true,
      },
    }) as Promise<{
      id: string;
      name: string | null;
      email: string;
      totpSecret: string | null;
      totpEnabled: boolean;
      lastTotpAt: Date | null;
    } | null>,
    "User",
  );

  if (!user.totpSecret) {
    throw new TRPCError({
      code: "BAD_REQUEST",
      message: "No TOTP secret generated. Call generateTotpSecret first.",
    });
  }
  if (user.totpEnabled) {
    throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is already enabled." });
  }

  const { TOTP, Secret } = await import("otpauth");
  const totp = new TOTP({
    issuer: "CapaKraken",
    label: user.email,
    algorithm: "SHA1",
    digits: 6,
    period: 30,
    secret: Secret.fromBase32(user.totpSecret),
  });

  const delta = totp.validate({ token: input.token, window: 1 });
  if (delta === null) {
    throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid TOTP token." });
  }

  // Atomic replay-guard: single UPDATE with WHERE-guard on lastTotpAt. See
  // packages/api/src/lib/totp-consume.ts for rationale.
  const accepted = await consumeTotpWindow(ctx.db, user.id);
  if (!accepted) {
    throw new TRPCError({
      code: "BAD_REQUEST",
      message: "TOTP code already used. Wait for the next code.",
    });
  }

  await (ctx.db.user.update as Function)({
    where: { id: user.id },
    data: { totpEnabled: true },
  });

  // Issue the initial backup-code set as part of the enable flow. Doing
  // this here (vs making it a separate opt-in step) avoids the common
  // footgun of users enabling MFA, losing their device, and being locked
  // out — one of the explicit motivations for #43 part 2.
  const plaintexts = generatePlaintextBackupCodes(BACKUP_CODE_COUNT);
  const hashes = await Promise.all(plaintexts.map((p) => hashBackupCode(p)));
  await ctx.db.$transaction([
    (ctx.db as unknown as { mfaBackupCode: { deleteMany: Function } }).mfaBackupCode.deleteMany({
      where: { userId: user.id },
    }),
    (ctx.db as unknown as { mfaBackupCode: { createMany: Function } }).mfaBackupCode.createMany({
      data: hashes.map((codeHash) => ({ userId: user.id, codeHash })),
    }),
  ]);

  void createAuditEntry({
    db: ctx.db,
    entityType: "User",
    entityId: user.id,
    entityName: `${user.name} (${user.email})`,
    action: "UPDATE",
    userId: user.id,
    source: "ui",
    summary: "Enabled TOTP MFA",
  });

  return { enabled: true, backupCodes: plaintexts };
}

export async function verifyTotp(
  ctx: UserPublicContext,
  input: z.infer<typeof VerifyTotpInputSchema>,
) {
  // Rate limit keyed on BOTH userId and source IP. userId-only keying
  // permits targeted user-lockout DoS; IP-only permits botnet bypass.
  // Both buckets must allow for the attempt to proceed (CWE-307, A01-1).
  const ipKey = ctx.clientIp ? `ip:${ctx.clientIp}` : "";
  const totpKeys = ipKey ? [`user:${input.userId}`, ipKey] : [`user:${input.userId}`];
  const rl = await totpRateLimiter(totpKeys);
  if (!rl.allowed) {
    throw new TRPCError({
      code: "TOO_MANY_REQUESTS",
      message: "Too many TOTP attempts. Please wait before trying again.",
    });
  }

  const user = (await ctx.db.user.findUnique({
    where: { id: input.userId },
    select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
  })) as {
    id: string;
    totpSecret: string | null;
    totpEnabled: boolean;
    lastTotpAt: Date | null;
  } | null;

  // Generic error for both not-found and TOTP-not-enabled to prevent user enumeration
  if (!user || !user.totpEnabled || !user.totpSecret) {
    throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
  }

  const { TOTP, Secret } = await import("otpauth");
  const totp = new TOTP({
    issuer: "CapaKraken",
    label: user.id,
    algorithm: "SHA1",
    digits: 6,
    period: 30,
    secret: Secret.fromBase32(user.totpSecret),
  });

  const delta = totp.validate({ token: input.token, window: 1 });
  if (delta === null) {
    throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
  }

  // Atomic replay-guard — see packages/api/src/lib/totp-consume.ts.
  const accepted = await consumeTotpWindow(ctx.db, user.id);
  if (!accepted) {
    throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
  }

  return { valid: true };
}

export async function getCurrentMfaStatus(ctx: UserSelfServiceContext) {
  const user = await findUniqueOrThrow(
    ctx.db.user.findUnique({
      where: { id: ctx.dbUser!.id },
      select: { totpEnabled: true },
    }),
    "User",
  );

  const backupCodesRemaining = user.totpEnabled
    ? await (
        ctx.db as unknown as {
          mfaBackupCode: {
            count: (args: { where: { userId: string; usedAt: null } }) => Promise<number>;
          };
        }
      ).mfaBackupCode.count({
        where: { userId: ctx.dbUser!.id, usedAt: null },
      })
    : 0;

  return { totpEnabled: user.totpEnabled, backupCodesRemaining };
}

// Generate (or regenerate) a user's backup-code set. Returns the plaintext
// codes exactly once — the caller MUST display them immediately; there is
// no re-display endpoint. Regeneration wipes the previous set atomically
// (deleteMany + createMany in a transaction), so a partially-regenerated
// state — some old codes still valid, some new codes issued — is not
// observable to either the user or an attacker.
//
// Requires TOTP to already be enabled: the codes are a *backup* for an
// existing second factor, not a way to bootstrap MFA.
export async function regenerateBackupCodes(ctx: UserSelfServiceContext) {
  const user = await findUniqueOrThrow(
    ctx.db.user.findUnique({
      where: { id: ctx.dbUser!.id },
      select: { id: true, name: true, email: true, totpEnabled: true },
    }),
    "User",
  );
  if (!user.totpEnabled) {
    throw new TRPCError({
      code: "BAD_REQUEST",
      message: "Enable TOTP before generating backup codes.",
    });
  }

  const plaintexts = generatePlaintextBackupCodes(BACKUP_CODE_COUNT);
  const hashes = await Promise.all(plaintexts.map((p) => hashBackupCode(p)));

  // Transaction guarantees all-or-nothing replacement: a failure after
  // deleteMany but before createMany would otherwise leave the user with
  // zero backup codes and a UI that thinks they have 10.
  await ctx.db.$transaction([
    (ctx.db as unknown as { mfaBackupCode: { deleteMany: Function } }).mfaBackupCode.deleteMany({
      where: { userId: user.id },
    }),
    (ctx.db as unknown as { mfaBackupCode: { createMany: Function } }).mfaBackupCode.createMany({
      data: hashes.map((codeHash) => ({ userId: user.id, codeHash })),
    }),
  ]);

  void createAuditEntry({
    db: ctx.db,
    entityType: "User",
    entityId: user.id,
    entityName: `${user.name} (${user.email})`,
    action: "UPDATE",
    userId: user.id,
    source: "ui",
    summary: "Regenerated MFA backup codes",
  });

  return { codes: plaintexts, count: plaintexts.length };
}
