import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { magicLink, admin } from 'better-auth/plugins';
import { APIError, createAuthMiddleware } from 'better-auth/api';
import { eq, and } from 'drizzle-orm';
import { db, dbGet, dbAll, isPostgres } from '../db/index.js';
import {
  authUsers,
  authSessions,
  authAccounts,
  authVerifications,
  authRateLimits,
} from '../db/auth-schema.js';
import { generateId } from './utils.js';
import { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
import { sendEmail } from './email.js';
import { getLoginLockout } from './stores/loginLockout.js';

const isProduction = process.env.NODE_ENV === 'production';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';

// Cookie domain shared across subdomains (e.g. ".spanglishcommunity.com") so a session
// issued by api.* is also sent to the site origin, where the frontend's Next middleware
// reads it to gate /admin and /dashboard. Leave unset in dev: localhost is single-host
// and needs a host-only cookie.
const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim();

const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
const rawSecret = process.env.BETTER_AUTH_SECRET;

// Never allow a weak/default secret in production: the secret signs session
// cookies, so a guessable value means forgeable sessions = account takeover.
if (isProduction && (!rawSecret || rawSecret.length < 32 || rawSecret === DEFAULT_DEV_SECRET)) {
  throw new Error(
    'BETTER_AUTH_SECRET must be set to a strong value (32+ characters) in production. Refusing to start.'
  );
}
if (!rawSecret) {
  console.warn('[auth] BETTER_AUTH_SECRET is not set; using an insecure development default.');
}

// The site origin plus its www/non-www alias, mirroring the CORS allowlist in
// index.ts. Requests whose Origin is not listed here are rejected by Better
// Auth's CSRF origin check.
function computeTrustedOrigins(): string[] {
  const origins = new Set<string>([frontendUrl]);
  try {
    const url = new URL(frontendUrl);
    const alias = url.hostname.startsWith('www.')
      ? url.hostname.slice(4)
      : `www.${url.hostname}`;
    origins.add(`${url.protocol}//${alias}${url.port ? `:${url.port}` : ''}`);
  } catch {
    /* keep frontendUrl as-is */
  }
  if (process.env.API_URL) origins.add(process.env.API_URL);
  if (!isProduction) {
    // Dev is frequently reached through a forwarded or proxied port (SSH tunnel, editor
    // port forwarding), so the browser's Origin is http://localhost:<random> and every
    // POST would fail the CSRF origin check. Trust any loopback port rather than pinning
    // FRONTEND_URL to a port that changes between sessions. Wildcard patterns are matched
    // per better-auth's trusted-origins helper; production stays on the exact allowlist.
    origins.add('http://localhost:*');
    origins.add('http://127.0.0.1:*');
  }
  return [...origins];
}

// Paths whose request body carries a new password that must satisfy the policy
// (Better Auth's minPasswordLength alone is weaker than the app's policy).
const PASSWORD_SETTING_PATHS = new Set([
  '/sign-up/email',
  '/reset-password',
  '/change-password',
  '/set-password',
]);

async function getCredentialAccount(userId: string): Promise<any | null> {
  return dbGet<any>(
    (db as any)
      .select()
      .from(authAccounts)
      .where(
        and(
          eq((authAccounts as any).userId, userId),
          eq((authAccounts as any).providerId, 'credential')
        )
      )
  );
}

export const auth = betterAuth({
  appName: 'Spanglish',
  baseURL: process.env.BETTER_AUTH_URL || frontendUrl,
  basePath: '/api/auth',
  secret: rawSecret || DEFAULT_DEV_SECRET,
  trustedOrigins: computeTrustedOrigins(),
  telemetry: { enabled: false },

  database: drizzleAdapter(db as any, {
    provider: isPostgres() ? 'pg' : 'sqlite',
    schema: {
      user: authUsers,
      session: authSessions,
      account: authAccounts,
      verification: authVerifications,
      rateLimit: authRateLimits,
    },
    // better-sqlite3 cannot run Drizzle's async transactions; operations run
    // sequentially instead (also the adapter default).
    transaction: false,
  }),

  advanced: {
    cookiePrefix: 'spanglish',
    useSecureCookies: isProduction,
    // Only adds a `Domain=` attribute — name, sameSite, secure and path are unchanged,
    // so the cookie names hardcoded in the frontend middleware and the Go photo-api
    // stay valid.
    ...(cookieDomain
      ? { crossSubDomainCookies: { enabled: true, domain: cookieDomain } }
      : {}),
    ipAddress: {
      // Set by the /api/auth/* mount in index.ts from getClientIp(), which
      // anchors trust in the TCP peer address (only our own proxies may speak
      // for the client via X-Real-IP / X-Forwarded-For). Never read the raw
      // forwarded headers here: without the socket they are spoofable, and
      // Better Auth would fall back to one shared rate-limit bucket.
      ipAddressHeaders: ['x-client-ip'],
    },
    database: {
      // Match the app's existing ID convention (uuid on pg, nanoid on sqlite)
      // so Better Auth rows are indistinguishable from legacy rows.
      generateId: () => generateId(),
    },
  },

  user: {
    additionalFields: {
      // `role`, `banned`, `banReason`, `banExpires` come from the admin plugin.
      phone: { type: 'string', required: false, input: true },
      languagePreference: { type: 'string', required: false, input: true },
      rucNumber: { type: 'string', required: false, input: false },
      isClaimed: { type: 'boolean', required: false, input: false, defaultValue: true },
      accountStatus: { type: 'string', required: false, input: false, defaultValue: 'active' },
    },
  },

  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days, rolling
    updateAge: 60 * 60 * 24, // refresh expiry at most once a day
    freshAge: 60 * 60 * 24, // sensitive operations require a session younger than this
    // Disabled deliberately: every request validates against the session table,
    // so ban/suspend/password-reset revocations apply instantly — and the Go
    // photo-api (which reads the same table) can never disagree with us.
    cookieCache: { enabled: false },
    storeSessionInDatabase: true,
  },

  emailAndPassword: {
    enabled: true,
    minPasswordLength: 10,
    maxPasswordLength: 128,
    autoSignIn: true,
    requireEmailVerification: false,
    revokeSessionsOnPasswordReset: true,
    resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes, matches the legacy flow
    sendResetPassword: async ({ user, url }) => {
      try {
        await sendEmail({
          to: user.email,
          subject: 'Reset Your Spanglish Password',
          html: `
        <h2>Reset Your Password</h2>
        <p>Click the link below to reset your password. This link expires in 30 minutes.</p>
        <p><a href="${url}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
        <p>Or copy this link: ${url}</p>
        <p>If you didn't request this, you can safely ignore this email.</p>
      `,
        });
      } catch (error) {
        console.error('Failed to send password reset email:', error);
      }
    },
    password: {
      // Keep the existing argon2id parameters; legacy bcrypt hashes (migrated
      // into auth_accounts) still verify and are upgraded on login below.
      hash: (password) => hashPassword(password),
      verify: ({ hash, password }) => verifyPassword(password, hash),
    },
  },

  ...(process.env.GOOGLE_CLIENT_ID
    ? {
        socialProviders: {
          google: {
            clientId: process.env.GOOGLE_CLIENT_ID,
            // Not required for ID-token (Google Identity Services) sign-in,
            // only for the redirect OAuth flow.
            clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
          },
        },
      }
    : {}),

  account: {
    accountLinking: {
      enabled: true,
      // Google verifies email ownership, so linking by email is safe — this
      // matches the legacy /api/auth/google auto-link behavior.
      trustedProviders: ['google'],
    },
  },

  rateLimit: {
    // Explicitly enabled so dev behaves like production (off in dev by default).
    enabled: true,
    window: 60,
    max: 100,
    // DB-backed rather than Redis: our Redis layer is fail-open by design,
    // which is the wrong default for auth rate limiting. The per-email login
    // lockout below is already Redis-shared across replicas.
    storage: 'database',
    modelName: 'rateLimit',
    customRules: {
      '/sign-in/email': { window: 900, max: 10 },
      '/sign-up/email': { window: 900, max: 10 },
      '/sign-in/magic-link': { window: 900, max: 5 },
      '/magic-link/verify': { window: 900, max: 30 },
      '/request-password-reset': { window: 900, max: 5 },
      '/reset-password': { window: 900, max: 10 },
      '/sign-in/social': { window: 900, max: 20 },
      '/change-password': { window: 900, max: 10 },
    },
  },

  databaseHooks: {
    user: {
      create: {
        before: async (user) => {
          // First user to register becomes admin (replaces isFirstUser())
          const existing = await dbAll<any>((db as any).select().from(authUsers).limit(1));
          if (!existing || existing.length === 0) {
            return { data: { ...user, role: 'admin' } };
          }
        },
      },
    },
  },

  hooks: {
    before: createAuthMiddleware(async (ctx) => {
      // Enforce the full password policy (character classes + blocklist) on
      // every password-setting path, for both client and server-side calls.
      if (PASSWORD_SETTING_PATHS.has(ctx.path)) {
        const password = ctx.body?.password ?? ctx.body?.newPassword;
        if (typeof password === 'string') {
          const result = validatePassword(password);
          if (!result.valid) {
            throw new APIError('BAD_REQUEST', { message: result.error });
          }
        }
      }

      // Per-email lockout: 5 failures / 15 min, Redis-shared when configured.
      // Kept as defense-in-depth on top of Better Auth's per-IP rate limits.
      if (ctx.path === '/sign-in/email' && typeof ctx.body?.email === 'string') {
        const lockout = await getLoginLockout().isLocked(ctx.body.email);
        if (lockout.locked) {
          throw new APIError('TOO_MANY_REQUESTS', {
            message: 'Too many login attempts. Please try again later.',
          });
        }
      }
    }),
    after: createAuthMiddleware(async (ctx) => {
      if (ctx.path !== '/sign-in/email' || typeof ctx.body?.email !== 'string') return;
      const email = ctx.body.email as string;

      if (ctx.context.returned instanceof APIError) {
        // Failed sign-in attempt counts toward the per-email lockout
        await getLoginLockout().recordFailure(email);
        return;
      }

      await getLoginLockout().clear(email);

      // Transparently upgrade legacy bcrypt hashes to argon2 now that we have
      // the verified plaintext. Best-effort: never block the login.
      try {
        const password = ctx.body?.password;
        const userId = (ctx.context.newSession?.user as any)?.id;
        if (typeof password === 'string' && userId) {
          const account = await getCredentialAccount(userId);
          if (account?.password && !String(account.password).startsWith('$argon2')) {
            const upgraded = await hashPassword(password);
            await (db as any)
              .update(authAccounts)
              .set({ password: upgraded, updatedAt: new Date() })
              .where(eq((authAccounts as any).id, account.id));
          }
        }
      } catch (err: any) {
        console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
      }
    }),
  },

  plugins: [
    magicLink({
      expiresIn: 60 * 10, // 10 minutes, matches the legacy flow
      // Magic links never create accounts (parity with the legacy behavior;
      // account creation is register / Google / guest booking only).
      disableSignUp: true,
      // Hashed at rest: a leaked verification table cannot be replayed.
      storeToken: 'hashed',
      sendMagicLink: async ({ email, url, token }) => {
        // Email a frontend URL (not the raw API verify URL) so the login
        // completes on the site, preserving the legacy UX. The page calls
        // authClient.magicLink.verify with the token.
        let callbackURL = '/';
        try {
          callbackURL = new URL(url).searchParams.get('callbackURL') || '/';
        } catch {
          /* default */
        }
        const link = `${frontendUrl}/auth/magic-link?token=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callbackURL)}`;
        const isClaim = callbackURL.startsWith('/auth/claim-account');
        try {
          await sendEmail({
            to: email,
            subject: isClaim ? 'Claim Your Spanglish Account' : 'Your Spanglish Login Link',
            html: isClaim
              ? `
        <h2>Claim Your Account</h2>
        <p>An account was created for you during booking. Click below to set up your login credentials. This link expires in 10 minutes.</p>
        <p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
        <p>Or copy this link: ${link}</p>
        <p>If you didn't request this, you can safely ignore this email.</p>
      `
              : `
        <h2>Login to Spanglish</h2>
        <p>Click the link below to log in. This link expires in 10 minutes.</p>
        <p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
        <p>Or copy this link: ${link}</p>
        <p>If you didn't request this, you can safely ignore this email.</p>
      `,
          });
        } catch (error) {
          console.error('Failed to send magic link email:', error);
        }
      },
    }),
    admin({
      defaultRole: 'user',
      adminRoles: ['admin'],
      bannedUserMessage: 'Account is suspended. Please contact support.',
    }),
  ],
});

export type Auth = typeof auth;
