import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import type { Request, Response, NextFunction, RequestHandler, Router } from 'express';
import type { IncomingMessage } from 'http';
import express from 'express';
import session from 'express-session';
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import type { Database } from 'better-sqlite3';
import type { AuthConfig, AuthProviderConfig } from '../config.js';
import type { Repository, User } from '../db/repository.js';
import { logger } from '../logger.js';
import { randomBytes } from 'crypto';
import { LoginRateLimiter, throttleScopeForIp } from './login-rate-limit.js';

/**
 * WebSocket upgrade（生 IncomingMessage）から認証済みユーザーを解決するチェッカー。
 * Express の上では express-session + passport が自動でこれを担うが、
 * server.on('upgrade', ...) は middleware を素通しするので個別に呼ぶ必要がある。
 */
export type UpgradeAuthChecker = (req: IncomingMessage) => Promise<Express.User | null>;

// ── Login Page Renderer ──────────────────────────────────────────────────────

const __authDirname = path.dirname(fileURLToPath(import.meta.url));

export interface LoginBranding {
  appName: string;
  loginPageTitle: string;
}

const DEFAULT_LOGIN_BRANDING: LoginBranding = {
  appName: 'MAESTRO',
  loginPageTitle: 'MAESTRO',
};

function escapeHtml(s: string): string {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

/**
 * A provider is usable only when ALL fields needed to complete an OAuth round
 * trip are present (Gitea additionally needs base_url). Used to gate auth
 * activation and login-button visibility so a partial config saved from the
 * Settings UI can't enable auth in a state where nobody can log in.
 */
export function isProviderConfigured(
  p: AuthProviderConfig | undefined,
  kind: 'google' | 'gitea',
): p is AuthProviderConfig {
  if (!p || !p.clientId || !p.clientSecret || !p.callbackUrl) return false;
  if (kind === 'gitea' && !p.baseUrl) return false;
  return true;
}

/**
 * Whether a provider should actually be LIVE (strategy + /auth/<kind> route).
 * A valid `primaryProvider` restricts auth to that single provider, so the
 * restriction is enforced at the route layer too — not just hidden on the login
 * page (otherwise the "disabled" provider's route stayed open to direct URLs).
 * An invalid primary (pointing at an unconfigured provider) is ignored.
 */
export function isProviderActive(authConfig: AuthConfig, kind: 'google' | 'gitea'): boolean {
  if (!isProviderConfigured(authConfig.providers?.[kind], kind)) return false;
  const primary = authConfig.primaryProvider;
  if (primary === 'google' && isProviderConfigured(authConfig.providers?.google, 'google')) return kind === 'google';
  if (primary === 'gitea' && isProviderConfigured(authConfig.providers?.gitea, 'gitea')) return kind === 'gitea';
  // primary=local restricts login to local accounts → OAuth providers off.
  if (primary === 'local' && isLocalEnabled(authConfig)) return false;
  return true;
}

/** Local email+password login is turned on. */
export function isLocalEnabled(authConfig: AuthConfig): boolean {
  return authConfig.local?.enabled === true;
}

/**
 * The org ids a user belongs to, for session.orgIds. Union of Gitea orgs
 * (from OAuth login) and local orgs (membership). buildVisibilityWhere is
 * provider-agnostic, so 'org' visibility works for either once these are set.
 */
export function resolveOrgIds(repo: Repository, userId: string): string[] {
  const gitea = repo.listUserGiteaOrgs(userId).map(o => o.orgId);
  const local = repo.listUserLocalOrgs(userId).map(o => o.orgId);
  return [...new Set([...gitea, ...local])];
}

/**
 * Self-service password change for the authenticated local user. Mount behind
 * requireAuth + a JSON body parser. Requires the CURRENT password (so a
 * hijacked session can't silently re-key the account) and only applies to
 * accounts that already have a local credential — OAuth-only users have none
 * (adding one is a separate, deferred action). 204 on success.
 */
export function buildChangePasswordHandler(repo: Repository): RequestHandler {
  return function changePassword(req: Request, res: Response): void {
    const user = req.user as Express.User | undefined;
    if (!user) { res.status(401).json({ error: 'unauthenticated' }); return; }
    const { currentPassword, newPassword } = (req.body ?? {}) as {
      currentPassword?: unknown;
      newPassword?: unknown;
    };
    if (typeof newPassword !== 'string' || newPassword.length < 8) {
      res.status(400).json({ error: 'new password must be at least 8 characters' });
      return;
    }
    if (!repo.hasLocalCredential(user.id)) {
      res.status(400).json({ error: 'this account has no local password to change' });
      return;
    }
    if (typeof currentPassword !== 'string' || !repo.verifyLocalPassword(user.id, currentPassword)) {
      res.status(403).json({ error: 'current password is incorrect' });
      return;
    }
    repo.setLocalPassword(user.id, newPassword);
    res.status(204).end();
  };
}

/**
 * auth-login.html をレンダリングする。
 * primary_provider 設定と各プロバイダの configured 状態に応じて
 * Google/Gitea ボタンおよび divider を表示/非表示する。
 * branding が指定されていれば {{APP_NAME}} / {{LOGIN_PAGE_TITLE}} を差し替える。
 */
function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string {
  const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8');
  const googleConfigured = isProviderConfigured(authConfig.providers?.google, 'google');
  const giteaConfigured = isProviderConfigured(authConfig.providers?.gitea, 'gitea');
  const localEnabled = isLocalEnabled(authConfig);
  const allowSignup = authConfig.local?.allowSignup === true;
  // Ignore a primaryProvider that points to an unconfigured/disabled provider —
  // otherwise it would hide the only working login and lock the operator out.
  const primary =
    (authConfig.primaryProvider === 'google' && googleConfigured) ||
    (authConfig.primaryProvider === 'gitea' && giteaConfigured) ||
    (authConfig.primaryProvider === 'local' && localEnabled)
      ? authConfig.primaryProvider
      : undefined;

  // Decide which login options to show
  let showGoogle: boolean;
  let showGitea: boolean;
  let showLocal: boolean;
  if (primary === 'google') {
    showGoogle = googleConfigured; showGitea = false; showLocal = false;
  } else if (primary === 'gitea') {
    showGoogle = false; showGitea = giteaConfigured; showLocal = false;
  } else if (primary === 'local') {
    showGoogle = false; showGitea = false; showLocal = localEnabled;
  } else {
    // No primary specified: show every configured/enabled option
    showGoogle = googleConfigured;
    showGitea = giteaConfigured;
    showLocal = localEnabled;
  }

  const stripBlock = (html: string, startMarker: string, endMarker: string): string => {
    const re = new RegExp(`<!--\\s*${startMarker}\\s*-->[\\s\\S]*?<!--\\s*${endMarker}\\s*-->`, 'g');
    return html.replace(re, '');
  };

  let out = raw;
  if (!showGoogle) out = stripBlock(out, 'GOOGLE_BUTTON_START', 'GOOGLE_BUTTON_END');
  if (!showGitea)  out = stripBlock(out, 'GITEA_BUTTON_START',  'GITEA_BUTTON_END');
  // OAuth-only divider: only when BOTH oauth buttons are visible
  if (!(showGoogle && showGitea)) out = stripBlock(out, 'DIVIDER_START', 'DIVIDER_END');
  // Local form + signup + its divider
  if (!showLocal) out = stripBlock(out, 'LOCAL_FORM_START', 'LOCAL_FORM_END');
  else {
    if (!allowSignup) out = stripBlock(out, 'LOCAL_SIGNUP_START', 'LOCAL_SIGNUP_END');
    // Divider above the local form only when an OAuth button sits above it
    if (!(showGoogle || showGitea)) out = stripBlock(out, 'LOCAL_DIVIDER_START', 'LOCAL_DIVIDER_END');
  }

  // Branding placeholders
  out = out
    .replace(/\{\{APP_NAME\}\}/g, escapeHtml(branding.appName))
    .replace(/\{\{LOGIN_PAGE_TITLE\}\}/g, escapeHtml(branding.loginPageTitle));
  return out;
}

// ── Global type augmentation ─────────────────────────────────────────────────

declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace Express {
    interface User {
      id: string;
      email: string;
      name: string | null;
      avatarUrl: string | null;
      role: 'admin' | 'user';
      status: 'active' | 'pending' | 'disabled';
      orgIds: string[];
      defaultVisibility: 'private' | 'org' | 'public';
      defaultVisibilityOrgId: string | null;
    }
  }
}

