import crypto from 'node:crypto';
import {
  type Database,
  and,
  createDb,
  desc,
  eq,
  gt,
  isNull,
  magicLinks,
  memberships,
  organizations,
  sessions,
  smsCodes,
  users,
} from '@bmm/db';

const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 min
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const SCRYPT_KEYLEN = 64;
// OWASP 2024+ recommends scrypt N≥2^17 for password hashing. Hash format
// embeds N (`scrypt$N$salt$hash`), so verifyPassword auto-handles old hashes
// at lower N — backward-compatible cost bump. (Za-002.)
const SCRYPT_N = 131_072;
// scrypt with high N also needs maxmem ceiling raised — Node defaults to
// ~32MiB which is below what N=131072 requires. (~128MiB needed per op.)
const SCRYPT_MAXMEM = 256 * 1024 * 1024;

function sha256(input: string): string {
  return crypto.createHash('sha256').update(input).digest('hex');
}

function randomToken(bytes = 32): string {
  return crypto.randomBytes(bytes).toString('base64url');
}

export function hashPassword(password: string): string {
  const salt = crypto.randomBytes(16).toString('hex');
  const derived = crypto
    .scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N, maxmem: SCRYPT_MAXMEM })
    .toString('hex');
  return `scrypt$${SCRYPT_N}$${salt}$${derived}`;
}

export function verifyPassword(password: string, stored: string): boolean {
  const parts = stored.split('$');
  if (parts.length !== 4 || parts[0] !== 'scrypt') return false;
  const N = Number.parseInt(parts[1] ?? '', 10);
  const salt = parts[2];
  const expectedHex = parts[3];
  if (!Number.isFinite(N) || !salt || !expectedHex) return false;
  const expected = Buffer.from(expectedHex, 'hex');
  // Use the N embedded in the hash so old (lower-N) hashes still verify.
  // maxmem scales with N — 128 * N * r bytes minimum, doubled for headroom.
  const maxmem = Math.max(64 * 1024 * 1024, N * 256);
  const actual = crypto.scryptSync(password, salt, expected.length, { N, maxmem });
  if (actual.length !== expected.length) return false;
  return crypto.timingSafeEqual(actual, expected);
}

function slugify(input: string): string {
  return (
    input
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '')
      .slice(0, 48) || 'org'
  );
}

export interface MagicLinkIssued {
  token: string;
  expiresAt: Date;
}

export async function issueMagicLink(
  email: string,
  db: Database = createDb(),
): Promise<MagicLinkIssued> {
  const lower = email.trim().toLowerCase();
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) {
    throw new Error('invalid_email');
  }
  const token = randomToken(32);
  const tokenHash = sha256(token);
  const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS);
  await db.insert(magicLinks).values({ email: lower, tokenHash, expiresAt });
  return { token, expiresAt };
}

export interface ConsumedSession {
  sessionToken: string;
  userId: string;
  orgId: string;
  email: string | null;
}

