import crypto from 'node:crypto';
import { config } from '../../core/config.js';
import { currentSessionFromContext, hydrateRequestAuth } from '../../core/auth-context.js';
import { query, transaction } from '../../core/db.js';
import { HttpError, type RequestContext } from '../../core/http.js';
import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js';
import { createSmsProvider } from './providers.js';
import {
  loadTenantAuthProviderConfig,
  optionalPublicString,
  providerEndpoint,
  providerEndpointForKeys,
  requirePublicString,
  requireSecretString,
} from '../../core/tenant-provider-config.js';
import {
  assertChinaPhone,
  clientIpFrom,
  createLoginSession,
  generateSmsCode,
  hashSmsCode,
  normalizePurpose,
  upsertOAuthUser,
  upsertPhoneUser,
  userAgentFrom,
  writeLoginEvent,
  type PlatformUserSummary,
} from './service.js';

interface SmsCodeRow {
  id: string;
  codeHash: string;
  attempts: number;
  expiresAt: string;
  status: string;
}

interface CooldownRow {
  createdAt: string;
}

type SmsVerifyResult =
  | {
      ok: false;
      statusCode: number;
      message: string;
      code: string;
    }
  | {
      ok: true;
      verified: true;
      purpose: string;
      phone: string;
      user?: unknown;
      isNewUser?: boolean;
      session?: unknown;
    };

type PhoneBindResult =
  | Extract<SmsVerifyResult, { ok: false }>
  | {
      ok: true;
      user: PlatformUserSummary;
      phoneChanged: boolean;
      revokedOtherSessions: number;
    };

function hashEquals(left: string, right: string) {
  const leftBuffer = Buffer.from(left, 'hex');
  const rightBuffer = Buffer.from(right, 'hex');
  return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}

function jsonObject(value: unknown) {
  return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}

async function consumeSmsCode(
  client: import('pg').PoolClient,
  input: {
    tenantId: string;
    phone: string;
    purpose: string;
    code: string;
    ipAddress: string;
    userAgent: string;
  },
): Promise<{ ok: true } | Extract<SmsVerifyResult, { ok: false }>> {
  const expectedHash = hashSmsCode(input.tenantId, input.phone, input.purpose, input.code);
  const codeResult = await client.query<SmsCodeRow>(
    `
    select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status
    from public.sms_verification_codes
    where tenant_id = $1
      and phone = $2
      and purpose = $3
      and consumed_at is null
      and status in ('pending', 'sent')
    order by created_at desc
    limit 1
    for update
    `,
    [input.tenantId, input.phone, input.purpose],
  );

  const smsCode = codeResult.rows[0];
  if (!smsCode) {
    await writeLoginEvent(client, {
      tenantId: input.tenantId,
      provider: 'sms',
      identifier: input.phone,
      result: 'failed',
      failureCode: 'SMS_CODE_NOT_FOUND',
      ipAddress: input.ipAddress,
      userAgent: input.userAgent,
      metadata: { purpose: input.purpose },
    });
    return { ok: false, statusCode: 400, message: 'SMS code not found or already used', code: 'SMS_CODE_NOT_FOUND' };
  }

  if (new Date(smsCode.expiresAt).getTime() <= Date.now()) {
    await client.query(
      `
      update public.sms_verification_codes
      set status = 'expired'
      where tenant_id = $1 and id = $2
      `,
      [input.tenantId, smsCode.id],
    );
    await writeLoginEvent(client, {
      tenantId: input.tenantId,
      provider: 'sms',
      identifier: input.phone,
      result: 'failed',
      failureCode: 'SMS_CODE_EXPIRED',
      ipAddress: input.ipAddress,
      userAgent: input.userAgent,
      metadata: { purpose: input.purpose },
    });
    return { ok: false, statusCode: 400, message: 'SMS code expired', code: 'SMS_CODE_EXPIRED' };
  }

  const matched = hashEquals(smsCode.codeHash, expectedHash);
  if (!matched) {
    const nextAttempts = smsCode.attempts + 1;
    const blocked = nextAttempts >= 5;
    await client.query(
      `
      update public.sms_verification_codes
      set attempts = attempts + 1,
          status = case when $3::boolean then 'blocked' else status end
      where tenant_id = $1 and id = $2
      `,
      [input.tenantId, smsCode.id, blocked],
    );
    await writeLoginEvent(client, {
      tenantId: input.tenantId,
      provider: 'sms',
      identifier: input.phone,
      result: blocked ? 'blocked' : 'failed',
      failureCode: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
      ipAddress: input.ipAddress,
      userAgent: input.userAgent,
      metadata: { purpose: input.purpose, attempts: nextAttempts },
    });
    return {
      ok: false,
      statusCode: blocked ? 429 : 400,
      message: blocked ? 'SMS code attempts exceeded' : 'Invalid SMS code',
      code: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
    };
  }

  await client.query(
    `
    update public.sms_verification_codes
    set status = 'verified', consumed_at = now()
    where tenant_id = $1 and id = $2
    `,
    [input.tenantId, smsCode.id],
  );

  return { ok: true };
}