// ── Middleware ────────────────────────────────────────────────────────────────

/**
 * requireAuth: 認証済みかつ status=active のユーザーのみ通過させる。
 * API リクエスト（/api/ プレフィックス）には 401 JSON を返す。
 * それ以外のリクエストは /auth/login にリダイレクトする。
 */
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
  if (req.isAuthenticated() && req.user && (req.user as Express.User).status === 'active') {
    next();
    return;
  }

  if (req.originalUrl.startsWith('/api/')) {
    res.status(401).json({ error: 'Unauthorized' });
  } else {
    res.redirect('/auth/login');
  }
}

/**
 * requireAdmin: admin ロールのユーザーのみ通過させる。
 * 未認証の場合は requireAuth と同じ挙動（401 or redirect）。
 * 認証済みだが admin でない場合は 403 を返す。
 */
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
  if (!req.isAuthenticated() || !req.user) {
    if (req.originalUrl.startsWith('/api/')) {
      res.status(401).json({ error: 'Unauthorized' });
    } else {
      res.redirect('/auth/login');
    }
    return;
  }

  const user = req.user as Express.User;
  if (user.role !== 'admin') {
    res.status(403).json({ error: 'Forbidden' });
    return;
  }

  next();
}

// ── SQLite Session Store ──────────────────────────────────────────────────────

