import { prisma, Prisma } from "@capakraken/db";
import { resolvePermissions, PermissionKey, SystemRole } from "@capakraken/shared";
import { initTRPC, TRPCError } from "@trpc/server";
import { Redis } from "ioredis";
import { ZodError } from "zod";
import { logger } from "./lib/logger.js";
import { assertNoDevBypassInProduction, isE2eBypassActive } from "./lib/runtime-security.js";
import { loggingMiddleware } from "./middleware/logging.js";
import { apiRateLimiter } from "./middleware/rate-limit.js";

// Minimal Session type to avoid next-auth peer-dep in this package
interface Session {
  user?: { email?: string | null; name?: string | null; image?: string | null } | null;
  expires: string;
}

// ─── Context ──────────────────────────────────────────────────────────────────

export interface TRPCContext {
  session: Session | null;
  db: typeof prisma;
  dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
  roleDefaults: Record<string, PermissionKey[]> | null;
  requestId?: string;
  /** Client IP extracted from X-Forwarded-For / X-Real-IP. Null if trust-proxy is off or header absent. */
  clientIp: string | null;
}

// Cache role defaults for 10 seconds. Short TTL is the fail-safe in case the
// Redis pub/sub invalidation below is down — even without cross-node
// invalidation the staleness window is bounded to 10 s for any revocation.
let _roleDefaultsCache: Record<string, PermissionKey[]> | null = null;
let _roleDefaultsCacheTime = 0;
const ROLE_DEFAULTS_TTL = 10_000;

// ─── Cross-instance cache invalidation via Redis pub/sub ──────────────────────
// Without this, `invalidateRoleDefaultsCache()` only clears the in-memory cache
// on the node that invoked it. Other nodes keep serving stale permissions for
// up to ROLE_DEFAULTS_TTL after a revocation, which is a real RBAC risk in
// multi-instance deployments (admin demotion, permission-override removal).
//
// We publish a single invalidate message per change; every node subscribes and
// clears its local cache on receipt. Failure to publish/subscribe is logged
// but never thrown — the TTL above is the fall-back.
const RBAC_INVALIDATE_CHANNEL = "capakraken:rbac-invalidate";

let _rbacPublisher: Redis | null = null;
let _rbacSubscriber: Redis | null = null;
let _rbacSubscriberInitialized = false;

function rbacRedisUrl(): string | null {
  return process.env["REDIS_URL"] ?? null;
}

function getRbacPublisher(): Redis | null {
  const url = rbacRedisUrl();
  if (!url) return null;
  if (!_rbacPublisher) {
    try {
      _rbacPublisher = new Redis(url, { lazyConnect: false, enableReadyCheck: false });
      _rbacPublisher.on("error", (err: unknown) => {
        logger.warn({ err, channel: RBAC_INVALIDATE_CHANNEL }, "RBAC Redis publisher error");
      });
    } catch (err) {
      logger.warn(
        { err },
        "RBAC Redis publisher init failed; cache invalidation will be local-only",
      );
      _rbacPublisher = null;
    }
  }
  return _rbacPublisher;
}

function ensureRbacSubscriber(): void {
  if (_rbacSubscriberInitialized) return;
  const url = rbacRedisUrl();
  if (!url) return;
  _rbacSubscriberInitialized = true;
  try {
    _rbacSubscriber = new Redis(url, { lazyConnect: false, enableReadyCheck: false });
    _rbacSubscriber.on("error", (err: unknown) => {
      logger.warn({ err, channel: RBAC_INVALIDATE_CHANNEL }, "RBAC Redis subscriber error");
    });
    void _rbacSubscriber.subscribe(RBAC_INVALIDATE_CHANNEL).catch((err: unknown) => {
      logger.warn({ err, channel: RBAC_INVALIDATE_CHANNEL }, "RBAC Redis subscribe failed");
    });
    _rbacSubscriber.on("message", (_channel: string, _message: string) => {
      // Any message on this channel means "someone mutated role/permission
      // state — drop our local view now". Body is ignored; the next request
      // re-reads from DB.
      _roleDefaultsCache = null;
      _roleDefaultsCacheTime = 0;
    });
  } catch (err) {
    logger.warn(
      { err },
      "RBAC Redis subscriber init failed; cache invalidation will be local-only",
    );
  }
}