function normalizeSmsProviderName(value: string) {
  const normalized = value.toLowerCase().replace(/_/g, '-');
  if (normalized === 'aliyun' || normalized === 'aliyun-sms') return 'aliyun';
  if (normalized === 'tencent' || normalized === 'tencent-sms') return 'tencent';
  return 'mock';
}

function smsProviderAliases(value: string) {
  const provider = normalizeSmsProviderName(value);
  if (provider === 'aliyun') return ['aliyun', 'aliyun-sms', 'aliyun_sms'];
  if (provider === 'tencent') return ['tencent', 'tencent-sms', 'tencent_sms'];
  return ['mock'];
}

async function activeSmsProvider(tenantId: string) {
  if (config.authSmsProvider !== 'mock') {
    const name = normalizeSmsProviderName(config.authSmsProvider);
    return {
      name,
      providerConfig: name === 'mock' ? null : await loadTenantAuthProviderConfig(tenantId, smsProviderAliases(name)),
    };
  }

  const rows = await query<{ provider: string }>(
    `
    select provider
    from public.tenant_auth_providers
    where tenant_id = $1
      and provider in ('aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms', 'mock')
      and status in ('active', 'testing')
    order by case
      when provider in ('aliyun', 'aliyun-sms', 'aliyun_sms') then 0
      when provider in ('tencent', 'tencent-sms', 'tencent_sms') then 1
      else 2
    end
    limit 1
    `,
    [tenantId],
  );

  const name = normalizeSmsProviderName(rows[0]?.provider || config.authSmsProvider);
  return {
    name,
    providerConfig: name === 'mock' ? null : await loadTenantAuthProviderConfig(tenantId, smsProviderAliases(name)),
  };
}

export async function sendSmsCodeRoute(ctx: RequestContext) {
  const tenantId = await tenantIdFrom(ctx);
  const body = await readJsonBody(ctx);
  const phone = assertChinaPhone(requiredString(body, 'phone'));
  const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
  const ipAddress = clientIpFrom(ctx);
  const userAgent = userAgentFrom(ctx);
  const metadata = jsonObject(body.metadata);

  const cooldownRows = await query<CooldownRow>(
    `
    select created_at as "createdAt"
    from public.sms_verification_codes
    where tenant_id = $1
      and phone = $2
      and purpose = $3
      and consumed_at is null
      and status in ('pending', 'sent')
      and created_at > now() - ($4::text || ' seconds')::interval
    order by created_at desc
    limit 1
    `,
    [tenantId, phone, purpose, config.authSmsCooldownSeconds],
  );

  const cooldownRow = cooldownRows[0];
  if (cooldownRow) {
    const elapsedSeconds = Math.floor((Date.now() - new Date(cooldownRow.createdAt).getTime()) / 1000);
    const cooldown = Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds);
    throw new HttpError(429, `SMS code was sent too frequently. Retry after ${cooldown} seconds.`, 'SMS_COOLDOWN');
  }

  const providerChoice = await activeSmsProvider(tenantId);
  const provider = createSmsProvider(providerChoice.name, providerChoice.providerConfig);
  if (config.isProduction && provider.name === 'mock') {
    throw new HttpError(503, 'SMS provider is not configured for production', 'SMS_PROVIDER_REQUIRED');
  }

  const code = generateSmsCode();
  const providerResult = await provider.send({ tenantId, phone, code, purpose, metadata });
  const codeHash = hashSmsCode(tenantId, phone, purpose, code);
  const expiresAt = new Date(Date.now() + config.authCodeTtlSeconds * 1000).toISOString();

  const item = await transaction(async client => {
    const insertResult = await client.query(
      `
      insert into public.sms_verification_codes (
        tenant_id, phone, purpose, code_hash, provider, status, expires_at,
        ip_address, user_agent, metadata
      )
      values ($1, $2, $3, $4, $5, 'sent', $6::timestamptz, $7, $8, $9::jsonb)
      returning id, phone, purpose, provider, status, expires_at as "expiresAt", created_at as "createdAt"
      `,
      [
        tenantId,
        phone,
        purpose,
        codeHash,
        providerResult.provider,
        expiresAt,
        ipAddress || null,
        userAgent || null,
        JSON.stringify({
          ...metadata,
          providerStatus: providerResult.status,
          providerMessageId: providerResult.providerMessageId || null,
        }),
      ],
    );

    await writeLoginEvent(client, {
      tenantId,
      provider: `sms:${providerResult.provider}`,
      identifier: phone,
      result: 'sent',
      ipAddress,
      userAgent,
      metadata: { purpose },
    });

    return insertResult.rows[0];
  });

  return {
    item,
    expireIn: config.authCodeTtlSeconds,
    cooldown: config.authSmsCooldownSeconds,
    debugCode: provider.name === 'mock' && !config.isProduction ? code : undefined,
  };
}