/**
 * Default session lifetime (ms) when auth.session_max_age is unset. Applied to
 * BOTH the cookie maxAge and the store TTL so an unconfigured deployment gets a
 * concrete, sliding expiry window instead of a browser-session cookie paired
 * with a store row that expires after a day (the old `?? 86400` fallback).
 * With rolling this is an inactivity window — an active session never expires;
 * we intentionally don't impose an absolute timeout for this self-hosted tool.
 */
export const DEFAULT_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days

/** Default on-disk location for the auto-generated session secret. */
export const DEFAULT_SESSION_SECRET_PATH = './data/secrets/session-secret.key';

/**
 * Resolve the session secret when auth.session_secret is unset. Reads a stable
 * secret persisted at `secretPath`, generating + saving one (0600, in a 0700
 * dir) on first run. This keeps sessions valid across restarts instead of the
 * old per-process random secret that logged everyone out on every restart.
 * Mirrors initMasterKey (src/crypto/sessions.ts). Falls back to an in-memory
 * random secret only if the file can't be read or written.
 */
export function loadOrCreateSessionSecret(secretPath: string): string {
  try {
    if (existsSync(secretPath)) {
      const existing = readFileSync(secretPath, 'utf8').trim();
      if (existing.length > 0) {
        if (existing.length < 16) {
          logger.warn(
            `[auth] session secret at ${secretPath} is suspiciously short (${existing.length} chars) — it may be truncated. Delete it to regenerate, or set auth.session_secret.`,
          );
        }
        return existing;
      }
    }
    const secret = randomBytes(32).toString('hex');
    mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
    if (existsSync(secretPath)) {
      // File exists but was empty/blank (we'd have returned above otherwise) —
      // overwrite it; no creation race to worry about since it already exists.
      writeFileSync(secretPath, secret, { mode: 0o600 });
    } else {
      // Atomic create: 'wx' fails if another process won the race to create the
      // file first; re-read theirs so all processes converge on one secret.
      try {
        writeFileSync(secretPath, secret, { mode: 0o600, flag: 'wx' });
      } catch (e) {
        if ((e as NodeJS.ErrnoException).code === 'EEXIST') {
          const won = readFileSync(secretPath, 'utf8').trim();
          if (won.length > 0) return won;
          writeFileSync(secretPath, secret, { mode: 0o600 }); // racer wrote blank — overwrite
        } else {
          throw e;
        }
      }
    }
    chmodSync(secretPath, 0o600);
    logger.info(
      `[auth] generated a persistent session secret at ${secretPath} (sessions now survive restarts). Override with auth.session_secret in Settings → Authentication.`,
    );
    return secret;
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    logger.warn(
      `[auth] could not persist a session secret at ${secretPath} (${msg}) — using a random per-process secret; sessions reset on restart. Set auth.session_secret in Settings → Authentication.`,
    );
    return randomBytes(32).toString('hex');
  }
}

/**
 * Repository の SQLite DB を使ったカスタムセッションストア。
 * sessions テーブル (sid, sess, expired) を直接操作する。
 */
