import crypto from 'node:crypto';
import {
  consumeMagicLink,
  consumeSmsCode,
  destroySession,
  getSession,
  issueMagicLink,
  issueSmsCode,
  loginWithPassword,
  upsertOAuthLogin,
} from '@bmm/auth';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { getOrgPlan } from '../lib/plan.js';
import { checkDailyLimit } from '../lib/rate-limit.js';
import { sendSms, smsConfigured } from '../lib/sms.js';

const SESSION_COOKIE = 'bmm_session';
const OAUTH_STATE_COOKIE = 'bmm_oauth_state';

/**
 * Single source of truth for the session cookie's flags. setCookie AND
 * clearCookie MUST agree on (path, sameSite, secure, httpOnly) — when they
 * drift, Chrome treats the clear directive as a brand-new cookie with
 * different security attributes and silently leaves the original one in
 * place. That's what bit us until now: logout looked successful (200 OK
 * with a Set-Cookie clear) but the real session cookie persisted because
 * the clear omitted Secure+HttpOnly.
 */
function sessionCookieOpts(): {
  httpOnly: true;
  sameSite: 'lax';
  path: '/';
  secure: boolean;
} {
  return {
    httpOnly: true,
    sameSite: 'lax',
    path: '/',
    secure: config.NODE_ENV === 'production',
  };
}

const GoogleClaims = z.object({
  iss: z.string(),
  aud: z.string(),
  exp: z.number(),
  email: z.string().email(),
  email_verified: z.union([z.boolean(), z.string()]).optional(),
  name: z.string().optional(),
});

/**
 * Decode (NOT signature-verify) a Google ID token payload. Signature verification
 * is unnecessary here because the token is fetched directly from Google's token
 * endpoint over TLS, authenticated with our client secret — an intermediary-free
 * channel, per Google's own guidance. We still validate iss / aud / exp / email
 * below as defense-in-depth.
 */
function decodeGoogleIdToken(idToken: string): z.infer<typeof GoogleClaims> {
  const parts = idToken.split('.');
  if (parts.length !== 3 || !parts[1]) throw new Error('malformed_id_token');
  const json = Buffer.from(parts[1], 'base64url').toString('utf8');
  return GoogleClaims.parse(JSON.parse(json));
}

function googleRedirectUri(): string {
  return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/google/callback`;
}

function googleConfigured(): boolean {
  return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET);
}

function githubConfigured(): boolean {
  return Boolean(config.GITHUB_OAUTH_ID && config.GITHUB_OAUTH_SECRET);
}

function githubRedirectUri(): string {
  return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/github/callback`;
}

// In-memory per-IP throttle for SMS-code requests — SMS costs money per send,
// so cap how often one IP can trigger a send regardless of which number.
const smsIpHits = new Map<string, number[]>();
function smsIpRateOk(ip: string, max = 5, windowMs = 10 * 60 * 1000): boolean {
  const now = Date.now();
  const hits = (smsIpHits.get(ip) ?? []).filter((t) => now - t < windowMs);
  if (hits.length >= max) {
    smsIpHits.set(ip, hits);
    return false;
  }
  hits.push(now);
  smsIpHits.set(ip, hits);
  return true;
}