export async function consumeMagicLink(
  token: string,
  meta: { ipAddress?: string; userAgent?: string } = {},
  db: Database = createDb(),
): Promise<ConsumedSession> {
  const tokenHash = sha256(token);
  const [row] = await db
    .select()
    .from(magicLinks)
    .where(and(eq(magicLinks.tokenHash, tokenHash), gt(magicLinks.expiresAt, new Date())))
    .limit(1);
  if (!row || row.consumedAt) {
    throw new Error('invalid_or_expired_token');
  }
  // Atomic single-use: only the request whose UPDATE actually flips
  // consumedAt from NULL → now() is allowed to mint a session. Two concurrent
  // requests with the same token can't both win — the loser sees rows = 0.
  // (Za-003.)
  const claimed = await db
    .update(magicLinks)
    .set({ consumedAt: new Date() })
    .where(and(eq(magicLinks.id, row.id), isNull(magicLinks.consumedAt)))
    .returning({ id: magicLinks.id });
  if (claimed.length === 0) {
    throw new Error('invalid_or_expired_token');
  }

  // Get or create user + default org
  let user = (await db.select().from(users).where(eq(users.email, row.email)).limit(1))[0];
  if (!user) {
    [user] = await db.insert(users).values({ email: row.email, emailVerified: true }).returning();
    const orgSlug = `${slugify(row.email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`;
    const [org] = await db
      .insert(organizations)
      .values({ slug: orgSlug, name: `${row.email.split('@')[0]}'s workspace` })
      .returning();
    if (!org) throw new Error('org_create_failed');
    await db.insert(memberships).values({ orgId: org.id, userId: user!.id, role: 'owner' });
  } else if (!user.emailVerified) {
    await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id));
  }

  if (!user) throw new Error('user_resolve_failed');

  // Deterministic ordering — when org-invites eventually let one user have
  // multiple memberships, we want the same "primary org" to win every login,
  // not a random one. Oldest membership = the org the user originally signed
  // up for. (Za-004.)
  const [membership] = await db
    .select()
    .from(memberships)
    .where(eq(memberships.userId, user.id))
    .orderBy(memberships.createdAt)
    .limit(1);
  if (!membership) throw new Error('no_org_membership');

  const sessionToken = randomToken(32);
  const sessionHash = sha256(sessionToken);
  await db.insert(sessions).values({
    userId: user.id,
    tokenHash: sessionHash,
    expiresAt: new Date(Date.now() + SESSION_TTL_MS),
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  return { sessionToken, userId: user.id, orgId: membership.orgId, email: user.email };
}

/**
 * Get-or-create a user from a verified third-party identity (Google, etc.) and
 * mint a session. The caller is responsible for verifying the identity provider's
 * token BEFORE calling this — `email` must already be proven to belong to the user.
 */
export async function upsertOAuthLogin(
  input: { email: string; name?: string | null },
  meta: { ipAddress?: string; userAgent?: string } = {},
  db: Database = createDb(),
): Promise<ConsumedSession> {
  const email = input.email.trim().toLowerCase();
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    throw new Error('invalid_email');
  }

  let user = (await db.select().from(users).where(eq(users.email, email)).limit(1))[0];
  if (!user) {
    [user] = await db
      .insert(users)
      .values({ email, emailVerified: true, name: input.name ?? undefined })
      .returning();
    if (!user) throw new Error('user_create_failed');
    const orgSlug = `${slugify(email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`;
    const [org] = await db
      .insert(organizations)
      .values({ slug: orgSlug, name: `${email.split('@')[0]}'s workspace` })
      .returning();
    if (!org) throw new Error('org_create_failed');
    await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' });
  } else if (!user.emailVerified) {
    await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id));
  }

  const resolved = user;
  const [membership] = await db
    .select()
    .from(memberships)
    .where(eq(memberships.userId, resolved.id))
    .limit(1);
  if (!membership) throw new Error('no_org_membership');

  const sessionToken = randomToken(32);
  await db.insert(sessions).values({
    userId: resolved.id,
    tokenHash: sha256(sessionToken),
    expiresAt: new Date(Date.now() + SESSION_TTL_MS),
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, resolved.id));

  return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email };
}

// ---- SMS one-time-code login ----

const SMS_CODE_TTL_MS = 10 * 60 * 1000; // 10 min
const SMS_RATE_WINDOW_MS = 15 * 60 * 1000;
const SMS_MAX_PER_WINDOW = 3; // codes issued per phone per window
const SMS_MAX_ATTEMPTS = 5; // wrong-code guesses per code

/** Normalise to strict E.164 (+ and 8-15 digits). Throws on anything else. */
function normalizePhone(raw: string): string {
  const p = raw.replace(/[\s\-().]/g, '');
  if (!/^\+[1-9]\d{7,14}$/.test(p)) throw new Error('invalid_phone');
  return p;
}

export interface SmsCodeIssued {
  phone: string;
  code: string;
  expiresAt: Date;
}

