import { Router, type IRouter } from "express";
import bcrypt from "bcryptjs";
import crypto from "node:crypto";
import { and, eq, or, isNull, gt } from "drizzle-orm";
import { db } from "@workspace/db";
import {
  usersTable,
  userRolesTable,
  rolesTable,
  appSettingsTable,
  passwordResetTokensTable,
  groupsTable,
  userGroupsTable,
  auditLogsTable,
  pushSubscriptionsTable,
} from "@workspace/db";
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
import { saveSession, destroySession } from "../lib/session";
import { logger } from "../lib/logger";
import {
  loginLimiters,
  registerLimiters,
  forgotPasswordLimiters,
  resetPasswordLimiters,
} from "../lib/authRateLimit";
import {
  RegisterBody,
  LoginBody,
  UpdateLanguageBody,
  UpdateClockStyleBody,
  UpdateClockHour12Body,
  UpdateNotificationPreferencesBody,
  ForgotPasswordBody,
  ResetPasswordBody,
  VerifyResetTokenBody,
} from "@workspace/api-zod";

const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;

function hashToken(token: string) {
  return crypto.createHash("sha256").update(token).digest("hex");
}

const router: IRouter = Router();

async function getUserGroupsForAuth(
  userId: number,
): Promise<Array<{ id: number; name: string }>> {
  const rows = await db
    .select({ id: groupsTable.id, name: groupsTable.name })
    .from(userGroupsTable)
    .innerJoin(groupsTable, eq(groupsTable.id, userGroupsTable.groupId))
    .where(eq(userGroupsTable.userId, userId));
  return rows;
}

function buildAuthUser(
  user: typeof usersTable.$inferSelect,
  roles: string[],
  groups: Array<{ id: number; name: string }>,
) {
  return {
    id: user.id,
    username: user.username,
    email: user.email,
    displayNameAr: user.displayNameAr,
    displayNameEn: user.displayNameEn,
    preferredLanguage: user.preferredLanguage,
    clockStyle: user.clockStyle,
    clockHour12: user.clockHour12,
    avatarUrl: user.avatarUrl,
    isActive: user.isActive,
    notificationSoundOrder: user.notificationSoundOrder,
    notificationSoundMeeting: user.notificationSoundMeeting,
    notificationSoundNote: user.notificationSoundNote,
    notifyOrdersEnabled: user.notifyOrdersEnabled,
    notifyMeetingsEnabled: user.notifyMeetingsEnabled,
    notifyNotesEnabled: user.notifyNotesEnabled,
    vibrationEnabledOrder: user.vibrationEnabledOrder,
    vibrationEnabledMeeting: user.vibrationEnabledMeeting,
    vibrationEnabledNote: user.vibrationEnabledNote,
    notificationsMuted: user.notificationsMuted,
    roles,
    groups,
    createdAt: user.createdAt,
  };
}

router.post("/auth/register", registerLimiters, async (req, res): Promise<void> => {
  const parsed = RegisterBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  const [settings] = await db.select().from(appSettingsTable).where(eq(appSettingsTable.id, 1));
  if (settings && !settings.registrationOpen) {
    res.status(403).json({ error: "Registration is closed" });
    return;
  }

  const { username, email, password, displayNameAr, displayNameEn, preferredLanguage } = parsed.data;

  const existing = await db
    .select()
    .from(usersTable)
    .where(eq(usersTable.username, username));

  if (existing.length > 0) {
    res.status(400).json({ error: "Username already taken" });
    return;
  }

  const passwordHash = await bcrypt.hash(password, 10);

  const [user] = await db
    .insert(usersTable)
    .values({
      username,
      email,
      passwordHash,
      displayNameAr: displayNameAr ?? null,
      displayNameEn: displayNameEn ?? null,
      preferredLanguage: preferredLanguage ?? "ar",
    })
    .returning();

  const [userRole] = await db.select().from(rolesTable).where(eq(rolesTable.name, "user"));
  if (userRole) {
    await db.insert(userRolesTable).values({ userId: user.id, roleId: userRole.id });
  }

  // Auto-assign the "Everyone" system group for consistent default linkage
  const [everyoneGroup] = await db
    .select()
    .from(groupsTable)
    .where(eq(groupsTable.name, "Everyone"));
  if (everyoneGroup) {
    await db
      .insert(userGroupsTable)
      .values({ userId: user.id, groupId: everyoneGroup.id })
      .onConflictDoNothing();
  }

  req.session.userId = user.id;
  await saveSession(req);
  const roles = await getUserRoles(user.id);
  res.status(201).json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
});