export async function verifySmsCodeRoute(ctx: RequestContext) {
  const tenantId = await tenantIdFrom(ctx);
  const body = await readJsonBody(ctx);
  const phone = assertChinaPhone(requiredString(body, 'phone'));
  const code = requiredString(body, 'code');
  const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
  const ipAddress = clientIpFrom(ctx);
  const userAgent = userAgentFrom(ctx);

  const result = await transaction<SmsVerifyResult>(async client => {
    const consumed = await consumeSmsCode(client, { tenantId, phone, purpose, code, ipAddress, userAgent });
    if (!consumed.ok) return consumed;

    if (purpose !== 'login') {
      await writeLoginEvent(client, {
        tenantId,
        provider: 'sms',
        identifier: phone,
        result: 'success',
        ipAddress,
        userAgent,
        metadata: { purpose, sessionIssued: false },
      });
      return { ok: true, verified: true, purpose, phone };
    }

    const { user, isNewUser } = await upsertPhoneUser(client, { tenantId, phone });
    const session = await createLoginSession(client, {
      tenantId,
      userId: user.id,
      provider: 'sms',
      ipAddress,
      userAgent,
      metadata: { purpose },
    });

    await writeLoginEvent(client, {
      tenantId,
      userId: user.id,
      provider: 'sms',
      identifier: phone,
      result: 'success',
      ipAddress,
      userAgent,
      metadata: { purpose, isNewUser },
    });

    return { ok: true, verified: true, purpose, phone, user, isNewUser, session };
  });

  if (!result.ok) {
    throw new HttpError(result.statusCode, result.message, result.code);
  }

  return result;
}

export async function meRoute(ctx: RequestContext) {
  const tenantId = await tenantIdFrom(ctx);
  await hydrateRequestAuth(ctx);

  const session = currentSessionFromContext(ctx);
  if (!session) {
    throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
  }
  if (session.tenantId !== tenantId) {
    throw new HttpError(403, 'Session does not belong to this tenant', 'AUTH_TENANT_MISMATCH');
  }

  return {
    user: {
      id: session.id,
      username: session.username,
      phone: session.phone,
      name: session.name,
      avatarUrl: session.avatarUrl,
      primaryRole: session.primaryRole,
      createdAt: session.createdAt,
    },
    session: {
      id: session.sessionId,
      expiresAt: session.sessionExpiresAt,
      source: session.authSource,
    },
  };
}

export async function logoutRoute(ctx: RequestContext) {
  const tenantId = await tenantIdFrom(ctx);
  await hydrateRequestAuth(ctx);
  const session = currentSessionFromContext(ctx);
  if (session?.authSource === 'app_session' && session.tenantId === tenantId) {
    await query(
      `
      update app_private.auth_sessions
      set revoked_at = now(), updated_at = now()
      where id = $1
      `,
      [session.sessionId],
    );
  }

  return { ok: true };
}

