import { readFileSync } 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 } from '../config.js';
import type { Repository } from '../db/repository.js';
import { logger } from '../logger.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;');
}

/**
 * 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 primary = authConfig.primaryProvider;
  const googleConfigured = !!authConfig.providers.google?.clientId;
  const giteaConfigured = !!authConfig.providers.gitea?.clientId;

  // Decide which buttons to show
  let showGoogle: boolean;
  let showGitea: boolean;
  if (primary === 'google') {
    showGoogle = googleConfigured;
    showGitea = false;
  } else if (primary === 'gitea') {
    showGoogle = false;
    showGitea = giteaConfigured;
  } else {
    // No primary specified: show every configured provider
    showGoogle = googleConfigured;
    showGitea = giteaConfigured;
  }

  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');
  // Hide divider unless both buttons are visible
  if (!(showGoogle && showGitea)) out = stripBlock(out, 'DIVIDER_START', '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 ──────────────────────────────────────────────────────

/**
 * Repository の SQLite DB を使ったカスタムセッションストア。
 * sessions テーブル (sid, sess, expired) を直接操作する。
 */
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 {
        const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000;
        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 ?? 86400) * 1000;
        const expired = new Date(Date.now() + ttl).toISOString();

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

  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 (!googleConfig) 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 (!giteaConfig) 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 = repo.listUserGiteaOrgs(user.id).map(o => o.orgId);
          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(
  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 (authConfig.providers.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 (authConfig.providers.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');
        }
      }
    );
  }

  // ログアウト
  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,
): AuthMiddlewares {
  const db = repo.getDb();

  // セッションミドルウェア
  const sessionMiddleware = session({
    secret: authConfig.sessionSecret,
    resave: false,
    saveUninitialized: false,
    store: createSqliteSessionStore(db),
    cookie: {
      secure: authConfig.secureCookie,
      maxAge: authConfig.sessionMaxAge,
    },
  });

  // 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 orgs = repo.listUserGiteaOrgs(id);
      const enriched: Express.User = {
        ...baseUser,
        orgIds: orgs.map(o => o.orgId),
        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(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,
  };
}