export function createSqliteSessionStore(db: Database): session.Store {
  // session.Store の基底クラスを継承
  const Store = session.Store as unknown as new () => session.Store;

  class SqliteStore extends Store {
    get(sid: string, callback: (err: unknown, session?: session.SessionData | null) => void): void {
      try {
        const row = db
          .prepare('SELECT sess, expired FROM sessions WHERE sid = ?')
          .get(sid) as { sess: string; expired: string } | undefined;

        if (!row) {
          callback(null, null);
          return;
        }

        // 期限切れチェック
        if (new Date(row.expired) <= new Date()) {
          db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
          callback(null, null);
          return;
        }

        const sessionData = JSON.parse(row.sess) as session.SessionData;
        callback(null, sessionData);
      } catch (err) {
        callback(err);
      }
    }

    set(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
      try {
        // cookie.maxAge is already in milliseconds; don't re-scale it. The
        // fallback is the same ms default the middleware applies, so the store
        // row and the cookie expire together.
        const ttl = sessionData.cookie?.maxAge ?? DEFAULT_SESSION_MAX_AGE_MS;
        const expired = new Date(Date.now() + ttl).toISOString();
        const sess = JSON.stringify(sessionData);

        db.prepare(`
          INSERT INTO sessions (sid, sess, expired)
          VALUES (?, ?, ?)
          ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expired = excluded.expired
        `).run(sid, sess, expired);

        callback?.();
      } catch (err) {
        callback?.(err);
      }
    }

    destroy(sid: string, callback?: (err?: unknown) => void): void {
      try {
        db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
        callback?.();
      } catch (err) {
        callback?.(err);
      }
    }

    touch(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
      try {
        const ttl = sessionData.cookie?.maxAge ?? DEFAULT_SESSION_MAX_AGE_MS;
        const expired = new Date(Date.now() + ttl).toISOString();

        db.prepare("UPDATE sessions SET expired = ? WHERE sid = ?").run(expired, sid);
        callback?.();
      } catch (err) {
        callback?.(err);
      }
    }
  }

  // Eagerly sweep already-expired rows at startup. get() prunes lazily, but a
  // session whose owner never returns would otherwise linger forever; the
  // idx_sessions_expired index keeps this cheap.
  try {
    db.prepare("DELETE FROM sessions WHERE expired < ?").run(new Date().toISOString());
  } catch {
    // Non-fatal: a missing table on a brand-new DB is fine; the store still works.
  }

  return new SqliteStore();
}

// ── OAuth Callback ────────────────────────────────────────────────────────────

/**
 * OAuth コールバック共通処理。
 * email から findOrCreateUserByOAuth を呼び出し、
 * adminEmails に一致する pending ユーザーは自動で admin に昇格する。
 */
async function handleOAuthCallback(
  repo: Repository,
  adminEmails: string[],
  provider: string,
  providerId: string,
  email: string,
  name: string,
  avatarUrl: string | undefined,
  done: (err: unknown, user?: Express.User | false) => void
): Promise<void> {
  try {
    let user = repo.findOrCreateUserByOAuth({
      provider,
      providerId,
      email,
      name,
      avatarUrl,
    });

    // adminEmails に一致する pending ユーザーを自動昇格
    if (user.status === 'pending' && adminEmails.includes(email)) {
      repo.updateUser(user.id, { status: 'active', role: 'admin' });
      const updated = repo.getUserById(user.id);
      if (updated) user = updated;
    }

    // deserializeUser will enrich with orgIds + defaults on subsequent requests.
    const sessionUser: Express.User = {
      ...user,
      orgIds: [],
      defaultVisibility: user.defaultVisibility ?? 'private',
      defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
    };
    done(null, sessionUser);
  } catch (err) {
    done(err);
  }
}

// ── Gitea Orgs Fetch ─────────────────────────────────────────────────────────

/**
 * Gitea の /api/v1/user/orgs を呼び出してユーザーの所属 org 一覧を取得し、
 * Repository に永続化する。返り値は org ID の文字列配列。
 * 失敗時は空配列を返し、警告ログを出力する（認証フロー自体は継続）。
 */