export async function loadRoleDefaults(): Promise<Record<string, PermissionKey[]>> {
  // Lazy-init the peer-invalidation subscriber on first use. Doing this at
  // first call (not module load) means test files that never touch RBAC never
  // open a Redis connection, and env changes set up by specific tests are
  // observed rather than snapshotted at import time.
  ensureRbacSubscriber();

  const now = Date.now();
  if (_roleDefaultsCache && now - _roleDefaultsCacheTime < ROLE_DEFAULTS_TTL) {
    return _roleDefaultsCache;
  }
  const configs = await prisma.systemRoleConfig.findMany({
    select: { role: true, defaultPermissions: true },
  });
  const map: Record<string, PermissionKey[]> = {};
  for (const c of configs) {
    map[c.role] = c.defaultPermissions as PermissionKey[];
  }
  _roleDefaultsCache = map;
  _roleDefaultsCacheTime = now;
  return map;
}

/**
 * Invalidate the role defaults cache on every running instance.
 *
 * Clears the local cache immediately and publishes a Redis message so peer
 * instances clear theirs too. If Redis is unavailable, only the local cache
 * is cleared — the 10 s TTL caps staleness on other nodes.
 *
 * Call this after mutating SystemRoleConfig, User.systemRole, or
 * User.permissionOverrides.
 */
export function invalidateRoleDefaultsCache(): void {
  _roleDefaultsCache = null;
  _roleDefaultsCacheTime = 0;

  const pub = getRbacPublisher();
  if (!pub) return;
  void pub.publish(RBAC_INVALIDATE_CHANNEL, "1").catch((err: unknown) => {
    logger.warn(
      { err, channel: RBAC_INVALIDATE_CHANNEL },
      "RBAC invalidation publish rejected — peer instances will rely on TTL",
    );
  });
}

export function createTRPCContext(opts: {
  session: Session | null;
  dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null;
  roleDefaults?: Record<string, PermissionKey[]> | null;
  clientIp?: string | null;
}): TRPCContext {
  return {
    session: opts.session,
    db: prisma,
    dbUser: opts.dbUser ?? null,
    roleDefaults: opts.roleDefaults ?? null,
    clientIp: opts.clientIp ?? null,
  };
}

// ─── tRPC Init ───────────────────────────────────────────────────────────────

const t = initTRPC.context<TRPCContext>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

// ─── Procedures ──────────────────────────────────────────────────────────────

export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;

/**
 * Public procedure — no authentication required.
 */
export const publicProcedure = t.procedure;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const withLogging = t.middleware(loggingMiddleware as any);

/**
 * Maps a known Prisma error to a TRPCError with a safe generic message.
 * Exported for unit testing.
 */
export function mapPrismaError(error: Prisma.PrismaClientKnownRequestError): TRPCError {
  switch (error.code) {
    case "P2002": // Unique constraint
      return new TRPCError({
        code: "CONFLICT",
        message: "A record with this value already exists.",
      });
    case "P2025": // Record not found
      return new TRPCError({
        code: "NOT_FOUND",
        message: "The requested record was not found.",
      });
    case "P2003": // Foreign key constraint
      return new TRPCError({
        code: "BAD_REQUEST",
        message: "A referenced record does not exist.",
      });
    default:
      // Log the full error server-side but don't expose internals
      // eslint-disable-next-line no-console
      console.error("[Prisma error]", error.code, error.message);
      return new TRPCError({
        code: "INTERNAL_SERVER_ERROR",
        message: "A database error occurred.",
      });
  }
}

/**
 * Middleware that catches PrismaClientKnownRequestError and maps it to a clean TRPCError
 * so DB internals never reach the client.
 */
const withPrismaErrors = t.middleware(async ({ next }) => {
  try {
    return await next();
  } catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      throw mapPrismaError(error);
    }
    throw error; // re-throw non-Prisma errors unchanged
  }
});
// Fail-fast if a dev-bypass flag is left on in a production build. A warning
// is not enough — historically a refactor that drops an import can silently
// re-enable the bypass. See packages/api/src/lib/runtime-security.ts.
assertNoDevBypassInProduction();
const isE2eTestMode = isE2eBypassActive();

