import { createHash, randomBytes } from "node:crypto";
import { and, eq, gt, ne } from "drizzle-orm";
import { compare } from "bcryptjs";
import {
  getAppOriginUrl,
  normalizeHostname,
  shouldUseStudioPathPreview,
} from "@/core/config/domains";
import { getDb } from "@/core/db/client";
import { users, verificationTokens } from "@/core/db/schema";
import {
  assertEmailChangeAllowed,
  recordEmailChangeAttempt,
} from "@/core/auth/email-change-throttle";
import { sendMail } from "@/services/mail-service";

const EMAIL_CHANGE_TTL_MS = 60 * 60 * 1000; // 1 hour
const TOKEN_PREFIX = "email-change";

export type AccountProfile = {
  id: string;
  name: string;
  email: string;
  pendingEmail: string | null;
  phone: string | null;
  emailVerifiedAt: Date | null;
  status: string;
};

export class AccountError extends Error {
  code:
    | "NOT_FOUND"
    | "INVALID_PASSWORD"
    | "EMAIL_TAKEN"
    | "SAME_EMAIL"
    | "RATE_LIMITED"
    | "INVALID_TOKEN"
    | "EXPIRED_TOKEN"
    | "NO_PENDING";

  retryAfterSec?: number;

  constructor(
    code: AccountError["code"],
    message: string,
    retryAfterSec?: number,
  ) {
    super(message);
    this.name = "AccountError";
    this.code = code;
    this.retryAfterSec = retryAfterSec;
  }
}

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

function emailChangeIdentifier(userId: string): string {
  return `${TOKEN_PREFIX}:${userId}`;
}

function buildVerifyUrl(
  rawToken: string,
  userId: string,
  requestHost?: string | null,
) {
  const origin = getAppOriginUrl();
  const url = new URL("/verify-email", origin);

  // Prefer LAN IP when the dashboard was opened that way so phones can open
  // the link. Never use tenant subdomains — proxy rewrites those into /site.
  if (requestHost && shouldUseStudioPathPreview(requestHost)) {
    url.host = normalizeHostname(requestHost);
  }

  url.searchParams.set("token", rawToken);
  url.searchParams.set("uid", userId);
  return url.toString();
}

export async function getAccountProfile(
  userId: string,
): Promise<AccountProfile | null> {
  const db = getDb();
  const [row] = await db
    .select({
      id: users.id,
      name: users.name,
      email: users.email,
      pendingEmail: users.pendingEmail,
      phone: users.phone,
      emailVerifiedAt: users.emailVerifiedAt,
      status: users.status,
    })
    .from(users)
    .where(eq(users.id, userId))
    .limit(1);

  return row ?? null;
}

export async function updateAccountProfile(
  userId: string,
  input: { name: string; phone: string },
): Promise<AccountProfile> {
  const db = getDb();
  const phone = input.phone.trim() || null;

  const [row] = await db
    .update(users)
    .set({
      name: input.name.trim(),
      phone,
      updatedAt: new Date(),
    })
    .where(eq(users.id, userId))
    .returning({
      id: users.id,
      name: users.name,
      email: users.email,
      pendingEmail: users.pendingEmail,
      phone: users.phone,
      emailVerifiedAt: users.emailVerifiedAt,
      status: users.status,
    });

  if (!row) {
    throw new AccountError("NOT_FOUND", "Account not found");
  }

  return row;
}

async function clearEmailChangeTokens(userId: string) {
  const db = getDb();
  await db
    .delete(verificationTokens)
    .where(eq(verificationTokens.identifier, emailChangeIdentifier(userId)));
}

async function issueEmailChangeToken(
  userId: string,
  pendingEmail: string,
  requestHost?: string | null,
): Promise<string> {
  const db = getDb();
  const raw = randomBytes(32).toString("base64url");
  const tokenHash = hashToken(raw);
  const expires = new Date(Date.now() + EMAIL_CHANGE_TTL_MS);

  await clearEmailChangeTokens(userId);
  await db.insert(verificationTokens).values({
    identifier: emailChangeIdentifier(userId),
    token: tokenHash,
    expires,
  });

  // Store pending email on the user; login email stays until verified.
  await db
    .update(users)
    .set({
      pendingEmail,
      updatedAt: new Date(),
    })
    .where(eq(users.id, userId));

  const verifyUrl = buildVerifyUrl(raw, userId, requestHost);
  await sendMail({
    to: pendingEmail,
    subject: "Confirm your new ARCHI email",
    text: [
      "Confirm your new login email for ARCHI.",
      "",
      `Open this link within 1 hour:`,
      verifyUrl,
      "",
      "If you did not request this change, you can ignore this message.",
    ].join("\n"),
  });

  return verifyUrl;
}