export async function bindPhoneRoute(ctx: RequestContext) {
  const tenantId = await tenantIdFrom(ctx);
  await hydrateRequestAuth(ctx);
  const session = currentSessionFromContext(ctx);
  if (!session) {
    throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
  }
  if (session.tenantId !== tenantId) {
    throw new HttpError(403, 'Session does not belong to this tenant', 'AUTH_TENANT_MISMATCH');
  }

  const body = await readJsonBody(ctx);
  const phone = assertChinaPhone(requiredString(body, 'phone'));
  const code = requiredString(body, 'code');
  const purpose = normalizePurpose(optionalString(body, 'purpose') || 'bind_phone');
  if (purpose !== 'bind_phone') {
    throw new HttpError(400, 'Phone binding requires a bind_phone SMS code', 'PHONE_BIND_PURPOSE_REQUIRED');
  }

  const ipAddress = clientIpFrom(ctx);
  const userAgent = userAgentFrom(ctx);
  const result = await transaction<PhoneBindResult>(async client => {
    const consumed = await consumeSmsCode(client, { tenantId, phone, purpose, code, ipAddress, userAgent });
    if (!consumed.ok) return consumed;

    const userResult = await client.query<PlatformUserSummary>(
      `
      select id, username, phone, name, avatar_url as "avatarUrl",
             primary_role as "primaryRole", created_at as "createdAt"
      from public.platform_users
      where id = $1
      limit 1
      for update
      `,
      [session.id],
    );
    const currentUser = userResult.rows[0];
    if (!currentUser) {
      return { ok: false, statusCode: 401, message: 'Authenticated user was not found', code: 'AUTH_USER_NOT_FOUND' };
    }

    const identityConflict = await client.query<{ userId: string }>(
      `
      select user_id as "userId"
      from public.user_identities
      where provider = 'phone'
        and provider_subject = $1
      limit 1
      for update
      `,
      [phone],
    );
    if (identityConflict.rows[0] && identityConflict.rows[0].userId !== session.id) {
      await writeLoginEvent(client, {
        tenantId,
        userId: session.id,
        provider: 'sms',
        identifier: phone,
        result: 'failed',
        failureCode: 'PHONE_ALREADY_BOUND',
        ipAddress,
        userAgent,
        metadata: { purpose, bindAttempt: true },
      });
      return { ok: false, statusCode: 409, message: 'Phone number is already bound to another account', code: 'PHONE_ALREADY_BOUND' };
    }

    const profileConflict = await client.query<{ id: string }>(
      `
      select id
      from public.platform_users
      where phone = $1 and id <> $2
      limit 1
      `,
      [phone, session.id],
    );
    if (profileConflict.rows[0]) {
      await writeLoginEvent(client, {
        tenantId,
        userId: session.id,
        provider: 'sms',
        identifier: phone,
        result: 'failed',
        failureCode: 'PHONE_ALREADY_BOUND',
        ipAddress,
        userAgent,
        metadata: { purpose, bindAttempt: true, conflictSource: 'platform_users' },
      });
      return { ok: false, statusCode: 409, message: 'Phone number is already bound to another account', code: 'PHONE_ALREADY_BOUND' };
    }

    await client.query(
      `
      delete from public.user_identities
      where user_id = $1
        and provider = 'phone'
        and provider_subject <> $2
      `,
      [session.id, phone],
    );

    const identityResult = await client.query(
      `
      insert into public.user_identities (user_id, provider, provider_subject, phone)
      values ($1, 'phone', $2, $2)
      on conflict (provider, provider_subject)
      do update set phone = excluded.phone,
                    updated_at = now()
      where public.user_identities.user_id = excluded.user_id
      returning id
      `,
      [session.id, phone],
    );
    if (!identityResult.rows[0]) {
      return { ok: false, statusCode: 409, message: 'Phone number is already bound to another account', code: 'PHONE_ALREADY_BOUND' };
    }

    const updatedUser = await client.query<PlatformUserSummary>(
      `
      update public.platform_users
      set phone = $2,
          raw_profile = coalesce(raw_profile, '{}'::jsonb) || $3::jsonb,
          updated_at = now()
      where id = $1
      returning id, username, phone, name, avatar_url as "avatarUrl",
                primary_role as "primaryRole", created_at as "createdAt"
      `,
      [
        session.id,
        phone,
        JSON.stringify({
          phoneBound: true,
          phoneBoundAt: new Date().toISOString(),
          phoneBindTenantId: tenantId,
        }),
      ],
    );

    await client.query(
      `
      update public.user_identities
      set phone = $2,
          updated_at = now()
      where user_id = $1
        and provider <> 'phone'
      `,
      [session.id, phone],
    );

    const revoked = await client.query(
      `
      update app_private.auth_sessions
      set revoked_at = now(),
          updated_at = now(),
          metadata = metadata || $4::jsonb
      where tenant_id = $1
        and user_id = $2
        and revoked_at is null
        and ($3::uuid is null or id <> $3::uuid)
      returning id
      `,
      [
        tenantId,
        session.id,
        session.authSource === 'app_session' ? session.sessionId : null,
        JSON.stringify({
          revokedBy: 'phone_bind',
          revokedAt: new Date().toISOString(),
        }),
      ],
    );

    await writeLoginEvent(client, {
      tenantId,
      userId: session.id,
      provider: 'sms',
      identifier: phone,
      result: 'success',
      ipAddress,
      userAgent,
      metadata: {
        purpose,
        phoneChanged: currentUser.phone !== phone,
        previousPhoneMasked: currentUser.phone ? `${currentUser.phone.slice(0, 3)}****${currentUser.phone.slice(-4)}` : null,
        revokedOtherSessions: revoked.rowCount || 0,
      },
    });

    return {
      ok: true,
      user: updatedUser.rows[0],
      phoneChanged: currentUser.phone !== phone,
      revokedOtherSessions: revoked.rowCount || 0,
    };
  });

  if (!result.ok) {
    throw new HttpError(result.statusCode, result.message, result.code);
  }

  return {
    ok: true,
    user: result.user,
    phoneChanged: result.phoneChanged,
    revokedOtherSessions: result.revokedOtherSessions,
  };
}

