import { and, count, eq, isNull, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
  email_verification_tokens,
  launch_claims,
  oauth_accounts,
  password_reset_tokens,
  sessions,
  users,
  type User,
} from "@/lib/schema/db";
import { LAUNCH_CLAIM_LIMIT } from "@/lib/limits";
import { canonicaliseEmail, normaliseEmail } from "./email";
import { hashToken, issueToken, newId } from "./tokens";
import { PASSWORD_RESET_TTL_MS, VERIFICATION_TTL_MS, siteUrl } from "./config";
import { hashPassword, verifyPassword } from "./password";
import { destroyAllSessionsFor } from "./session";
import {
  sendPasswordResetEmail,
  sendVerificationEmail,
  type MailResult,
} from "./mailer";

/** Postgres unique-violation. Two simultaneous signups race here, not in JS. */
const UNIQUE_VIOLATION = "23505";

export function isUniqueViolation(error: unknown): boolean {
  return (
    typeof error === "object" &&
    error !== null &&
    (error as { code?: string }).code === UNIQUE_VIOLATION
  );
}

export async function findUserByEmail(rawEmail: string): Promise<User | null> {
  const key = normaliseEmail(rawEmail);
  if (!key) return null;

  const rows = await db.select().from(users).where(eq(users.emailKey, key)).limit(1);
  return rows[0] ?? null;
}

export async function findUserByGoogleId(sub: string): Promise<User | null> {
  const rows = await db
    .select({ user: users })
    .from(oauth_accounts)
    .innerJoin(users, eq(oauth_accounts.userId, users.id))
    .where(and(eq(oauth_accounts.provider, "google"), eq(oauth_accounts.providerAccountId, sub)))
    .limit(1);

  return rows[0]?.user ?? null;
}

export interface CreateUserInput {
  email: string;
  name?: string | null;
  passwordHash?: string | null;
  /** Google has already proven ownership of the address; local signups have not. */
  emailVerified?: boolean;
}

/**
 * Inserts a real (non-guest) account. Throws a unique violation when the
 * normalised email is already taken — callers translate that into the
 * "already registered" response rather than pre-checking, so concurrent
 * requests cannot both slip through.
 */
export async function createUser(input: CreateUserInput): Promise<User> {
  const email = canonicaliseEmail(input.email);
  const emailKey = normaliseEmail(input.email);
  if (!emailKey) throw new Error("Cannot create a user without a valid email");

  const rows = await db
    .insert(users)
    .values({
      id: newId("usr"),
      email,
      emailKey,
      name: input.name?.trim() || null,
      passwordHash: input.passwordHash ?? null,
      emailVerifiedAt: input.emailVerified ? new Date() : null,
      isGuest: false,
    })
    .returning();

  const user = rows[0];

  // Launch special: the first 500 registrations reserve a bonus slot. Only a
  // confirmed insert sets the flag, so concurrent signups cannot both win.
  const granted = await claimLaunchBonus(user.id);
  if (granted) user.launchBonus = true;

  return user;
}

/**
 * Reserviert einen Launch-Special-Slot für die ersten {@link LAUNCH_CLAIM_LIMIT}
 * Registrierungen. Atomar und race-sicher:
 *  - die `position` kommt aus einer Sequenz (keine doppelten Slots),
 *  - der `ON CONFLICT` schützt vor doppeltem Claim desselben Nutzers,
 *  - der `WHERE`-Guard kappt bei 500.
 */
export async function claimLaunchBonus(userId: string): Promise<boolean> {
  try {
    const result = await db.execute(sql`
      WITH next_pos AS (
        SELECT nextval('launch_claims_position_seq') AS position
      )
      INSERT INTO launch_claims (id, user_id, position)
      SELECT ${userId} || '-' || next_pos.position, ${userId}, next_pos.position
      FROM next_pos
      WHERE next_pos.position <= ${LAUNCH_CLAIM_LIMIT}
        AND (SELECT count(*) FROM launch_claims) < ${LAUNCH_CLAIM_LIMIT}
      ON CONFLICT (user_id) DO NOTHING
      RETURNING position
    `);

    const granted = Array.isArray(result.rows) && result.rows.length > 0;
    if (granted) {
      await db
        .update(users)
        .set({ launchBonus: true, updatedAt: new Date() })
        .where(eq(users.id, userId));
    }
    return granted;
  } catch {
    return false;
  }
}

/** Anzahl bereits vergebener Launch-Bonus-Slots. */
export async function countLaunchClaims(): Promise<number> {
  try {
    const rows = await db.select({ value: count() }).from(launch_claims);
    return Number(rows[0]?.value ?? 0);
  } catch {
    return 0;
  }
}