/**
 * Protected procedure — requires authenticated session AND a valid DB user record.
 * This prevents stale sessions from accessing data after the DB user is deleted.
 */
export const protectedProcedure = t.procedure
  .use(withPrismaErrors)
  .use(withLogging)
  .use(async ({ ctx, next }) => {
    if (!ctx.session?.user) {
      throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
    }
    if (!ctx.dbUser) {
      throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" });
    }

    // Rate limit by user ID
    if (!isE2eTestMode) {
      const rateLimitResult = await apiRateLimiter(ctx.dbUser.id);
      if (!rateLimitResult.allowed) {
        throw new TRPCError({
          code: "TOO_MANY_REQUESTS",
          message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`,
        });
      }
    }

    return next({
      ctx: {
        ...ctx,
        session: ctx.session,
        user: ctx.session.user,
        dbUser: ctx.dbUser,
      },
    });
  });

/**
 * Resource overview procedure — requires broad people-directory visibility.
 * Accepts explicit permission grants, not just elevated roles.
 */
export const resourceOverviewProcedure = protectedProcedure.use(({ ctx, next }) => {
  const user = ctx.dbUser;
  if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });

  const permissions = resolvePermissions(
    user.systemRole as SystemRole,
    user.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
    ctx.roleDefaults ?? undefined,
  );

  if (
    !permissions.has(PermissionKey.VIEW_ALL_RESOURCES) &&
    !permissions.has(PermissionKey.MANAGE_RESOURCES)
  ) {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "Resource overview access required",
    });
  }

  return next({ ctx: { ...ctx, user, permissions } });
});

/**
 * Planning read procedure — requires the explicit broad planning read audience.
 */
export const planningReadProcedure = protectedProcedure.use(({ ctx, next }) => {
  const user = ctx.dbUser;
  if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });

  const permissions = resolvePermissions(
    user.systemRole as SystemRole,
    user.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
    ctx.roleDefaults ?? undefined,
  );

  if (!permissions.has(PermissionKey.VIEW_PLANNING)) {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "Planning read access required",
    });
  }

  return next({ ctx: { ...ctx, user, permissions } });
});

/**
 * Manager procedure — requires MANAGER or ADMIN role.
 */
export const managerProcedure = protectedProcedure.use(({ ctx, next }) => {
  const user = ctx.dbUser;
  if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
  const allowedRoles: string[] = [SystemRole.ADMIN, SystemRole.MANAGER];
  if (!allowedRoles.includes(user.systemRole)) {
    throw new TRPCError({ code: "FORBIDDEN", message: "Manager or Admin role required" });
  }
  const permissions = resolvePermissions(
    user.systemRole as SystemRole,
    user.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
    ctx.roleDefaults ?? undefined,
  );
  return next({ ctx: { ...ctx, user, permissions } });
});

/**
 * Controller procedure — requires CONTROLLER, MANAGER, or ADMIN role.
 * Grants read-only access to financial and export data.
 */
export const controllerProcedure = protectedProcedure.use(({ ctx, next }) => {
  const user = ctx.dbUser;
  if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
  const allowed: SystemRole[] = [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER];
  if (!allowed.includes(user.systemRole as SystemRole)) {
    throw new TRPCError({ code: "FORBIDDEN", message: "Controller access required" });
  }
  const permissions = resolvePermissions(
    user.systemRole as SystemRole,
    user.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
    ctx.roleDefaults ?? undefined,
  );
  return next({ ctx: { ...ctx, user, permissions } });
});

/**
 * Admin procedure — requires ADMIN role only.
 */
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
  const user = ctx.dbUser;
  if (!user || user.systemRole !== SystemRole.ADMIN) {
    throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" });
  }
  const permissions = resolvePermissions(SystemRole.ADMIN, null, ctx.roleDefaults ?? undefined);
  return next({ ctx: { ...ctx, user, permissions } });
});

/**
 * requirePermission — throws FORBIDDEN if the ctx lacks the given permission.
 */
export function requirePermission(
  ctx: { permissions: Set<PermissionKey> },
  key: PermissionKey,
): void {
  if (!ctx.permissions.has(key)) {
    throw new TRPCError({ code: "FORBIDDEN", message: `Permission required: ${key}` });
  }
}