/** Generate a 6-digit code for a phone number, store it hashed, rate-limited. */
export async function issueSmsCode(
  phoneRaw: string,
  db: Database = createDb(),
): Promise<SmsCodeIssued> {
  const phone = normalizePhone(phoneRaw);
  const since = new Date(Date.now() - SMS_RATE_WINDOW_MS);
  const recent = await db
    .select({ id: smsCodes.id })
    .from(smsCodes)
    .where(and(eq(smsCodes.phone, phone), gt(smsCodes.createdAt, since)));
  if (recent.length >= SMS_MAX_PER_WINDOW) throw new Error('rate_limited');

  const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
  const expiresAt = new Date(Date.now() + SMS_CODE_TTL_MS);
  await db.insert(smsCodes).values({ phone, codeHash: sha256(`${phone}:${code}`), expiresAt });
  return { phone, code, expiresAt };
}

/** Verify a code, then get-or-create the phone's user and mint a session. */
export async function consumeSmsCode(
  phoneRaw: string,
  code: string,
  meta: { ipAddress?: string; userAgent?: string } = {},
  db: Database = createDb(),
): Promise<ConsumedSession> {
  const phone = normalizePhone(phoneRaw);
  const [row] = await db
    .select()
    .from(smsCodes)
    .where(and(eq(smsCodes.phone, phone), gt(smsCodes.expiresAt, new Date())))
    .orderBy(desc(smsCodes.createdAt))
    .limit(1);
  if (!row || row.consumedAt) throw new Error('invalid_or_expired_code');
  if (row.attempts >= SMS_MAX_ATTEMPTS) throw new Error('too_many_attempts');
  if (sha256(`${phone}:${code}`) !== row.codeHash) {
    await db
      .update(smsCodes)
      .set({ attempts: row.attempts + 1 })
      .where(eq(smsCodes.id, row.id));
    throw new Error('invalid_code');
  }
  await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id));

  let user = (await db.select().from(users).where(eq(users.phone, phone)).limit(1))[0];
  if (!user) {
    [user] = await db.insert(users).values({ phone }).returning();
    if (!user) throw new Error('user_create_failed');
    const orgSlug = `phone-${randomToken(4).toLowerCase()}`;
    const [org] = await db
      .insert(organizations)
      .values({ slug: orgSlug, name: 'My workspace' })
      .returning();
    if (!org) throw new Error('org_create_failed');
    await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' });
  }
  const resolved = user;

  const [membership] = await db
    .select()
    .from(memberships)
    .where(eq(memberships.userId, resolved.id))
    .limit(1);
  if (!membership) throw new Error('no_org_membership');

  const sessionToken = randomToken(32);
  await db.insert(sessions).values({
    userId: resolved.id,
    tokenHash: sha256(sessionToken),
    expiresAt: new Date(Date.now() + SESSION_TTL_MS),
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, resolved.id));

  return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email };
}

export interface AuthedUser {
  userId: string;
  orgId: string;
  email: string | null;
  phone: string | null;
  role: string;
  isAdmin: boolean;
}

export async function getSession(
  sessionToken: string | null | undefined,
  db: Database = createDb(),
): Promise<AuthedUser | null> {
  if (!sessionToken) return null;
  const hash = sha256(sessionToken);
  const [row] = await db
    .select({
      userId: sessions.userId,
      expiresAt: sessions.expiresAt,
      email: users.email,
      phone: users.phone,
      isAdmin: users.isAdmin,
    })
    .from(sessions)
    .innerJoin(users, eq(users.id, sessions.userId))
    .where(eq(sessions.tokenHash, hash))
    .limit(1);
  if (!row || row.expiresAt < new Date()) return null;
  const [membership] = await db
    .select({ orgId: memberships.orgId, role: memberships.role })
    .from(memberships)
    .where(eq(memberships.userId, row.userId))
    .limit(1);
  if (!membership) return null;
  return {
    userId: row.userId,
    orgId: membership.orgId,
    email: row.email,
    phone: row.phone,
    role: membership.role,
    isAdmin: row.isAdmin,
  };
}