export async function linkGoogleAccount(userId: string, sub: string): Promise<void> {
  await db
    .insert(oauth_accounts)
    .values({
      id: newId("oa"),
      userId,
      provider: "google",
      providerAccountId: sub,
    })
    .onConflictDoNothing();
}

export async function findUserByAppleId(sub: string): Promise<User | null> {
  const rows = await db
    .select({ user: users })
    .from(oauth_accounts)
    .innerJoin(users, eq(oauth_accounts.userId, users.id))
    .where(and(eq(oauth_accounts.provider, "apple"), eq(oauth_accounts.providerAccountId, sub)))
    .limit(1);

  return rows[0]?.user ?? null;
}

export async function linkAppleAccount(userId: string, sub: string): Promise<void> {
  await db
    .insert(oauth_accounts)
    .values({
      id: newId("oa"),
      userId,
      provider: "apple",
      providerAccountId: sub,
    })
    .onConflictDoNothing();
}

export async function markEmailVerified(userId: string): Promise<void> {
  await db
    .update(users)
    .set({ emailVerifiedAt: new Date(), updatedAt: new Date() })
    .where(eq(users.id, userId));
}

/**
 * Replaces any outstanding link with a fresh one and mails it out, so the most
 * recent email is always the only one that works.
 */
export async function issueVerificationLink(
  user: User,
  lang: "de" | "en"
): Promise<MailResult> {
  await db
    .delete(email_verification_tokens)
    .where(
      and(
        eq(email_verification_tokens.userId, user.id),
        isNull(email_verification_tokens.consumedAt)
      )
    );

  const { token, hash } = issueToken();
  await db.insert(email_verification_tokens).values({
    id: hash,
    userId: user.id,
    email: user.email ?? "",
    expiresAt: new Date(Date.now() + VERIFICATION_TTL_MS),
  });

  const link = `${siteUrl}/api/auth/verify?token=${encodeURIComponent(token)}`;

  return sendVerificationEmail({
    to: user.email ?? "",
    name: user.name,
    link,
    lang,
  });
}

export type VerificationOutcome =
  | { status: "success"; user: User }
  | { status: "already_verified"; user: User }
  | { status: "expired" }
  | { status: "invalid" };

export async function consumeVerificationToken(token: string): Promise<VerificationOutcome> {
  const rows = await db
    .select({ record: email_verification_tokens, user: users })
    .from(email_verification_tokens)
    .innerJoin(users, eq(email_verification_tokens.userId, users.id))
    .where(eq(email_verification_tokens.id, hashToken(token)))
    .limit(1);

  const row = rows[0];
  if (!row) return { status: "invalid" };

  if (row.user.emailVerifiedAt) return { status: "already_verified", user: row.user };
  if (row.record.consumedAt) return { status: "invalid" };
  if (row.record.expiresAt.getTime() < Date.now()) return { status: "expired" };

  await db
    .update(email_verification_tokens)
    .set({ consumedAt: new Date() })
    .where(eq(email_verification_tokens.id, row.record.id));

  await markEmailVerified(row.user.id);

  return { status: "success", user: { ...row.user, emailVerifiedAt: new Date() } };
}

/** True when the account exists purely as a Google identity (no local password). */
export function isGoogleOnlyAccount(user: User): boolean {
  return !user.passwordHash;
}

// ---------------------------------------------------------------------------
// Password reset
// ---------------------------------------------------------------------------

/**
 * Issues a reset link, invalidating any earlier one so only the newest email
 * works. Also valid for Google-only accounts: the link proves control of an
 * address Google already verified, so it lets the owner add a local password
 * rather than dead-ending them.
 */
export async function issuePasswordResetLink(
  user: User,
  lang: "de" | "en",
  requestedIpHash?: string | null
): Promise<MailResult> {
  await db
    .delete(password_reset_tokens)
    .where(
      and(
        eq(password_reset_tokens.userId, user.id),
        isNull(password_reset_tokens.consumedAt)
      )
    );

  const { token, hash } = issueToken();
  await db.insert(password_reset_tokens).values({
    id: hash,
    userId: user.id,
    expiresAt: new Date(Date.now() + PASSWORD_RESET_TTL_MS),
    requestedIpHash: requestedIpHash ?? null,
  });

  const link = `${siteUrl}/auth/reset-password?token=${encodeURIComponent(token)}`;

  return sendPasswordResetEmail({
    to: user.email ?? "",
    name: user.name,
    link,
    lang,
  });
}

export type ResetTokenState = "valid" | "expired" | "invalid";

/**
 * Checks a reset token without spending it, so the reset page can show an
 * honest state before the user types a new password into a form that would fail.
 */