export async function fetchGiteaOrgsForUser(
  repo: Repository,
  userId: string,
  baseUrl: string,
  accessToken: string,
): Promise<string[]> {
  let res: globalThis.Response;
  try {
    res = await fetch(`${baseUrl}/api/v1/user/orgs`, {
      headers: { Authorization: `token ${accessToken}`, Accept: 'application/json' },
    });
  } catch (err) {
    console.warn(`[auth] gitea orgs fetch error: ${(err as Error).message}`);
    // Clear stale cache: if we can't confirm membership, don't keep old grants around.
    repo.replaceUserGiteaOrgs(userId, []);
    return [];
  }
  if (!res.ok) {
    console.warn(`[auth] gitea orgs fetch failed: ${res.status}`);
    repo.replaceUserGiteaOrgs(userId, []);
    return [];
  }
  const orgs = (await res.json()) as Array<{ id: number; username: string }>;
  const items = orgs.map(o => ({ orgId: String(o.id), orgName: o.username }));
  repo.replaceUserGiteaOrgs(userId, items);
  return items.map(i => i.orgId);
}

// ── Strategy Registration ─────────────────────────────────────────────────────

function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void {
  const googleConfig = authConfig.providers?.google;
  if (!isProviderConfigured(googleConfig, 'google')) return;
  if (!isProviderActive(authConfig, 'google')) return;

  passport.use(
    new GoogleStrategy(
      {
        clientID: googleConfig.clientId,
        clientSecret: googleConfig.clientSecret,
        callbackURL: googleConfig.callbackUrl,
      },
      async (_accessToken, _refreshToken, profile, done) => {
        const email = profile.emails?.[0]?.value ?? '';
        const name = profile.displayName ?? '';
        const avatarUrl = profile.photos?.[0]?.value;

        await handleOAuthCallback(
          repo,
          authConfig.adminEmails ?? [],
          'google',
          profile.id,
          email,
          name,
          avatarUrl,
          done as (err: unknown, user?: Express.User | false) => void
        );
      }
    )
  );
}

function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
  const giteaConfig = authConfig.providers?.gitea;
  if (!isProviderConfigured(giteaConfig, 'gitea')) return;
  if (!isProviderActive(authConfig, 'gitea')) return;

  const baseUrl = giteaConfig.baseUrl ?? '';

  passport.use(
    'gitea',
    new OAuth2Strategy(
      {
        authorizationURL: `${baseUrl}/login/oauth/authorize`,
        tokenURL: `${baseUrl}/login/oauth/access_token`,
        clientID: giteaConfig.clientId,
        clientSecret: giteaConfig.clientSecret,
        callbackURL: giteaConfig.callbackUrl,
      },
      async (accessToken: string, _refreshToken: string, _params: unknown, _profile: unknown, done: (err: unknown, user?: Express.User | false) => void) => {
        try {
          // Gitea 専用: アクセストークンでユーザー情報を取得
          const response = await fetch(`${baseUrl}/api/v1/user`, {
            headers: {
              Authorization: `token ${accessToken}`,
              'Content-Type': 'application/json',
            },
          });

          if (!response.ok) {
            done(new Error(`Gitea userinfo fetch failed: ${response.status}`));
            return;
          }

          const profile = await response.json() as {
            id: number;
            login: string;
            email: string;
            full_name?: string;
            avatar_url?: string;
          };

          const email = profile.email && profile.email.length > 0
            ? profile.email
            : `${profile.login}@gitea.local`;
          // Gitea returns full_name="" when the user hasn't set it; `??` would
          // keep that empty string, so use `||` to fall through to the login.
          const name = profile.full_name || profile.login || '';
          const avatarUrl = profile.avatar_url;

          // Gitea verify は handleOAuthCallback をインライン化:
          // accessToken/baseUrl がこのスコープでしか得られないため、
          // user を確定させた後 fetchGiteaOrgsForUser を呼んで orgs を永続化する。
          let user = repo.findOrCreateUserByOAuth({
            provider: 'gitea',
            providerId: String(profile.id),
            email,
            name,
            avatarUrl,
          });
          if (user.status === 'pending' && (authConfig.adminEmails ?? []).includes(email)) {
            repo.updateUser(user.id, { status: 'active', role: 'admin' });
            const updated = repo.getUserById(user.id);
            if (updated) user = updated;
          }
          await fetchGiteaOrgsForUser(repo, user.id, baseUrl, accessToken);
          const orgIds = resolveOrgIds(repo, user.id);
          const sessionUser: Express.User = {
            ...user,
            orgIds,
            defaultVisibility: user.defaultVisibility ?? 'private',
            defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
          };
          done(null, sessionUser);
        } catch (err) {
          done(err);
        }
      }
    )
  );
}