router.post("/auth/login", loginLimiters, async (req, res): Promise<void> => {
  const parsed = LoginBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  const { username, password } = parsed.data;

  const [user] = await db
    .select()
    .from(usersTable)
    .where(eq(usersTable.username, username));

  if (!user) {
    res.status(401).json({ error: "Invalid credentials" });
    return;
  }

  const valid = await bcrypt.compare(password, user.passwordHash);
  if (!valid) {
    res.status(401).json({ error: "Invalid credentials" });
    return;
  }

  if (!user.isActive) {
    res.status(401).json({ error: "Account is inactive" });
    return;
  }

  req.session.userId = user.id;
  await saveSession(req);
  const roles = await getUserRoles(user.id);
  res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
});

router.post("/auth/forgot-password", forgotPasswordLimiters, async (req, res): Promise<void> => {
  const parsed = ForgotPasswordBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  const identifier = parsed.data.identifier.trim();
  const [user] = await db
    .select()
    .from(usersTable)
    .where(or(eq(usersTable.username, identifier), eq(usersTable.email, identifier)));

  if (user && user.isActive) {
    const rawToken = crypto.randomBytes(32).toString("hex");
    const tokenHash = hashToken(rawToken);
    const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS);

    await db.insert(passwordResetTokensTable).values({
      userId: user.id,
      tokenHash,
      expiresAt,
    });

    // No email integration is wired up in this project. The token is intentionally
    // NOT returned to the client — exposing it would defeat the purpose of the
    // reset flow. An admin can fetch the most recent token from the
    // `password_reset_tokens` table to manually deliver the link until an email
    // service is configured. See follow-up task to add email delivery.
    console.log(
      `[auth] Password reset requested for user id=${user.id}; ` +
        `token expires at ${expiresAt.toISOString()}.`,
    );
  }

  res.json({ success: true });
});

router.post("/auth/reset-password/verify", resetPasswordLimiters, async (req, res): Promise<void> => {
  const parsed = VerifyResetTokenBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  const tokenHash = hashToken(parsed.data.token);
  const [record] = await db
    .select()
    .from(passwordResetTokensTable)
    .where(
      and(
        eq(passwordResetTokensTable.tokenHash, tokenHash),
        isNull(passwordResetTokensTable.usedAt),
        gt(passwordResetTokensTable.expiresAt, new Date()),
      ),
    );

  res.json({ valid: Boolean(record) });
});

router.post("/auth/reset-password", resetPasswordLimiters, async (req, res): Promise<void> => {
  const parsed = ResetPasswordBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  const tokenHash = hashToken(parsed.data.token);
  const [record] = await db
    .select()
    .from(passwordResetTokensTable)
    .where(
      and(
        eq(passwordResetTokensTable.tokenHash, tokenHash),
        isNull(passwordResetTokensTable.usedAt),
        gt(passwordResetTokensTable.expiresAt, new Date()),
      ),
    );

  if (!record) {
    res.status(400).json({ error: "Invalid or expired token" });
    return;
  }

  const passwordHash = await bcrypt.hash(parsed.data.newPassword, 10);

  const now = new Date();

  await db
    .update(usersTable)
    .set({ passwordHash })
    .where(eq(usersTable.id, record.userId));

  // Mark this token used and invalidate any other outstanding reset
  // tokens for the same user so they cannot be replayed.
  await db
    .update(passwordResetTokensTable)
    .set({ usedAt: now })
    .where(
      and(
        eq(passwordResetTokensTable.userId, record.userId),
        isNull(passwordResetTokensTable.usedAt),
      ),
    );

  res.json({ success: true });
});

router.post(
  "/auth/admin/users/:id/issue-reset-link",
  requireAdmin,
  async (req, res): Promise<void> => {
    const userId = Number(req.params.id);
    if (!Number.isInteger(userId)) {
      res.status(400).json({ error: "Invalid user id" });
      return;
    }

    const [user] = await db
      .select()
      .from(usersTable)
      .where(eq(usersTable.id, userId));

    if (!user) {
      res.status(404).json({ error: "User not found" });
      return;
    }

    // Invalidate any outstanding reset tokens for this user so only
    // the newly issued link is usable.
    await db
      .update(passwordResetTokensTable)
      .set({ usedAt: new Date() })
      .where(
        and(
          eq(passwordResetTokensTable.userId, user.id),
          isNull(passwordResetTokensTable.usedAt),
        ),
      );

    const rawToken = crypto.randomBytes(32).toString("hex");
    const tokenHash = hashToken(rawToken);
    const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS);

    await db.insert(passwordResetTokensTable).values({
      userId: user.id,
      tokenHash,
      expiresAt,
    });

    const origin =
      (req.headers["origin"] as string | undefined) ??
      `${req.protocol}://${req.get("host")}`;
    const resetUrl = `${origin.replace(/\/$/, "")}/reset-password?token=${rawToken}`;

    await db.insert(auditLogsTable).values({
      actorUserId: req.session.userId ?? null,
      action: "auth.issue_reset_link",
      targetType: "user",
      targetId: user.id,
      metadata: {
        username: user.username,
        email: user.email,
        expiresAt: expiresAt.toISOString(),
      },
    });

    res.json({ resetUrl, expiresAt: expiresAt.toISOString() });
  },
);