export async function destroySession(
  sessionToken: string,
  db: Database = createDb(),
): Promise<void> {
  const hash = sha256(sessionToken);
  await db.delete(sessions).where(eq(sessions.tokenHash, hash));
}

export interface PasswordLoginResult {
  sessionToken: string;
  userId: string;
  orgId: string;
  email: string | null;
  isAdmin: boolean;
}

export async function loginWithPassword(
  emailRaw: string,
  password: string,
  meta: { ipAddress?: string; userAgent?: string } = {},
  db: Database = createDb(),
): Promise<PasswordLoginResult> {
  const email = emailRaw.trim().toLowerCase();
  const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
  if (!user || !user.passwordHash) {
    throw new Error('invalid_credentials');
  }
  if (!verifyPassword(password, user.passwordHash)) {
    throw new Error('invalid_credentials');
  }
  // Deterministic ordering — when org-invites eventually let one user have
  // multiple memberships, we want the same "primary org" to win every login,
  // not a random one. Oldest membership = the org the user originally signed
  // up for. (Za-004.)
  const [membership] = await db
    .select()
    .from(memberships)
    .where(eq(memberships.userId, user.id))
    .orderBy(memberships.createdAt)
    .limit(1);
  if (!membership) throw new Error('no_org_membership');

  const sessionToken = randomToken(32);
  await db.insert(sessions).values({
    userId: user.id,
    tokenHash: sha256(sessionToken),
    expiresAt: new Date(Date.now() + SESSION_TTL_MS),
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));

  return {
    sessionToken,
    userId: user.id,
    orgId: membership.orgId,
    email: user.email,
    isAdmin: user.isAdmin,
  };
}

export interface SeedAdminInput {
  email: string;
  password: string;
  name?: string;
}

export async function seedAdmin(
  input: SeedAdminInput,
  db: Database = createDb(),
): Promise<{ created: boolean; userId: string; orgId: string }> {
  const email = input.email.trim().toLowerCase();
  const existing = (await db.select().from(users).where(eq(users.email, email)).limit(1))[0];

  if (existing) {
    const updates: Partial<typeof users.$inferInsert> = {};
    if (!existing.isAdmin) updates.isAdmin = true;
    if (!existing.passwordHash) updates.passwordHash = hashPassword(input.password);
    if (!existing.emailVerified) updates.emailVerified = true;
    if (input.name && !existing.name) updates.name = input.name;
    if (Object.keys(updates).length > 0) {
      await db.update(users).set(updates).where(eq(users.id, existing.id));
    }
    const [m] = await db
      .select()
      .from(memberships)
      .where(eq(memberships.userId, existing.id))
      .limit(1);
    if (!m) {
      // Has a user but no org — bootstrap one
      const orgSlug = `admin-${randomToken(3).toLowerCase()}`;
      const [org] = await db
        .insert(organizations)
        .values({ slug: orgSlug, name: `${input.name ?? 'Admin'} workspace`, plan: 'enterprise' })
        .returning();
      if (!org) throw new Error('org_create_failed');
      await db.insert(memberships).values({ orgId: org.id, userId: existing.id, role: 'owner' });
      return { created: false, userId: existing.id, orgId: org.id };
    }
    return { created: false, userId: existing.id, orgId: m.orgId };
  }

  const [user] = await db
    .insert(users)
    .values({
      email,
      emailVerified: true,
      isAdmin: true,
      name: input.name,
      passwordHash: hashPassword(input.password),
    })
    .returning();
  if (!user) throw new Error('user_create_failed');

  const orgSlug = `admin-${randomToken(3).toLowerCase()}`;
  const [org] = await db
    .insert(organizations)
    .values({ slug: orgSlug, name: `${input.name ?? 'Admin'} workspace`, plan: 'enterprise' })
    .returning();
  if (!org) throw new Error('org_create_failed');
  await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' });
  return { created: true, userId: user.id, orgId: org.id };
}

export const __test = { sha256, randomToken, slugify };