// ── Auth Router ───────────────────────────────────────────────────────────────

function createAuthRouter(
  repo: Repository,
  authConfig: AuthConfig,
  getBranding?: () => LoginBranding,
): Router {
  const router = express.Router();
  const __dirname = path.dirname(fileURLToPath(import.meta.url));

  // ログインページ
  router.get('/login', (_req: Request, res: Response) => {
    const branding = getBranding ? getBranding() : DEFAULT_LOGIN_BRANDING;
    res.type('html').send(renderLoginPage(authConfig, branding));
  });

  // 承認待ちページ（承認済みなら自動リダイレクト）
  router.get('/pending', (req, res) => {
    if (req.isAuthenticated() && (req.user as Express.User).status === 'active') {
      res.redirect('/');
      return;
    }
    res.sendFile(path.join(__dirname, 'auth-pending.html'));
  });

  // ステータス確認エンドポイント（承認待ちページのポーリング用）
  router.get('/status', (req, res) => {
    if (!req.isAuthenticated() || !req.user) {
      res.json({ status: 'unauthenticated' });
      return;
    }
    res.json({ status: (req.user as Express.User).status });
  });

  // Google OAuth
  if (isProviderActive(authConfig, 'google')) {
    router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));

    router.get(
      '/google/callback',
      passport.authenticate('google', { failureRedirect: '/auth/login' }),
      (req, res) => {
        const user = req.user as Express.User | undefined;
        if (user?.status === 'active') {
          res.redirect('/');
        } else {
          res.redirect('/auth/pending');
        }
      }
    );
  }

  // Gitea OAuth
  if (isProviderActive(authConfig, 'gitea')) {
    router.get('/gitea', passport.authenticate('gitea'));

    router.get(
      '/gitea/callback',
      passport.authenticate('gitea', { failureRedirect: '/auth/login' }),
      (req, res) => {
        const user = req.user as Express.User | undefined;
        if (user?.status === 'active') {
          res.redirect('/');
        } else {
          res.redirect('/auth/pending');
        }
      }
    );
  }

  // Local accounts (email + password)
  if (isLocalEnabled(authConfig)) {
    const parseBody = [express.urlencoded({ extended: false }), express.json()];

    // Throttle online password guessing per client IP: lock for 15 min after
    // 10 failures within 15 min. scrypt already makes each guess costly; this
    // caps the rate. Keyed by IP only — NOT by account — on purpose: account
    // lockout would let anyone lock a victim out by failing on their email
    // (targeted DoS), and would let unauthenticated callers grow the key map
    // with arbitrary email strings (memory DoS). Process-local; a multi-process
    // / HA deployment behind a load balancer would need a shared store.
    const loginLimiter = new LoginRateLimiter({
      maxAttempts: 10,
      windowMs: 15 * 60 * 1000,
      lockoutMs: 15 * 60 * 1000,
      maxKeys: 10_000,
    });
    // `req.ip` honors Express `trust proxy` (set when secureCookie is on, i.e.
    // behind a TLS proxy). Falls back to the raw socket address otherwise.
    const clientIp = (req: Request): string => req.ip ?? req.socket?.remoteAddress ?? 'unknown';
    // Keep raw user input out of log lines (strip control chars, cap length).
    const logSafe = (s: string): string => s.replace(/[^\x20-\x7e]/g, '').slice(0, 120);

    const toExpressUser = (u: User): Express.User => ({
      ...u,
      orgIds: resolveOrgIds(repo, u.id),
      defaultVisibility: u.defaultVisibility ?? 'private',
      defaultVisibilityOrgId: u.defaultVisibilityOrgId ?? null,
    });

    const readCreds = (req: Request): { email: string; password: string } | null => {
      const b = (req.body ?? {}) as { email?: unknown; password?: unknown };
      if (typeof b.email !== 'string' || typeof b.password !== 'string') return null;
      const email = b.email.trim();
      if (!email || !b.password) return null;
      return { email, password: b.password };
    };

    // Login: verify password, then establish the passport session via req.login.
    router.post('/local', ...parseBody, (req: Request, res: Response, next: NextFunction) => {
      const creds = readCreds(req);
      if (!creds) { res.redirect('/auth/login?error=invalid'); return; }
      const ip = clientIp(req);
      // Key on the /64 for IPv6 so a client holding a whole /64 can't rotate
      // source addresses to evade the throttle (raw `ip` is kept for logs).
      const ipKey = `ip:${throttleScopeForIp(ip)}`;
      if (loginLimiter.isLocked(ipKey)) {
        logger.warn(`[auth] local login throttled ip=${logSafe(ip)}`);
        res.redirect('/auth/login?error=locked');
        return;
      }
      const user = repo.getUserByEmail(creds.email);
      // Verify even when the user is missing is not needed; getUserByEmail is the
      // gate. Wrong password and unknown email both surface as the same error.
      if (!user || !repo.verifyLocalPassword(user.id, creds.password)) {
        loginLimiter.recordFailure(ipKey);
        logger.warn(`[auth] local login failed ip=${logSafe(ip)} email=${logSafe(creds.email)}`);
        res.redirect('/auth/login?error=credentials');
        return;
      }
      if (user.status === 'disabled') { res.redirect('/auth/login?error=disabled'); return; }
      // Clear the throttle on success. req.login (passport SessionManager.logIn)
      // regenerates the session id itself, so no explicit regenerate is needed
      // here for session-fixation protection.
      loginLimiter.reset(ipKey);
      req.login(toExpressUser(user), (err) => {
        if (err) { next(err); return; }
        res.redirect(user.status === 'active' ? '/' : '/auth/pending');
      });
    });

    // Self-signup (opt-in): creates a pending user an admin must approve.
    if (authConfig.local?.allowSignup) {
      router.post('/local/signup', ...parseBody, (req: Request, res: Response, next: NextFunction) => {
        const creds = readCreds(req);
        if (!creds) { res.redirect('/auth/login?error=invalid'); return; }
        if (creds.password.length < 8) { res.redirect('/auth/login?error=weak'); return; }
        let user;
        try {
          user = repo.createLocalUser({ email: creds.email, password: creds.password, role: 'user', status: 'pending' });
        } catch {
          // Most likely the email is already registered. Don't disclose which.
          res.redirect('/auth/login?error=signup');
          return;
        }
        // req.login regenerates the session id (passport SessionManager.logIn),
        // which is the session-fixation protection for this manual flow.
        req.login(toExpressUser(user), (err) => {
          if (err) { next(err); return; }
          res.redirect('/auth/pending');
        });
      });
    }
  }

  // ログアウト
  router.get('/logout', (req, res, next) => {
    req.logout((err) => {
      if (err) {
        next(err);
        return;
      }
      res.redirect('/auth/login');
    });
  });

  return router;
}