router.post("/auth/logout", async (req, res): Promise<void> => {
  // #626: drop every Web Push subscription this user owns before
  // tearing down the session. Otherwise the scheduler keeps firing
  // pushes to the iPad after sign-out (the rows live independently of
  // the session table, keyed by user_id). The client also tries to
  // unsubscribe its own browser endpoint, but that fails closed if the
  // SW or network hiccups — this server-side wipe is the authoritative
  // stop. Pruning every endpoint (not just the caller's) is the
  // correct behaviour for a single-user-per-account product: signing
  // out on any device implies "I don't want notifications anywhere
  // until I sign back in".
  const userId = req.session.userId;
  if (typeof userId === "number" && Number.isInteger(userId) && userId > 0) {
    try {
      await db
        .delete(pushSubscriptionsTable)
        .where(eq(pushSubscriptionsTable.userId, userId));
    } catch (err) {
      // Best-effort: a failed delete must not block sign-out. Log so
      // an operator investigating "I logged out but still got a push"
      // can correlate the leaked row to a real DB failure instead of
      // chasing a silent regression. The scheduler also prunes
      // 404/410 endpoints on send, so a leaked row eventually
      // self-cleans the next time it's used (worst case: one stale
      // ring until the OS-level subscription expires).
      logger.warn(
        { err, userId },
        "Failed to delete push_subscriptions on logout — endpoint may continue receiving pushes until next 404/410 prune",
      );
    }
  }
  await destroySession(req);
  res.json({ success: true });
});

router.get("/auth/me", requireAuth, async (req, res): Promise<void> => {
  const [user] = await db
    .select()
    .from(usersTable)
    .where(eq(usersTable.id, req.session.userId!));

  if (!user) {
    res.status(401).json({ error: "User not found" });
    return;
  }

  const roles = await getUserRoles(user.id);
  res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
});

router.patch("/auth/me/language", requireAuth, async (req, res): Promise<void> => {
  const parsed = UpdateLanguageBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  const [user] = await db
    .update(usersTable)
    .set({ preferredLanguage: parsed.data.language })
    .where(eq(usersTable.id, req.session.userId!))
    .returning();

  const roles = await getUserRoles(user.id);
  res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
});

router.patch("/auth/me/clock-hour12", requireAuth, async (req, res): Promise<void> => {
  const parsed = UpdateClockHour12Body.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  const [user] = await db
    .update(usersTable)
    .set({ clockHour12: parsed.data.clockHour12 ?? null })
    .where(eq(usersTable.id, req.session.userId!))
    .returning();

  if (!user) {
    res.status(401).json({ error: "User not found" });
    return;
  }

  const roles = await getUserRoles(user.id);
  res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
});

router.patch("/auth/me/clock-style", requireAuth, async (req, res): Promise<void> => {
  const parsed = UpdateClockStyleBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }

  const [user] = await db
    .update(usersTable)
    .set({ clockStyle: parsed.data.clockStyle ?? null })
    .where(eq(usersTable.id, req.session.userId!))
    .returning();

  if (!user) {
    res.status(401).json({ error: "User not found" });
    return;
  }

  const roles = await getUserRoles(user.id);
  res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
});

router.patch("/auth/me/notification-preferences", requireAuth, async (req, res): Promise<void> => {
  const parsed = UpdateNotificationPreferencesBody.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: parsed.error.message });
    return;
  }
  const updates: Record<string, unknown> = {};
  for (const k of [
    "notificationSoundOrder",
    "notificationSoundMeeting",
    "notificationSoundNote",
    "notifyOrdersEnabled",
    "notifyMeetingsEnabled",
    "notifyNotesEnabled",
    "vibrationEnabledOrder",
    "vibrationEnabledMeeting",
    "vibrationEnabledNote",
    "notificationsMuted",
  ] as const) {
    if (parsed.data[k] !== undefined) updates[k] = parsed.data[k];
  }

  if (Object.keys(updates).length === 0) {
    const [user] = await db.select().from(usersTable).where(eq(usersTable.id, req.session.userId!));
    if (!user) {
      res.status(401).json({ error: "User not found" });
      return;
    }
    const roles = await getUserRoles(user.id);
    res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
    return;
  }

  const [user] = await db
    .update(usersTable)
    .set(updates)
    .where(eq(usersTable.id, req.session.userId!))
    .returning();

  if (!user) {
    res.status(401).json({ error: "User not found" });
    return;
  }

  const roles = await getUserRoles(user.id);
  res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
});

export default router;