function normalizeOAuthProvider(value: string) {
  const normalized = value.toLowerCase().replace(/_/g, '-');
  if (normalized === 'wechat' || normalized === 'wechat-web') return 'wechat-web';
  if (normalized === 'wechat-miniapp' || normalized === 'wechat-mini') return 'wechat-miniapp';
  if (normalized === 'qq' || normalized === 'qq-oauth') return 'qq';
  return normalized;
}

function oauthProviderAliases(value: string) {
  const provider = normalizeOAuthProvider(value);
  if (provider === 'wechat-miniapp') return ['wechat-miniapp', 'wechat_miniapp', 'wechat-mini', 'wechatMiniapp'];
  if (provider === 'wechat-web') return ['wechat-web', 'wechat_web', 'wechat'];
  if (provider === 'qq') return ['qq', 'qq-oauth', 'qq_oauth'];
  return [provider];
}

function stringField(source: Record<string, unknown>, keys: string[]) {
  for (const key of keys) {
    const value = source[key];
    if (typeof value === 'string' && value.trim()) return value.trim();
  }
  return '';
}

function intField(source: Record<string, unknown>, keys: string[]) {
  const value = stringField(source, keys) || keys.map(key => source[key]).find(item => typeof item === 'number');
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : null;
}

function sanitizeOAuthProfilePayload(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(item => sanitizeOAuthProfilePayload(item));
  if (!value || typeof value !== 'object') return value;
  const sanitized: Record<string, unknown> = {};
  for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
    const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
    if (
      normalized.includes('accesstoken') ||
      normalized.includes('refreshtoken') ||
      normalized.includes('sessionkey') ||
      normalized.includes('secret') ||
      normalized.includes('clientsecret') ||
      normalized.includes('avatar') ||
      normalized.includes('headimg') ||
      normalized.includes('figureurl')
    ) {
      continue;
    }
    sanitized[key] = sanitizeOAuthProfilePayload(child);
  }
  return sanitized;
}

async function parseOAuthProviderResponse(response: Response) {
  const text = await response.text();
  const trimmed = text.trim();
  if (!trimmed) return {};

  const tryJson = (candidate: string) => {
    try {
      return jsonObject(JSON.parse(candidate));
    } catch {
      return null;
    }
  };

  const directJson = tryJson(trimmed);
  if (directJson) return directJson;

  const jsonStart = trimmed.indexOf('{');
  const jsonEnd = trimmed.lastIndexOf('}');
  if (jsonStart >= 0 && jsonEnd > jsonStart) {
    const jsonpJson = tryJson(trimmed.slice(jsonStart, jsonEnd + 1));
    if (jsonpJson) return jsonpJson;
  }

  const params = new URLSearchParams(trimmed);
  const parsed = Object.fromEntries(params.entries());
  return jsonObject(parsed);
}

async function callWechatWebAccessToken(input: {
  endpoint: string;
  appId: string;
  appSecret: string;
  code: string;
}) {
  const url = new URL(input.endpoint);
  url.searchParams.set('appid', input.appId);
  url.searchParams.set('secret', input.appSecret);
  url.searchParams.set('code', input.code);
  url.searchParams.set('grant_type', 'authorization_code');

  const response = await fetch(url);
  const raw = await parseOAuthProviderResponse(response);
  const errcode = Number(raw.errcode || 0);
  if (!response.ok || errcode) {
    const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : '';
    throw new HttpError(401, `WeChat web code exchange failed${suffix}`, 'WECHAT_WEB_CODE_EXCHANGE_FAILED');
  }

  const accessToken = stringField(raw, ['access_token', 'accessToken']);
  const openId = stringField(raw, ['openid', 'openId']);
  const unionId = stringField(raw, ['unionid', 'unionId']);
  const scope = stringField(raw, ['scope']);
  const expiresIn = intField(raw, ['expires_in', 'expiresIn']);
  if (!accessToken || !openId) {
    throw new HttpError(401, 'WeChat web token response is missing access_token or openid', 'WECHAT_WEB_CODE_EXCHANGE_INVALID');
  }

  return { accessToken, openId, unionId, scope, expiresIn };
}