// ── setupAuth ─────────────────────────────────────────────────────────────────

export interface AuthMiddlewares {
  sessionMiddleware: RequestHandler;
  passportInit: RequestHandler;
  passportSession: RequestHandler;
  authRouter: Router;
  /**
   * Raw HTTP upgrade（WebSocket）リクエストから認証済みユーザーを解決する。
   * Cookie → セッション → Passport deserialize の順に通し、最終的な req.user を返す。
   * 認証されていなければ null を返す。
   */
  authenticateUpgrade: UpgradeAuthChecker;
}

/**
 * 認証モジュールのセットアップ。
 * セッション、Passport、OAuth ストラテジーを設定し、
 * ミドルウェアと認証ルーターを返す。
 */
export function setupAuth(
  repo: Repository,
  authConfig: AuthConfig,
  getBranding?: () => LoginBranding,
  sessionSecretPath: string = DEFAULT_SESSION_SECRET_PATH,
): AuthMiddlewares {
  const db = repo.getDb();

  // express-session throws "secret option required" (→ 500 on every request) if
  // the secret is empty. Auth can be enabled from the Settings UI before a
  // session_secret is set, so when it's unset we use a secret persisted on disk
  // (generated once) rather than a per-process random one — the latter logged
  // everyone out on every restart. A configured auth.session_secret still wins.
  // Trim so a whitespace-only YAML value ("   ") isn't accepted as a (very weak)
  // secret — treat it as unset and fall back to the persisted secret.
  let sessionSecret = authConfig.sessionSecret?.trim() ?? '';
  if (sessionSecret.length === 0) {
    sessionSecret = loadOrCreateSessionSecret(sessionSecretPath);
  }

  // セッションミドルウェア
  const sessionMiddleware = session({
    secret: sessionSecret,
    resave: false,
    saveUninitialized: false,
    // rolling: re-issue the cookie (and bump the store TTL via touch) on every
    // response so an actively-used session never expires mid-use. Without it the
    // cookie's Max-Age is frozen at login and the user is logged out at a fixed
    // wall-clock time regardless of activity.
    rolling: true,
    store: createSqliteSessionStore(db),
    cookie: {
      // httpOnly: keep the session cookie out of document.cookie (defense in
      // depth against XSS token theft). sameSite 'lax': don't send the cookie on
      // cross-site POST/DELETE, which blocks the basic CSRF vector without a
      // token for this same-origin app.
      httpOnly: true,
      sameSite: 'lax',
      secure: authConfig.secureCookie,
      // Always give the cookie a concrete lifetime. When session_max_age is
      // unset, fall back to the default instead of leaving maxAge undefined —
      // an undefined maxAge yields a browser-session cookie (no Max-Age) that the
      // browser keeps only until it closes, paired with the store's 1-day TTL.
      // A concrete maxAge + rolling gives a proper sliding inactivity window.
      maxAge: authConfig.sessionMaxAge ?? DEFAULT_SESSION_MAX_AGE_MS,
    },
  });

  // Passport シリアライズ/デシリアライズ
  passport.serializeUser((user: Express.User, done) => {
    done(null, user.id);
  });

  passport.deserializeUser((id: string, done) => {
    try {
      const baseUser = repo.getUserById(id);
      if (!baseUser) { done(null, false); return; }
      const enriched: Express.User = {
        ...baseUser,
        orgIds: resolveOrgIds(repo, id),
        defaultVisibility: baseUser.defaultVisibility ?? 'private',
        defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null,
      };
      done(null, enriched);
    } catch (err) {
      done(err);
    }
  });

  // OAuth ストラテジー登録
  registerGoogleStrategy(repo, authConfig);
  registerGiteaStrategy(repo, authConfig);

  // 認証ルーター
  const authRouter = createAuthRouter(repo, authConfig, getBranding);

  const passportInit = passport.initialize();
  const passportSession = passport.session();

  // 生 upgrade リクエスト用の認証チェッカー。
  // sessionMiddleware → passportInit → passportSession を順に走らせ、req.user を populate する。
  // 失敗時は null を返し、呼び出し側で socket.destroy() する想定。
  // 各 middleware が next(err) を呼んだ場合（session store 障害・deserialize 失敗等）は
  // ログを出してから null を返す（fail-closed）。
  const authenticateUpgrade: UpgradeAuthChecker = (req) => {
    return new Promise((resolve) => {
      // express-session 等が res.setHeader / res.end を呼ぶことがあるため、
      // 必要最小限のメソッドを no-op で備えたスタブを渡す。
      const fakeRes = {
        setHeader: () => fakeRes,
        getHeader: () => undefined,
        removeHeader: () => fakeRes,
        end: () => fakeRes,
        writeHead: () => fakeRes,
        statusCode: 200,
        on: () => fakeRes,
      } as unknown as Response;

      const reqAny = req as unknown as Request;
      const failClosed = (stage: string, err: unknown): void => {
        const msg = err instanceof Error ? err.message : String(err);
        logger.warn(`[auth] authenticateUpgrade ${stage} failed: ${msg}`);
        resolve(null);
      };

      sessionMiddleware(reqAny, fakeRes, (sessionErr?: unknown) => {
        if (sessionErr) { failClosed('sessionMiddleware', sessionErr); return; }
        passportInit(reqAny, fakeRes, (initErr?: unknown) => {
          if (initErr) { failClosed('passportInit', initErr); return; }
          passportSession(reqAny, fakeRes, (sessErr?: unknown) => {
            if (sessErr) { failClosed('passportSession', sessErr); return; }
            const user = reqAny.user as Express.User | undefined;
            if (!user) {
              resolve(null);
              return;
            }
            // status=active のみ認める（disabled/pending を弾く）
            if (user.status !== 'active') {
              resolve(null);
              return;
            }
            resolve(user);
          });
        });
      });
    });
  };

  return {
    sessionMiddleware,
    passportInit,
    passportSession,
    authRouter,
    authenticateUpgrade,
  };
}