export async function peekPasswordResetToken(token: string): Promise<ResetTokenState> {
  const rows = await db
    .select()
    .from(password_reset_tokens)
    .where(eq(password_reset_tokens.id, hashToken(token)))
    .limit(1);

  const record = rows[0];
  if (!record || record.consumedAt) return "invalid";
  if (record.expiresAt.getTime() < Date.now()) return "expired";
  return "valid";
}

export type PasswordResetOutcome =
  | { status: "success"; userId: string }
  | { status: "expired" }
  | { status: "invalid" };

/**
 * Spends the token and installs the new password.
 *
 * Three things happen together on success, and all three matter: the token is
 * consumed so the link cannot be replayed, every existing session is dropped so
 * an attacker who was already signed in loses access, and the address is marked
 * verified because opening the link proved control of the inbox.
 */
export async function resetPasswordWithToken(
  token: string,
  newPassword: string
): Promise<PasswordResetOutcome> {
  const rows = await db
    .select()
    .from(password_reset_tokens)
    .where(eq(password_reset_tokens.id, hashToken(token)))
    .limit(1);

  const record = rows[0];
  if (!record || record.consumedAt) return { status: "invalid" };
  if (record.expiresAt.getTime() < Date.now()) return { status: "expired" };

  const passwordHash = await hashPassword(newPassword);

  await db
    .update(users)
    .set({ passwordHash, emailVerifiedAt: new Date(), updatedAt: new Date() })
    .where(eq(users.id, record.userId));

  await db
    .update(password_reset_tokens)
    .set({ consumedAt: new Date() })
    .where(eq(password_reset_tokens.id, record.id));

  await db.delete(sessions).where(eq(sessions.userId, record.userId));

  return { status: "success", userId: record.userId };
}

// ---------------------------------------------------------------------------
// Password change (logged in)
// ---------------------------------------------------------------------------

export type ChangePasswordOutcome =
  | { status: "success"; userId: string }
  | { status: "no_password" } // Google-only account
  | { status: "wrong_password" }
  | { status: "invalid" }; // user not found

/**
 * Replaces the password of an already signed-in user.
 *
 * The caller must already hold a live session; this function re-checks the
 * CURRENT password so a stolen cookie alone cannot be used to take over the
 * credential. On success every existing session row for the account is
 * deleted — a device that was already signed in (e.g. one an attacker had
 * access through) loses access instantly. The route handler is expected to
 * issue a fresh session for the current device afterwards.
 */
export async function changePasswordForUser(
  userId: string,
  currentPassword: string,
  newPassword: string
): Promise<ChangePasswordOutcome> {
  const rows = await db.select().from(users).where(eq(users.id, userId)).limit(1);
  const user = rows[0];
  if (!user) return { status: "invalid" };

  if (!user.passwordHash) return { status: "no_password" };

  if (!(await verifyPassword(currentPassword, user.passwordHash))) {
    return { status: "wrong_password" };
  }

  await db
    .update(users)
    .set({ passwordHash: await hashPassword(newPassword), updatedAt: new Date() })
    .where(eq(users.id, userId));

  await destroyAllSessionsFor(userId);

  return { status: "success", userId };
}

// ---------------------------------------------------------------------------
// Account deletion
// ---------------------------------------------------------------------------

export type DeleteAccountOutcome =
  | { status: "success" }
  | { status: "wrong_password" }
  | { status: "invalid" }; // user not found

/**
 * Permanently deletes an account.
 *
 * A local password is re-verified first, exactly like `changePasswordForUser`
 * — a stolen session cookie alone must not be enough to destroy the account.
 * Google-only accounts (no local password) skip that check: holding a valid
 * session already proves control, and there is nothing to verify against.
 *
 * The delete itself is a single row removal. Every dependent table cascades
 * via its foreign key — sessions, oauth accounts, reset/verification tokens,
 * projects, receipts (and their line items), launch claims — so this one
 * statement is the whole deletion. `licenses` and `security_events` reference
 * the user with `ON DELETE SET NULL` instead, so billing history and the
 * audit trail outlive the account that generated them.
 */
export async function deleteUserAccount(
  userId: string,
  currentPassword?: string | null
): Promise<DeleteAccountOutcome> {
  const rows = await db.select().from(users).where(eq(users.id, userId)).limit(1);
  const user = rows[0];
  if (!user) return { status: "invalid" };

  if (user.passwordHash) {
    if (!currentPassword || !(await verifyPassword(currentPassword, user.passwordHash))) {
      return { status: "wrong_password" };
    }
  }

  await db.delete(users).where(eq(users.id, userId));

  return { status: "success" };
}
