import * as fs from 'fs';

import express from 'express';
import cookieParser from 'cookie-parser';
import crypto from 'crypto';
import dotenv from 'dotenv';
import * as jose from 'jose';
import {
  discoverOidcConfig,
  buildAuthorizationUrl,
  exchangeCodeForTokens,
  verifyIdToken,
  type OidcConfig,
  type OidcDiscovery,
} from './auth-oidc/oidcClient';
import { createSessionToken } from './auth-oidc/session';
import { requireOidcSession, requireTeamAccess } from './auth-oidc/middleware';
import { LdapTeamDirectory } from './auth-oidc/ldapTeamClient';
import type { TeamDirectory } from './auth-oidc/types';
import { publishEvent, type BackendConfig, type PublishEventPayload, type PublishResult } from './backendClient';
import { renderSubmitPage } from './eventForm';
import { handleEventSubmission } from './submitHandler';
import { describeUploadedFile, upload, UPLOAD_DIR } from './uploadRoute';

dotenv.config();

/**
 * Refuses to start rather than silently falling back to an insecure default.
 * Missing/placeholder secrets (e.g. an unset SESSION_SECRET) would otherwise let
 * anyone forge a valid session cookie, so we crash loudly at startup instead.
 */
function requireEnv(name: string, value: string, placeholder?: string): string {
  if (!value || value === placeholder) {
    throw new Error(`${name} is not set (or is still the placeholder value) — refusing to start. Set a real value in .env.`);
  }
  return value;
}

export interface AppDependencies {
  teamDirectory: TeamDirectory;
  sessionSecret: Uint8Array;
  candidateTeams?: string[];
  backend: BackendConfig;
  publicBaseUrl: string;
  // Injectable for tests so they don't need a real backend/ to talk to --
  // defaults to the real HTTP call otherwise. Same pattern as teamDirectory.
  publish?: (config: BackendConfig, payload: PublishEventPayload) => Promise<PublishResult>;
}

export function buildApp(deps: AppDependencies) {
  const app = express();
  app.use(cookieParser());
  app.use(express.json());

  const candidateTeams = deps.candidateTeams ?? ['progcom'];
  const publish = deps.publish ?? publishEvent;

  fs.mkdirSync(UPLOAD_DIR, { recursive: true });
  app.use('/uploads', express.static(UPLOAD_DIR));

  // Both guards below are the same full auth chain proven out by the
  // original /submit route: a valid OIDC session AND live membership in an
  // allowed LDAP team, re-checked on every request. /submit, /upload, and
  // the POST that actually publishes all sit behind the identical pair.
  const requireSession = requireOidcSession('eventer_session', deps.sessionSecret, '/login');
  const requireTeam = requireTeamAccess(deps.teamDirectory, candidateTeams);

  app.get('/submit', requireSession, requireTeam, (req, res) => {
    res.status(200).send(renderSubmitPage(req.eventerSession!));
  });

  app.post('/upload', requireSession, requireTeam, upload.single('poster'), (req, res) => {
    if (!req.file) {
      res.status(400).json({ error: { code: 'MISSING_FILE', message: "No file uploaded under field 'poster'" } });
      return;
    }
    res.json(describeUploadedFile(req.file, deps.publicBaseUrl));
  });

  app.post('/submit', requireSession, requireTeam, async (req, res) => {
    const outcome = await handleEventSubmission(req.body, req.eventerSession!, deps.backend, publish);
    res.status(outcome.status).json(outcome);
  });

  // multer's fileFilter (bad content type) and file-size-limit errors both
  // arrive here as Express error-handling middleware, not as a normal
  // response from the /upload handler above -- without this, they'd fall
  // through to Express's default HTML error page instead of the JSON error
  // shape every other route in this app uses.
  app.use((error: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
    if (error?.code === 'LIMIT_FILE_SIZE') {
      res.status(413).json({ error: { code: 'PAYLOAD_TOO_LARGE', message: 'Poster exceeds the size limit' } });
      return;
    }
    if (error instanceof Error) {
      res.status(400).json({ error: { code: 'UPLOAD_REJECTED', message: error.message } });
      return;
    }
    console.error(error);
    res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'Unexpected error' } });
  });

  return app;
}