export async function authRoutes(app: FastifyInstance): Promise<void> {
  app.post('/v1/auth/magic-link', async (req, reply) => {
    // Email auth is off by default — no SMTP wired yet. Closes the
    // account-takeover-via-magic-link path (Za-001) until an email sender
    // is configured AND a primaryProvider column lets us bind users to a
    // single login method.
    if (!config.EMAIL_AUTH_ENABLED) {
      return reply.code(503).send({
        error: 'email_auth_disabled',
        detail: 'Email login is currently unavailable. Use Google, GitHub, or SMS.',
      });
    }
    const Body = z.object({ email: z.string().email() });
    const parsed = Body.safeParse(req.body);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_email' });

    // Two-axis rate-limit: per-IP (prevents IP-flooding the endpoint) and
    // per-email (prevents inbox-flooding a specific target). Both required
    // because the IP cap protects us, the email cap protects the recipient.
    const ipOk = await checkDailyLimit('magic_ip', req.ip, 10);
    if (!ipOk.ok) {
      return reply.code(429).send({
        error: 'rate_limited',
        detail: 'Too many magic-link requests from this IP. Try again tomorrow.',
      });
    }
    const emailOk = await checkDailyLimit('magic_email', parsed.data.email.toLowerCase(), 5);
    if (!emailOk.ok) {
      return reply.code(429).send({
        error: 'rate_limited',
        detail: 'Too many magic-link requests for this email. Try again tomorrow.',
      });
    }

    try {
      const { token, expiresAt } = await issueMagicLink(parsed.data.email);
      const callbackUrl = `${config.NEXT_PUBLIC_APP_URL}/login/callback?token=${token}`;
      // In dev we print the link to stdout so the developer can click it.
      // In production we must NEVER log the full token — anyone with
      // `docker logs` access would silently impersonate any user.
      if (config.NODE_ENV !== 'production') {
        app.log.info({ to: parsed.data.email, expiresAt }, `[magic-link] -> ${callbackUrl}`);
        console.log(`\n[magic-link] ${parsed.data.email} ->\n  ${callbackUrl}\n`);
      } else {
        app.log.info(
          { to: parsed.data.email, expiresAt },
          '[magic-link] issued (URL withheld from logs)',
        );
        // TODO(launch): hook up Resend / SES here. Until then, production
        // magic-link is effectively dead — fail loud rather than silent.
        app.log.error('magic-link email sender not configured — link cannot reach user');
      }
      return reply.send({ ok: true });
    } catch (e) {
      app.log.error(e);
      return reply.code(400).send({ error: 'magic_link_failed' });
    }
  });

  app.post('/v1/auth/verify', async (req, reply) => {
    if (!config.EMAIL_AUTH_ENABLED) {
      return reply.code(503).send({ error: 'email_auth_disabled' });
    }
    const Body = z.object({ token: z.string().min(10) });
    const parsed = Body.safeParse(req.body);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_token' });
    try {
      const session = await consumeMagicLink(parsed.data.token, {
        ipAddress: req.ip,
        userAgent: req.headers['user-agent'],
      });
      reply.setCookie(SESSION_COOKIE, session.sessionToken, {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: config.NODE_ENV === 'production',
        maxAge: 30 * 24 * 60 * 60,
      });
      await audit({
        orgId: session.orgId,
        userId: session.userId,
        action: 'auth.login',
        resourceType: 'session',
        metadata: { email: session.email },
        ipAddress: req.ip,
      });
      return reply.send({
        ok: true,
        user: { id: session.userId, email: session.email, orgId: session.orgId },
      });
    } catch (e) {
      app.log.warn({ err: e }, 'magic link verify failed');
      return reply.code(400).send({ error: 'invalid_or_expired_token' });
    }
  });

  app.get('/v1/auth/me', async (req, reply) => {
    const token = req.cookies[SESSION_COOKIE];
    const session = await getSession(token);
    if (!session) return reply.code(401).send({ error: 'unauthorized' });
    // Plan is on the org, not the session — look it up fresh so a Stripe
    // upgrade is reflected without forcing a re-login.
    const plan = await getOrgPlan(session.orgId);
    return reply.send({ user: { ...session, plan } });
  });

  app.post('/v1/auth/admin/login', async (req, reply) => {
    const Body = z.object({
      email: z.string().email(),
      password: z.string().min(8),
    });
    const parsed = Body.safeParse(req.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: 'invalid_input' });
    }
    try {
      const session = await loginWithPassword(parsed.data.email, parsed.data.password, {
        ipAddress: req.ip,
        userAgent: req.headers['user-agent'],
      });
      if (!session.isAdmin) {
        await destroySession(session.sessionToken);
        return reply.code(403).send({ error: 'not_admin' });
      }
      reply.setCookie(SESSION_COOKIE, session.sessionToken, {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: config.NODE_ENV === 'production',
        maxAge: 30 * 24 * 60 * 60,
      });
      await audit({
        orgId: session.orgId,
        userId: session.userId,
        action: 'admin.login',
        resourceType: 'session',
        metadata: { email: session.email },
        ipAddress: req.ip,
      });
      return reply.send({
        ok: true,
        user: { id: session.userId, email: session.email, orgId: session.orgId, isAdmin: true },
      });
    } catch (err) {
      app.log.warn({ err }, 'admin login failed');
      // Constant-time-ish to discourage username enumeration
      await new Promise((r) => setTimeout(r, 300));
      return reply.code(401).send({ error: 'invalid_credentials' });
    }
  });

  app.post('/v1/auth/logout', async (req, reply) => {
    const token = req.cookies[SESSION_COOKIE];
    const session = token ? await getSession(token) : null;
    if (token) await destroySession(token);
    reply.clearCookie(SESSION_COOKIE, sessionCookieOpts());
    if (session) {
      await audit({
        orgId: session.orgId,
        userId: session.userId,
        action: 'auth.logout',
        resourceType: 'session',
        ipAddress: req.ip,
      });
    }
    return reply.send({ ok: true });
  });

  // Which login providers are configured. Lets the UI hide buttons + forms
  // when their backing infra isn't wired. `email` defaults to false because
  // we haven't bought an SMTP provider yet — flipping EMAIL_AUTH_ENABLED to
  // true re-enables the magic-link form section.
  app.get('/v1/auth/providers', async (_req, reply) => {
    return reply.send({
      google: googleConfigured(),
      github: githubConfigured(),
      sms: smsConfigured(),
      email: config.EMAIL_AUTH_ENABLED,
    });
  });

  // Step 1: hand the browser off to Google's consent screen.
  app.get('/v1/auth/google', async (_req, reply) => {
    if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) {
      return reply.code(503).send({ error: 'google_oauth_not_configured' });
    }
    const state = crypto.randomBytes(16).toString('base64url');
    reply.setCookie(OAUTH_STATE_COOKIE, state, {
      httpOnly: true,
      sameSite: 'lax',
      path: '/',
      secure: config.NODE_ENV === 'production',
      maxAge: 600,
    });
    const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
    url.searchParams.set('client_id', config.GOOGLE_OAUTH_ID);
    url.searchParams.set('redirect_uri', googleRedirectUri());
    url.searchParams.set('response_type', 'code');
    url.searchParams.set('scope', 'openid email profile');
    url.searchParams.set('state', state);
    url.searchParams.set('access_type', 'online');
    url.searchParams.set('prompt', 'select_account');
    return reply.redirect(url.toString());
  });

  // Step 2: Google redirects back here with an auth code. Exchange it, verify
  // the ID token, mint a session, drop the user on the dashboard.
  app.get('/v1/auth/google/callback', async (req, reply) => {
    const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`;
    const Query = z.object({
      code: z.string().min(10).optional(),
      state: z.string().min(8).optional(),
      error: z.string().optional(),
    });
    const q = Query.safeParse(req.query);
    const cookieState = req.cookies[OAUTH_STATE_COOKIE];
    reply.clearCookie(OAUTH_STATE_COOKIE, { path: '/' });

    if (!q.success || q.data.error || !q.data.code || !q.data.state) {
      return reply.redirect(`${loginUrl}?error=google_failed`);
    }
    // CSRF: the state echoed back by Google must match the one we set.
    // Length-check first — timingSafeEqual throws on a length mismatch.
    if (
      !cookieState ||
      cookieState.length !== q.data.state.length ||
      !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state))
    ) {
      return reply.redirect(`${loginUrl}?error=google_state`);
    }
    if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) {
      return reply.redirect(`${loginUrl}?error=google_failed`);
    }

    try {
      const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
        method: 'POST',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          code: q.data.code,
          client_id: config.GOOGLE_OAUTH_ID,
          client_secret: config.GOOGLE_OAUTH_SECRET,
          redirect_uri: googleRedirectUri(),
          grant_type: 'authorization_code',
        }),
      });
      if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`);
      const tokens = (await tokenRes.json()) as { id_token?: string };
      if (!tokens.id_token) throw new Error('no_id_token');

      const claims = decodeGoogleIdToken(tokens.id_token);
      if (claims.iss !== 'accounts.google.com' && claims.iss !== 'https://accounts.google.com') {
        throw new Error('bad_iss');
      }
      if (claims.aud !== config.GOOGLE_OAUTH_ID) throw new Error('bad_aud');
      if (claims.exp * 1000 < Date.now()) throw new Error('token_expired');
      const verified = claims.email_verified === true || claims.email_verified === 'true';
      if (!verified) throw new Error('email_unverified');

      const session = await upsertOAuthLogin(
        { email: claims.email, name: claims.name ?? null },
        { ipAddress: req.ip, userAgent: req.headers['user-agent'] },
      );
      reply.setCookie(SESSION_COOKIE, session.sessionToken, {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: config.NODE_ENV === 'production',
        maxAge: 30 * 24 * 60 * 60,
      });
      await audit({
        orgId: session.orgId,
        userId: session.userId,
        action: 'auth.login',
        resourceType: 'session',
        metadata: { email: session.email, provider: 'google' },
        ipAddress: req.ip,
      });
      return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`);
    } catch (err) {
      app.log.warn({ err }, 'google oauth callback failed');
      return reply.redirect(`${loginUrl}?error=google_failed`);
    }
  });

  // ---- GitHub OAuth ----
  app.get('/v1/auth/github', async (_req, reply) => {
    if (!config.GITHUB_OAUTH_ID || !config.GITHUB_OAUTH_SECRET) {
      return reply.code(503).send({ error: 'github_oauth_not_configured' });
    }
    const state = crypto.randomBytes(16).toString('base64url');
    reply.setCookie(OAUTH_STATE_COOKIE, state, {
      httpOnly: true,
      sameSite: 'lax',
      path: '/',
      secure: config.NODE_ENV === 'production',
      maxAge: 600,
    });
    const url = new URL('https://github.com/login/oauth/authorize');
    url.searchParams.set('client_id', config.GITHUB_OAUTH_ID);
    url.searchParams.set('redirect_uri', githubRedirectUri());
    url.searchParams.set('scope', 'read:user user:email');
    url.searchParams.set('state', state);
    return reply.redirect(url.toString());
  });

  app.get('/v1/auth/github/callback', async (req, reply) => {
    const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`;
    const Query = z.object({
      code: z.string().min(8).optional(),
      state: z.string().min(8).optional(),
      error: z.string().optional(),
    });
    const q = Query.safeParse(req.query);
    const cookieState = req.cookies[OAUTH_STATE_COOKIE];
    reply.clearCookie(OAUTH_STATE_COOKIE, { path: '/' });

    if (!q.success || q.data.error || !q.data.code || !q.data.state) {
      return reply.redirect(`${loginUrl}?error=github_failed`);
    }
    if (
      !cookieState ||
      cookieState.length !== q.data.state.length ||
      !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state))
    ) {
      return reply.redirect(`${loginUrl}?error=github_state`);
    }
    if (!config.GITHUB_OAUTH_ID || !config.GITHUB_OAUTH_SECRET) {
      return reply.redirect(`${loginUrl}?error=github_failed`);
    }

    try {
      const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
        method: 'POST',
        headers: {
          accept: 'application/json',
          'content-type': 'application/x-www-form-urlencoded',
        },
        body: new URLSearchParams({
          client_id: config.GITHUB_OAUTH_ID,
          client_secret: config.GITHUB_OAUTH_SECRET,
          code: q.data.code,
          redirect_uri: githubRedirectUri(),
        }),
      });
      if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`);
      const tokens = (await tokenRes.json()) as { access_token?: string };
      if (!tokens.access_token) throw new Error('no_access_token');

      // GitHub's API rejects requests without a User-Agent header.
      const ghHeaders = {
        authorization: `Bearer ${tokens.access_token}`,
        accept: 'application/vnd.github+json',
        'user-agent': 'BuildMyMCPServer',
      };
      const userRes = await fetch('https://api.github.com/user', { headers: ghHeaders });
      if (!userRes.ok) throw new Error(`user_fetch_${userRes.status}`);
      const ghUser = (await userRes.json()) as { name?: string; login?: string };

      // /user omits the email when it is private — /user/emails always lists it.
      const emailRes = await fetch('https://api.github.com/user/emails', { headers: ghHeaders });
      if (!emailRes.ok) throw new Error(`email_fetch_${emailRes.status}`);
      const emails = (await emailRes.json()) as Array<{
        email: string;
        primary: boolean;
        verified: boolean;
      }>;
      const primary = emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified);
      if (!primary) throw new Error('no_verified_email');

      const session = await upsertOAuthLogin(
        { email: primary.email, name: ghUser.name ?? ghUser.login ?? null },
        { ipAddress: req.ip, userAgent: req.headers['user-agent'] },
      );
      reply.setCookie(SESSION_COOKIE, session.sessionToken, {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: config.NODE_ENV === 'production',
        maxAge: 30 * 24 * 60 * 60,
      });
      await audit({
        orgId: session.orgId,
        userId: session.userId,
        action: 'auth.login',
        resourceType: 'session',
        metadata: { email: session.email, provider: 'github' },
        ipAddress: req.ip,
      });
      return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`);
    } catch (err) {
      app.log.warn({ err }, 'github oauth callback failed');
      return reply.redirect(`${loginUrl}?error=github_failed`);
    }
  });

  // ---- SMS one-time-code login ----
  app.post('/v1/auth/sms/request', async (req, reply) => {
    if (!smsConfigured()) return reply.code(503).send({ error: 'sms_not_configured' });
    const Body = z.object({ phone: z.string().min(8).max(24) });
    const parsed = Body.safeParse(req.body);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_phone' });
    if (!smsIpRateOk(req.ip)) return reply.code(429).send({ error: 'rate_limited' });
    try {
      const { phone, code } = await issueSmsCode(parsed.data.phone);
      await sendSms(phone, `${code} is your BuildMyMCPServer login code. Valid for 10 minutes.`);
      return reply.send({ ok: true });
    } catch (e) {
      const msg = (e as Error).message;
      if (msg === 'invalid_phone') return reply.code(400).send({ error: 'invalid_phone' });
      if (msg === 'rate_limited') return reply.code(429).send({ error: 'rate_limited' });
      app.log.warn({ err: e }, 'sms request failed');
      return reply.code(400).send({ error: 'sms_request_failed' });
    }
  });

  app.post('/v1/auth/sms/verify', async (req, reply) => {
    const Body = z.object({
      phone: z.string().min(8).max(24),
      code: z.string().regex(/^\d{6}$/),
    });
    const parsed = Body.safeParse(req.body);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
    try {
      const session = await consumeSmsCode(parsed.data.phone, parsed.data.code, {
        ipAddress: req.ip,
        userAgent: req.headers['user-agent'],
      });
      reply.setCookie(SESSION_COOKIE, session.sessionToken, {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: config.NODE_ENV === 'production',
        maxAge: 30 * 24 * 60 * 60,
      });
      await audit({
        orgId: session.orgId,
        userId: session.userId,
        action: 'auth.login',
        resourceType: 'session',
        metadata: { provider: 'sms' },
        ipAddress: req.ip,
      });
      return reply.send({ ok: true, user: { id: session.userId, orgId: session.orgId } });
    } catch (e) {
      const msg = (e as Error).message;
      const status: Record<string, number> = {
        invalid_or_expired_code: 400,
        invalid_code: 400,
        too_many_attempts: 429,
        invalid_phone: 400,
      };
      if (status[msg]) return reply.code(status[msg]).send({ error: msg });
      app.log.warn({ err: e }, 'sms verify failed');
      return reply.code(400).send({ error: 'sms_verify_failed' });
    }
  });
}