export async function requestEmailChange(
  userId: string,
  input: { newEmail: string; currentPassword: string },
  options?: { requestHost?: string | null },
): Promise<{ pendingEmail: string }> {
  const throttleKey = `email-change:${userId}`;
  const allowed = assertEmailChangeAllowed(throttleKey);
  if (!allowed.ok) {
    throw new AccountError(
      "RATE_LIMITED",
      `Too many email change requests. Try again in ${allowed.retryAfterSec}s.`,
      allowed.retryAfterSec,
    );
  }

  const db = getDb();
  const newEmail = input.newEmail.trim().toLowerCase();

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

  if (!user || !user.passwordHash) {
    throw new AccountError("NOT_FOUND", "Account not found");
  }

  if (newEmail === user.email.toLowerCase()) {
    throw new AccountError(
      "SAME_EMAIL",
      "That is already your current login email",
    );
  }

  const passwordOk = await compare(input.currentPassword, user.passwordHash);
  if (!passwordOk) {
    recordEmailChangeAttempt(throttleKey);
    throw new AccountError("INVALID_PASSWORD", "Current password is incorrect");
  }

  const [taken] = await db
    .select({ id: users.id })
    .from(users)
    .where(and(eq(users.email, newEmail), ne(users.id, userId)))
    .limit(1);

  if (taken) {
    throw new AccountError("EMAIL_TAKEN", "That email is already in use");
  }

  // Also block if another user already has this pending
  const [pendingTaken] = await db
    .select({ id: users.id })
    .from(users)
    .where(and(eq(users.pendingEmail, newEmail), ne(users.id, userId)))
    .limit(1);

  if (pendingTaken) {
    throw new AccountError("EMAIL_TAKEN", "That email is already in use");
  }

  recordEmailChangeAttempt(throttleKey);
  await issueEmailChangeToken(userId, newEmail, options?.requestHost);

  return { pendingEmail: newEmail };
}

export async function resendEmailChange(
  userId: string,
  options?: { requestHost?: string | null },
): Promise<{ pendingEmail: string }> {
  const throttleKey = `email-change:${userId}`;
  const allowed = assertEmailChangeAllowed(throttleKey);
  if (!allowed.ok) {
    throw new AccountError(
      "RATE_LIMITED",
      `Too many email change requests. Try again in ${allowed.retryAfterSec}s.`,
      allowed.retryAfterSec,
    );
  }

  const account = await getAccountProfile(userId);
  if (!account) {
    throw new AccountError("NOT_FOUND", "Account not found");
  }
  if (!account.pendingEmail) {
    throw new AccountError("NO_PENDING", "No pending email change to resend");
  }

  recordEmailChangeAttempt(throttleKey);
  await issueEmailChangeToken(
    userId,
    account.pendingEmail,
    options?.requestHost,
  );

  return { pendingEmail: account.pendingEmail };
}

export async function cancelEmailChange(userId: string): Promise<void> {
  const db = getDb();
  await clearEmailChangeTokens(userId);
  await db
    .update(users)
    .set({
      pendingEmail: null,
      updatedAt: new Date(),
    })
    .where(eq(users.id, userId));
}

/**
 * Consume a one-time email-change token and promote pendingEmail → email.
 * Returns the updated account on success.
 */
export async function confirmEmailChange(
  userId: string,
  rawToken: string,
): Promise<AccountProfile> {
  const db = getDb();
  const tokenHash = hashToken(rawToken);
  const now = new Date();

  const [tokenRow] = await db
    .select()
    .from(verificationTokens)
    .where(
      and(
        eq(verificationTokens.identifier, emailChangeIdentifier(userId)),
        eq(verificationTokens.token, tokenHash),
        gt(verificationTokens.expires, now),
      ),
    )
    .limit(1);

  if (!tokenRow) {
    // Distinguish expired vs invalid if a matching (expired) row exists
    const [any] = await db
      .select()
      .from(verificationTokens)
      .where(
        and(
          eq(verificationTokens.identifier, emailChangeIdentifier(userId)),
          eq(verificationTokens.token, tokenHash),
        ),
      )
      .limit(1);

    if (any) {
      throw new AccountError("EXPIRED_TOKEN", "This verification link has expired");
    }
    throw new AccountError("INVALID_TOKEN", "Invalid verification link");
  }

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

  if (!user?.pendingEmail) {
    throw new AccountError("NO_PENDING", "No pending email change found");
  }

  const pending = user.pendingEmail.toLowerCase();

  const [taken] = await db
    .select({ id: users.id })
    .from(users)
    .where(and(eq(users.email, pending), ne(users.id, userId)))
    .limit(1);

  if (taken) {
    throw new AccountError("EMAIL_TAKEN", "That email is already in use");
  }

  const [row] = await db
    .update(users)
    .set({
      email: pending,
      pendingEmail: null,
      emailVerifiedAt: now,
      status: user.status === "pending_verification" ? "active" : user.status,
      updatedAt: now,
    })
    .where(eq(users.id, userId))
    .returning({
      id: users.id,
      name: users.name,
      email: users.email,
      pendingEmail: users.pendingEmail,
      phone: users.phone,
      emailVerifiedAt: users.emailVerifiedAt,
      status: users.status,
    });

  await clearEmailChangeTokens(userId);

  if (!row) {
    throw new AccountError("NOT_FOUND", "Account not found");
  }

  return row;
}

/** Exported for unit tests */
export const __test = {
  hashToken,
  emailChangeIdentifier,
  EMAIL_CHANGE_TTL_MS,
};