async function callWechatWebUserInfo(input: {
  endpoint: string;
  accessToken: string;
  openId: string;
  lang: string;
}) {
  const url = new URL(input.endpoint);
  url.searchParams.set('access_token', input.accessToken);
  url.searchParams.set('openid', input.openId);
  url.searchParams.set('lang', input.lang);

  const response = await fetch(url);
  const raw = await parseOAuthProviderResponse(response);
  const errcode = Number(raw.errcode || 0);
  if (!response.ok || errcode) {
    const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : '';
    throw new HttpError(401, `WeChat web userinfo failed${suffix}`, 'WECHAT_WEB_USERINFO_FAILED');
  }

  const nickname = stringField(raw, ['nickname', 'name']);
  const unionId = stringField(raw, ['unionid', 'unionId']);
  return {
    nickname,
    unionId,
    profile: {
      ...jsonObject(sanitizeOAuthProfilePayload(raw)),
      nickname,
    },
  };
}

async function callWechatCode2Session(input: {
  endpoint: string;
  appId: string;
  appSecret: string;
  code: string;
}) {
  const url = new URL(input.endpoint);
  url.searchParams.set('appid', input.appId);
  url.searchParams.set('secret', input.appSecret);
  url.searchParams.set('js_code', input.code);
  url.searchParams.set('grant_type', 'authorization_code');

  const response = await fetch(url);
  const raw = jsonObject(await response.json().catch(() => ({})));
  const errcode = Number(raw.errcode || 0);
  if (!response.ok || errcode) {
    const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : '';
    throw new HttpError(401, `WeChat miniapp code exchange failed${suffix}`, 'WECHAT_CODE_EXCHANGE_FAILED');
  }

  const openId = typeof raw.openid === 'string' ? raw.openid.trim() : '';
  const sessionKey = typeof raw.session_key === 'string' ? raw.session_key.trim() : '';
  const unionId = typeof raw.unionid === 'string' ? raw.unionid.trim() : '';
  if (!openId || !sessionKey) {
    throw new HttpError(401, 'WeChat miniapp response is missing openid or session_key', 'WECHAT_CODE_EXCHANGE_INVALID');
  }

  return { openId, sessionKey, unionId, raw };
}

export async function wechatMiniappLoginRoute(ctx: RequestContext) {
  const tenantId = await tenantIdFrom(ctx);
  const body = await readJsonBody(ctx);
  const code = requiredString(body, 'code');
  const profile = jsonObject(body.profile);
  const ipAddress = clientIpFrom(ctx);
  const userAgent = userAgentFrom(ctx);
  const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('wechat-miniapp'));
  if (!providerConfig) {
    throw new HttpError(503, 'WeChat miniapp auth provider is not configured', 'PROVIDER_NOT_CONFIGURED');
  }

  const appId = requirePublicString(providerConfig, ['appId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED');
  const appSecret = requireSecretString(providerConfig, ['appSecret', 'secret'], 'OAUTH_SECRET_REQUIRED');
  const endpoint = providerEndpoint(
    providerConfig,
    'https://api.weixin.qq.com/sns/jscode2session',
    ['weixin.qq.com'],
    'OAUTH_ENDPOINT_NOT_ALLOWED',
  );
  const exchanged = await callWechatCode2Session({ endpoint, appId, appSecret, code });
  const provider = 'wechat-miniapp';
  const providerSubject = `${appId}:${exchanged.openId}`;

  const result = await transaction(async client => {
    const { user, isNewUser } = await upsertOAuthUser(client, {
      tenantId,
      provider,
      providerSubject,
      openId: exchanged.openId,
      unionId: exchanged.unionId || null,
      profile,
      secretPayload: {
        hasSessionKey: true,
        sessionKeyUpdatedAt: new Date().toISOString(),
      },
    });
    const session = await createLoginSession(client, {
      tenantId,
      userId: user.id,
      provider,
      ipAddress,
      userAgent,
      metadata: {
        appId,
        openId: exchanged.openId,
        unionId: exchanged.unionId || null,
        isNewUser,
      },
    });

    await writeLoginEvent(client, {
      tenantId,
      userId: user.id,
      provider,
      identifier: exchanged.openId,
      result: 'success',
      ipAddress,
      userAgent,
      metadata: {
        appId,
        unionId: exchanged.unionId || null,
        isNewUser,
      },
    });

    return {
      provider,
      user,
      isNewUser,
      session,
      identity: {
        provider,
        openId: exchanged.openId,
        unionId: exchanged.unionId || null,
      },
    };
  });

  return result;
}