if (require.main === module) {
  const OIDC_ISSUER_URL = process.env.OIDC_ISSUER_URL ?? 'https://keycloak.csclub.uwaterloo.ca/realms/csc';
  const OIDC_CLIENT_ID = requireEnv('OIDC_CLIENT_ID', process.env.OIDC_CLIENT_ID ?? '');
  const OIDC_CLIENT_SECRET = requireEnv('OIDC_CLIENT_SECRET', process.env.OIDC_CLIENT_SECRET ?? '');
  const REDIRECT_URI = process.env.OIDC_REDIRECT_URI ?? 'http://localhost:3001/callback';
  const SESSION_SECRET = new TextEncoder().encode(
    requireEnv('SESSION_SECRET', process.env.SESSION_SECRET ?? '', 'dev-only-insecure-secret-change-me-before-deploying')
  );
  const CANDIDATE_TEAMS = (process.env.ALLOWED_TEAMS ?? 'progcom')
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean);
  const oidcConfig: OidcConfig = { issuerUrl: OIDC_ISSUER_URL, clientId: OIDC_CLIENT_ID, clientSecret: OIDC_CLIENT_SECRET };

  const teamDirectory = new LdapTeamDirectory({
    url: process.env.LDAP_URL ?? 'ldap://ldap1.csclub.uwaterloo.ca',
    baseDn: process.env.LDAP_BASE_DN ?? 'dc=csclub,dc=uwaterloo,dc=ca',
  });

  // Must match backend/.env's UPSTREAM_API_SECRET exactly -- this is the
  // only thing authenticating upstream/'s calls to backend/.
  const BACKEND_URL = requireEnv('BACKEND_URL', process.env.BACKEND_URL ?? '');
  const BACKEND_SECRET = requireEnv('BACKEND_SECRET', process.env.BACKEND_SECRET ?? '');
  const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3001}`;

  const app = buildApp({
    teamDirectory,
    sessionSecret: SESSION_SECRET,
    candidateTeams: CANDIDATE_TEAMS,
    backend: { baseUrl: BACKEND_URL, secret: BACKEND_SECRET },
    publicBaseUrl: PUBLIC_BASE_URL,
  });

  let discoveryCache: OidcDiscovery | null = null;
  async function getDiscovery(): Promise<OidcDiscovery> {
    if (!discoveryCache) {
      discoveryCache = await discoverOidcConfig(OIDC_ISSUER_URL);
    }
    return discoveryCache;
  }

  app.get('/login', async (req, res) => {
    try {
      const discovery = await getDiscovery();
      const state = crypto.randomBytes(16).toString('hex');
      res.cookie('oidc_state', state, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 10 * 60 * 1000 });
      res.redirect(buildAuthorizationUrl(discovery, oidcConfig, REDIRECT_URI, state));
    } catch (err) {
      console.error('OIDC login failed:', err);
      res.status(502).json({ status: 'error', error: 'oidc_discovery_failed', message: 'Could not reach the login system right now. Please try again shortly.' });
    }
  });

  app.get('/callback', async (req, res) => {
    try {
      const code = req.query.code;
      const expectedState = req.cookies.oidc_state;
      const returnedState = req.query.state;
      res.clearCookie('oidc_state');
      if (!expectedState || !returnedState || expectedState !== returnedState) {
        res.status(400).json({ status: 'error', error: 'invalid_state', message: 'Login request could not be verified. Please try logging in again.' });
        return;
      }
      if (typeof code !== 'string') {
        res.status(400).send('Missing authorization code');
        return;
      }
      const discovery = await getDiscovery();
      if (discovery.issuer !== OIDC_ISSUER_URL) {
        throw new Error(`OIDC issuer mismatch: discovery document reports issuer "${discovery.issuer}" but expected "${OIDC_ISSUER_URL}"`);
      }
      const tokens = await exchangeCodeForTokens(discovery, oidcConfig, code, REDIRECT_URI);
      if (!tokens.id_token) {
        res.status(401).send('Login failed: ' + (tokens.error ?? 'no id_token returned'));
        return;
      }
      const jwks = jose.createRemoteJWKSet(new URL(discovery.jwks_uri));
      const session = await verifyIdToken(tokens.id_token, jwks, discovery.issuer, OIDC_CLIENT_ID);
      const sessionToken = await createSessionToken(session, SESSION_SECRET);
      res.cookie('eventer_session', sessionToken, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 1000 });
      res.redirect('/submit');
    } catch (err) {
      console.error('OIDC callback failed:', err);
      res.status(502).json({ status: 'error', error: 'oidc_callback_failed', message: 'Login failed. Please try again.' });
    }
  });

  const port = process.env.PORT ?? 3001;
  app.listen(port, () => {
    console.log(`Eventer upstream listening on :${port}`);
  });
}