export async function wechatWebLoginRoute(ctx: RequestContext) {
  const tenantId = await tenantIdFrom(ctx);
  const body = await readJsonBody(ctx);
  const code = requiredString(body, 'code');
  const clientProfile = jsonObject(body.profile);
  const lang = optionalString(body, 'lang') || 'zh_CN';
  const ipAddress = clientIpFrom(ctx);
  const userAgent = userAgentFrom(ctx);
  const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('wechat-web'));
  if (!providerConfig) {
    throw new HttpError(503, 'WeChat web auth provider is not configured', 'PROVIDER_NOT_CONFIGURED');
  }

  const appId = requirePublicString(providerConfig, ['appId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED');
  const appSecret = requireSecretString(providerConfig, ['appSecret', 'clientSecret', 'secret'], 'OAUTH_SECRET_REQUIRED');
  const tokenEndpoint = providerEndpointForKeys(
    providerConfig,
    ['tokenEndpoint', 'accessTokenEndpoint', 'endpoint'],
    'https://api.weixin.qq.com/sns/oauth2/access_token',
    ['weixin.qq.com'],
    'OAUTH_ENDPOINT_NOT_ALLOWED',
  );
  const userInfoEndpoint = providerEndpointForKeys(
    providerConfig,
    ['userInfoEndpoint', 'userinfoEndpoint'],
    'https://api.weixin.qq.com/sns/userinfo',
    ['weixin.qq.com'],
    'OAUTH_ENDPOINT_NOT_ALLOWED',
  );

  const token = await callWechatWebAccessToken({ endpoint: tokenEndpoint, appId, appSecret, code });
  const userInfo = await callWechatWebUserInfo({
    endpoint: userInfoEndpoint,
    accessToken: token.accessToken,
    openId: token.openId,
    lang,
  });
  const provider = 'wechat_web';
  const unionId = userInfo.unionId || token.unionId || null;
  const providerSubject = `${appId}:${token.openId}`;
  const profile = {
    ...userInfo.profile,
    ...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
    nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
  };

  return transaction(async client => {
    const { user, isNewUser } = await upsertOAuthUser(client, {
      tenantId,
      provider,
      providerSubject,
      openId: token.openId,
      unionId,
      profile,
      secretPayload: {
        tokenSeenAt: new Date().toISOString(),
        scope: token.scope || null,
        expiresIn: token.expiresIn,
      },
    });
    const session = await createLoginSession(client, {
      tenantId,
      userId: user.id,
      provider,
      ipAddress,
      userAgent,
      metadata: {
        appId,
        openId: token.openId,
        unionId,
        isNewUser,
      },
    });

    await writeLoginEvent(client, {
      tenantId,
      userId: user.id,
      provider,
      identifier: token.openId,
      result: 'success',
      ipAddress,
      userAgent,
      metadata: {
        appId,
        unionId,
        isNewUser,
      },
    });

    return {
      provider,
      user,
      isNewUser,
      session,
      identity: {
        provider,
        openId: token.openId,
        unionId,
      },
    };
  });
}

async function callQqAccessToken(input: {
  endpoint: string;
  appId: string;
  appSecret: string;
  code: string;
  redirectUri: string;
}) {
  const url = new URL(input.endpoint);
  url.searchParams.set('grant_type', 'authorization_code');
  url.searchParams.set('client_id', input.appId);
  url.searchParams.set('client_secret', input.appSecret);
  url.searchParams.set('code', input.code);
  url.searchParams.set('redirect_uri', input.redirectUri);
  url.searchParams.set('fmt', 'json');

  const response = await fetch(url);
  const raw = await parseOAuthProviderResponse(response);
  const error = stringField(raw, ['error']);
  if (!response.ok || error) {
    throw new HttpError(401, 'QQ code exchange failed', 'QQ_CODE_EXCHANGE_FAILED');
  }

  const accessToken = stringField(raw, ['access_token', 'accessToken']);
  const expiresIn = intField(raw, ['expires_in', 'expiresIn']);
  if (!accessToken) {
    throw new HttpError(401, 'QQ token response is missing access_token', 'QQ_CODE_EXCHANGE_INVALID');
  }

  return { accessToken, expiresIn };
}

async function callQqOpenId(input: { endpoint: string; appId: string; accessToken: string }) {
  const url = new URL(input.endpoint);
  url.searchParams.set('access_token', input.accessToken);
  url.searchParams.set('fmt', 'json');

  const response = await fetch(url);
  const raw = await parseOAuthProviderResponse(response);
  const error = stringField(raw, ['error']);
  if (!response.ok || error) {
    throw new HttpError(401, 'QQ openid exchange failed', 'QQ_OPENID_EXCHANGE_FAILED');
  }

  const openId = stringField(raw, ['openid', 'openId']);
  const clientId = stringField(raw, ['client_id', 'clientId']);
  if (!openId) {
    throw new HttpError(401, 'QQ openid response is missing openid', 'QQ_OPENID_EXCHANGE_INVALID');
  }
  if (clientId && clientId !== input.appId) {
    throw new HttpError(401, 'QQ openid response client_id does not match appId', 'QQ_OPENID_CLIENT_MISMATCH');
  }

  return { openId, clientId };
}

async function callQqUserInfo(input: {
  endpoint: string;
  appId: string;
  accessToken: string;
  openId: string;
}) {
  const url = new URL(input.endpoint);
  url.searchParams.set('access_token', input.accessToken);
  url.searchParams.set('oauth_consumer_key', input.appId);
  url.searchParams.set('openid', input.openId);
  url.searchParams.set('fmt', 'json');

  const response = await fetch(url);
  const raw = await parseOAuthProviderResponse(response);
  const ret = Number(raw.ret || 0);
  if (!response.ok || ret !== 0) {
    throw new HttpError(401, 'QQ userinfo failed', 'QQ_USERINFO_FAILED');
  }

  const nickname = stringField(raw, ['nickname', 'name']);
  return {
    nickname,
    profile: {
      ...jsonObject(sanitizeOAuthProfilePayload(raw)),
      nickname,
    },
  };
}

export async function qqLoginRoute(ctx: RequestContext) {
  const tenantId = await tenantIdFrom(ctx);
  const body = await readJsonBody(ctx);
  const code = requiredString(body, 'code');
  const ipAddress = clientIpFrom(ctx);
  const userAgent = userAgentFrom(ctx);
  const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('qq'));
  if (!providerConfig) {
    throw new HttpError(503, 'QQ auth provider is not configured', 'PROVIDER_NOT_CONFIGURED');
  }

  const appId = requirePublicString(providerConfig, ['appId', 'clientId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED');
  const appSecret = requireSecretString(providerConfig, ['appSecret', 'clientSecret', 'secret'], 'OAUTH_SECRET_REQUIRED');
  const redirectUri = optionalPublicString(providerConfig, ['redirectUri', 'callbackUrl']) || optionalString(body, 'redirectUri');
  if (!redirectUri) {
    throw new HttpError(400, 'QQ OAuth redirectUri is required for code exchange', 'OAUTH_REDIRECT_URI_REQUIRED');
  }
  const tokenEndpoint = providerEndpointForKeys(
    providerConfig,
    ['tokenEndpoint', 'accessTokenEndpoint', 'endpoint'],
    'https://graph.qq.com/oauth2.0/token',
    ['graph.qq.com'],
    'OAUTH_ENDPOINT_NOT_ALLOWED',
  );
  const openIdEndpoint = providerEndpointForKeys(
    providerConfig,
    ['openIdEndpoint', 'openidEndpoint'],
    'https://graph.qq.com/oauth2.0/me',
    ['graph.qq.com'],
    'OAUTH_ENDPOINT_NOT_ALLOWED',
  );
  const userInfoEndpoint = providerEndpointForKeys(
    providerConfig,
    ['userInfoEndpoint', 'userinfoEndpoint'],
    'https://graph.qq.com/user/get_user_info',
    ['graph.qq.com'],
    'OAUTH_ENDPOINT_NOT_ALLOWED',
  );

  const token = await callQqAccessToken({ endpoint: tokenEndpoint, appId, appSecret, code, redirectUri });
  const openId = await callQqOpenId({ endpoint: openIdEndpoint, appId, accessToken: token.accessToken });
  const userInfo = await callQqUserInfo({
    endpoint: userInfoEndpoint,
    appId,
    accessToken: token.accessToken,
    openId: openId.openId,
  });
  const provider = 'qq';
  const providerSubject = `${appId}:${openId.openId}`;
  const clientProfile = jsonObject(body.profile);
  const profile = {
    ...userInfo.profile,
    ...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
    nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
  };

  return transaction(async client => {
    const { user, isNewUser } = await upsertOAuthUser(client, {
      tenantId,
      provider,
      providerSubject,
      openId: openId.openId,
      profile,
      secretPayload: {
        tokenSeenAt: new Date().toISOString(),
        expiresIn: token.expiresIn,
      },
    });
    const session = await createLoginSession(client, {
      tenantId,
      userId: user.id,
      provider,
      ipAddress,
      userAgent,
      metadata: {
        appId,
        openId: openId.openId,
        isNewUser,
      },
    });

    await writeLoginEvent(client, {
      tenantId,
      userId: user.id,
      provider,
      identifier: openId.openId,
      result: 'success',
      ipAddress,
      userAgent,
      metadata: {
        appId,
        isNewUser,
      },
    });

    return {
      provider,
      user,
      isNewUser,
      session,
      identity: {
        provider,
        openId: openId.openId,
      },
    };
  });
}
