require('dotenv').config();
const express = require('express');
const session = require('express-session');
const bodyParser = require('body-parser');
const { createProxyMiddleware } = require('http-proxy-middleware');
const path = require('path');
const fs = require('fs');

// Import openid-client in a way that works with both CJS and ESM builds.
let Issuer, generators;
try {
  const oc = require('openid-client');
  // package may export under .default for some ESM->CJS interop
  Issuer = oc.Issuer || (oc.default && oc.default.Issuer);
  generators = oc.generators || (oc.default && oc.default.generators);
} catch (err) {
  // don't crash here — we'll log and skip OIDC setup later
  console.warn('openid-client not available via require():', err.message);
}

const app = express();
const PORT = process.env.PORT || 3000;

app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

app.use(bodyParser.urlencoded({ extended: false }));
app.use(session({
  secret: process.env.SESSION_SECRET || 'dev-secret',
  resave: false,
  saveUninitialized: false,
  cookie: { secure: false }
}));

// --- Admin credentials (set via env) ---
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASS || 'admin';

function ensureAdmin(req, res, next) {
  if (req.session && req.session.isAdmin) return next();
  return res.redirect('/admin/login');
}

// Admin routes: login, logout, manage hosts
app.get('/admin/login', (req, res) => {
  res.render('admin/login', { error: null });
});
app.post('/admin/login', (req, res) => {
  const { username, password } = req.body || {};
  if (username === ADMIN_USER && password === ADMIN_PASS) {
    req.session.isAdmin = true;
    return res.redirect('/admin');
  }
  res.render('admin/login', { error: 'Invalid credentials' });
});
app.get('/admin/logout', (req, res) => {
  if (req.session) delete req.session.isAdmin;
  res.redirect('/admin/login');
});

// list hosts
app.get('/admin', ensureAdmin, (req, res) => {
  res.render('admin/index', { hosts: HOSTS });
});

app.get('/admin/new', ensureAdmin, (req, res) => {
  res.render('admin/edit_host', { host: null });
});

app.get('/admin/edit/:id', ensureAdmin, (req, res) => {
  const host = HOSTS.find(h => h.id === req.params.id);
  if (!host) return res.status(404).send('Not found');
  res.render('admin/edit_host', { host });
});

// save new or existing host
app.post('/admin/save', ensureAdmin, (req, res) => {
  const idInput = (req.body.idInput || '').trim();
  if (!idInput) return res.status(400).send('id required');
  const existingIndex = HOSTS.findIndex(h => h.id === idInput);
  const hostObj = {
    id: idInput,
    pathPrefix: req.body.pathPrefix || '/',
    upstream: req.body.upstream || ''
  };
  // mask upstream option: rewrite absolute URLs and cookies so proxy origin is used
  hostObj.maskUpstream = req.body.mask_upstream === 'on';
  if (req.body.oidc_issuer) {
    hostObj.oidc = {
      issuer: req.body.oidc_issuer,
      client_id: req.body.oidc_client_id,
      client_secret: req.body.oidc_client_secret,
      redirect_uri: req.body.oidc_redirect_uri
    };
  }
  if (existingIndex >= 0) {
    HOSTS[existingIndex] = hostObj;
  } else {
    HOSTS.push(hostObj);
  }
  // persist to config.json
  try {
    fs.writeFileSync(cfgPath, JSON.stringify(HOSTS, null, 2), 'utf8');
  } catch (err) {
    console.error('Failed writing config.json', err);
  }
  return res.redirect('/admin');
});

app.post('/admin/delete/:id', ensureAdmin, (req, res) => {
  const id = req.params.id;
  HOSTS = HOSTS.filter(h => h.id !== id);
  try { fs.writeFileSync(cfgPath, JSON.stringify(HOSTS, null, 2), 'utf8'); } catch(e){console.error(e)}
  res.redirect('/admin');
});

app.post('/admin/reload', ensureAdmin, async (req, res) => {
  // reload hosts from disk and re-run OIDC setup
  try {
    if (fs.existsSync(cfgPath)) {
      HOSTS = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
    }
    // reinitialize oidc clients
    Object.keys(oidcClients).forEach(k => delete oidcClients[k]);
    await setupOidc();
    res.redirect('/admin');
  } catch (err) {
    console.error('Reload error', err);
    res.status(500).send('Reload failed');
  }
});

// Admin preview page (iframe) — keep user on proxy domain while showing upstream
app.get('/admin/preview/:id', ensureAdmin, (req, res) => {
  const host = HOSTS.find(h => h.id === req.params.id);
  if (!host) return res.status(404).send('Not found');
  res.render('admin/preview', { host });
});

// Proxy used by the preview iframe; strips headers that prevent embedding.
app.use('/preview-proxy/:id', ensureAdmin, (req, res, next) => {
  const id = req.params.id;
  const host = HOSTS.find(h => h.id === id);
  if (!host) return res.status(404).send('Unknown host');

  // Create a proxy middleware tailored to this host
  const proxy = createProxyMiddleware({
    target: host.upstream,
    changeOrigin: true,
    selfHandleResponse: true,
    proxyTimeout: 15000,
    pathRewrite: (path, req2) => {
      // path starts with /preview-proxy/:id
      const prefix = `/preview-proxy/${id}`;
      let newPath = path.replace(prefix, '') || '/';
      // ensure upstream receives the host's pathPrefix if configured
      if (host.pathPrefix && host.pathPrefix !== '/') {
        // avoid double slashes
        newPath = host.pathPrefix.replace(/\/$/, '') + newPath;
      }
      return newPath;
    },
    onProxyReq(proxyReq, req2, res2) {
      // pass admin identity header for upstream if desired
      if (req.session && req.session.isAdmin) proxyReq.setHeader('X-Admin-User', req.session.username || 'admin');
    },
    onProxyRes(proxyRes, req2, res2) {
      // helper: compute proxy origin used by clients
      const proto = (req2.headers['x-forwarded-proto'] || req2.protocol || 'http');
      const proxyOrigin = `${proto}://${req2.get('host')}`;
      // Remove headers that would prevent embedding in an iframe
      try {
        delete proxyRes.headers['x-frame-options'];
        delete proxyRes.headers['x-content-security-policy'];
        delete proxyRes.headers['content-security-policy'];
        delete proxyRes.headers['frame-options'];
      } catch (e) {}

      // If masking is enabled for this host, perform stronger rewrites
      if (host.maskUpstream) {
        // rewrite Location header
        if (proxyRes.headers.location) {
          try {
            const loc = proxyRes.headers.location;
            if (loc.startsWith(host.upstream)) {
              const newLoc = loc.replace(host.upstream, proxyOrigin + (host.pathPrefix && host.pathPrefix !== '/' ? host.pathPrefix : ''));
              proxyRes.headers.location = newLoc;
            }
          } catch (e) {}
        }

        // rewrite Set-Cookie: remove Domain attribute
        if (proxyRes.headers['set-cookie']) {
          try {
            const cookies = proxyRes.headers['set-cookie'].map(cookie => cookie.replace(/;\s*Domain=[^;]+/i, ''));
            proxyRes.headers['set-cookie'] = cookies;
          } catch (e) {}
        }

        const contentType = (proxyRes.headers['content-type'] || '').toLowerCase();
        if (contentType.includes('text/html')) {
          const chunks = [];
          proxyRes.on('data', chunk => chunks.push(chunk));
          proxyRes.on('end', () => {
            try {
              const body = Buffer.concat(chunks).toString('utf8');
              const upstreamOrigin = host.upstream.replace(/\/$/, '');
              const from = new RegExp(upstreamOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
              const prefix = (host.pathPrefix && host.pathPrefix !== '/') ? host.pathPrefix.replace(/\/$/, '') : '';
              const replaced = body.replace(from, proxyOrigin + prefix);
              Object.keys(proxyRes.headers).forEach(h => {
                if (h.toLowerCase() === 'content-length') delete proxyRes.headers[h];
              });
              res2.writeHead(proxyRes.statusCode, proxyRes.headers);
              res2.end(replaced);
            } catch (e) {
              res2.writeHead(proxyRes.statusCode, proxyRes.headers);
              res2.end();
            }
          });
        } else {
          res2.writeHead(proxyRes.statusCode, proxyRes.headers);
          proxyRes.pipe(res2);
        }
      } else {
        // Not masking: just forward the response with frame headers removed
        res2.writeHead(proxyRes.statusCode, proxyRes.headers);
        proxyRes.pipe(res2);
      }
    }
  });
  return proxy(req, res, next);
});

// Load hosts configuration. If `config.json` exists, use it; otherwise fallback to UPSTREAM env.
let HOSTS = [];
const cfgPath = path.join(__dirname, 'config.json');
if (fs.existsSync(cfgPath)) {
  try {
    HOSTS = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  } catch (err) {
    console.error('Failed parsing config.json:', err.message);
    process.exit(1);
  }
} else {
  if (process.env.UPSTREAM) {
    HOSTS = [{ id: 'default', pathPrefix: '/', upstream: process.env.UPSTREAM }];
  } else {
    console.warn('No config.json found and no UPSTREAM env; create config.json or set UPSTREAM');
  }
}

// Prepare OIDC clients for hosts that include OIDC config
const oidcClients = {};

async function setupOidc() {
  if (!Issuer) {
    console.warn('openid-client Issuer is unavailable; skipping OIDC setup.');
    return;
  }
  for (const host of HOSTS) {
    if (host.oidc && host.oidc.issuer) {
      try {
        const issuer = await Issuer.discover(host.oidc.issuer);
        const client = new issuer.Client({
          client_id: host.oidc.client_id,
          client_secret: host.oidc.client_secret,
          redirect_uris: [host.oidc.redirect_uri],
          response_types: ['code']
        });
        oidcClients[host.id] = { client, config: host.oidc };
        console.log(`OIDC client ready for host ${host.id}`);
      } catch (err) {
        console.error(`Failed to configure OIDC for host ${host.id}:`, err.message);
      }
    }
  }
}

// Simple in-memory user store for non-OIDC hosts (demo only)
const USERS = {
  'alice': 'password123',
  'bob': 's3cret'
};

app.get('/login', (req, res) => {
  // If multiple hosts configured, show choices
  if (HOSTS.length > 1) return res.render('login', { error: null, hosts: HOSTS });
  // otherwise if the single host has OIDC configured, redirect
  const host = HOSTS[0];
  if (host && host.oidc && oidcClients[host.id]) return res.redirect(`/login/${host.id}`);
  res.render('login', { error: null, hosts: HOSTS });
});

// Start login for a specific host (OIDC or local form)
app.get('/login/:hostId', async (req, res) => {
  const hostId = req.params.hostId;
  const host = HOSTS.find(h => h.id === hostId);
  if (!host) return res.status(404).send('Unknown host');

  if (host.oidc && oidcClients[hostId]) {
    const { client } = oidcClients[hostId];
    const code_verifier = generators.codeVerifier();
    const code_challenge = generators.codeChallenge(code_verifier);
    const state = generators.state();
    const nonce = generators.nonce();
    // store PKCE and nonce in session scoped by host
    req.session.oidc = req.session.oidc || {};
    req.session.oidc[hostId] = { code_verifier, state, nonce, dest: req.query.dest || req.session.dest || '/' };
    const authUrl = client.authorizationUrl({
      scope: 'openid profile email',
      code_challenge,
      code_challenge_method: 'S256',
      state,
      nonce,
      redirect_uri: host.oidc.redirect_uri
    });
    return res.redirect(authUrl);
  }

  // No OIDC: render local form with host hint
  return res.render('login', { error: null, hosts: [host] });
});

// Local username/password POST (for non-OIDC hosts)
app.post('/login/:hostId', (req, res) => {
  const hostId = req.params.hostId;
  const host = HOSTS.find(h => h.id === hostId);
  if (!host) return res.status(404).send('Unknown host');

  const { username, password } = req.body || {};
  if (username && password && USERS[username] === password) {
    req.session.user = { username, host: hostId };
    const dest = req.session.dest || host.pathPrefix || '/';
    delete req.session.dest;
    return res.redirect(dest);
  }
  res.status(401).render('login', { error: 'Invalid credentials', hosts: [host] });
});

// OIDC callback per-host
app.get('/callback/:hostId', async (req, res) => {
  const hostId = req.params.hostId;
  const host = HOSTS.find(h => h.id === hostId);
  if (!host) return res.status(404).send('Unknown host');
  const clientEntry = oidcClients[hostId];
  if (!clientEntry) return res.status(400).send('OIDC not configured for this host');

  const params = clientEntry.client.callbackParams(req);
  const saved = (req.session.oidc || {})[hostId];
  if (!saved) return res.status(400).send('Missing OIDC session data');
  try {
    const tokenSet = await clientEntry.client.callback(host.oidc.redirect_uri, params, { code_verifier: saved.code_verifier, state: saved.state, nonce: saved.nonce });
    const userinfo = await clientEntry.client.userinfo(tokenSet.access_token);
    // store tokens and profile in session under host
    req.session.tokens = req.session.tokens || {};
    req.session.tokens[hostId] = tokenSet;
    req.session.user = { username: userinfo.preferred_username || userinfo.email || userinfo.sub, profile: userinfo, host: hostId };
    const dest = saved.dest || host.pathPrefix || '/';
    delete req.session.oidc[hostId];
    return res.redirect(dest);
  } catch (err) {
    console.error('OIDC callback error:', err);
    return res.status(500).send('Authentication error');
  }
});

app.get('/logout', (req, res) => {
  req.session.destroy(() => res.redirect('/login'));
});

// Middleware to require login per host
function findHostForReq(req) {
  // Prefer longest matching pathPrefix
  const urlPath = req.originalUrl || req.url;
  let match = null;
  for (const host of HOSTS) {
    const prefix = host.pathPrefix || '/';
    if (prefix === '/') {
      if (!match) match = host;
    } else if (urlPath.startsWith(prefix)) {
      if (!match || (prefix.length > (match.pathPrefix || '').length)) match = host;
    }
  }
  return match;
}

function requireLoginForHost(req, res, next) {
  const host = findHostForReq(req);
  if (!host) return res.status(404).send('No host configured for this path');
  // If OIDC configured for host, require token
  if (host.oidc) {
    if (req.session && req.session.tokens && req.session.tokens[host.id] && req.session.user && req.session.user.host === host.id) return next();
    // store original path and redirect to host-specific login
    req.session.dest = req.originalUrl;
    return res.redirect(`/login/${host.id}`);
  }
  // Non-OIDC: allow if session user exists (and optionally matches host)
  if (req.session && req.session.user) return next();
  req.session.dest = req.originalUrl;
  return res.redirect('/login');
}

// Proxy handler: select host then proxy, optionally injecting Authorization header
app.use('/', requireLoginForHost, (req, res, next) => {
  const host = findHostForReq(req);
  if (!host) return res.status(404).send('No host configured');
  // build proxy middleware on the fly for this host
  const prefix = host.pathPrefix || '/';
  const pathRewrite = {};
  if (prefix !== '/') {
    // remove prefix when forwarding
    pathRewrite[`^${prefix}`] = '';
  }
  const proxy = createProxyMiddleware({
    target: host.upstream,
    changeOrigin: true,
    proxyTimeout: 10000,
    pathRewrite,
    selfHandleResponse: true, // we may rewrite HTML and headers (masking optional)
    onProxyReq(proxyReq, req, res) {
      // if we have an access token for this host, supply it
      if (req.session && req.session.tokens && req.session.tokens[host.id] && req.session.tokens[host.id].access_token) {
        proxyReq.setHeader('Authorization', `Bearer ${req.session.tokens[host.id].access_token}`);
      }
      // add forwarded user header
      if (req.session && req.session.user) {
        proxyReq.setHeader('X-Forwarded-User', req.session.user.username);
      }
    },
    onProxyRes(proxyRes, req2, res2) {
      // helper: compute proxy origin used by clients
      const proto = (req2.headers['x-forwarded-proto'] || req2.protocol || 'http');
      const proxyOrigin = `${proto}://${req2.get('host')}`;
      // Always remove frame-blocking headers for embedding/preview
      try {
        delete proxyRes.headers['x-frame-options'];
        delete proxyRes.headers['x-content-security-policy'];
        delete proxyRes.headers['content-security-policy'];
        delete proxyRes.headers['frame-options'];
      } catch (e) {}

      // If masking is enabled for this host, perform stronger rewrites
      if (host.maskUpstream) {
        // rewrite Location header to point to proxy origin
        if (proxyRes.headers.location) {
          try {
            const loc = proxyRes.headers.location;
            if (loc.startsWith(host.upstream)) {
              const newLoc = loc.replace(host.upstream, proxyOrigin + (host.pathPrefix && host.pathPrefix !== '/' ? host.pathPrefix : ''));
              proxyRes.headers.location = newLoc;
            }
          } catch (e) {}
        }

        // rewrite Set-Cookie: remove Domain attribute so cookie is set for proxy host
        if (proxyRes.headers['set-cookie']) {
          try {
            const cookies = proxyRes.headers['set-cookie'].map(cookie => cookie.replace(/;\s*Domain=[^;]+/i, ''));
            proxyRes.headers['set-cookie'] = cookies;
          } catch (e) {}
        }

        // For HTML responses, buffer and rewrite absolute URLs from upstream to proxy
        const contentType = (proxyRes.headers['content-type'] || '').toLowerCase();
        if (contentType.includes('text/html')) {
          const chunks = [];
          proxyRes.on('data', chunk => chunks.push(chunk));
          proxyRes.on('end', () => {
            try {
              const body = Buffer.concat(chunks).toString('utf8');
              const upstreamOrigin = host.upstream.replace(/\/$/, '');
              const from = new RegExp(upstreamOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
              const prefix = (host.pathPrefix && host.pathPrefix !== '/') ? host.pathPrefix.replace(/\/$/, '') : '';
              const replaced = body.replace(from, proxyOrigin + prefix);
              // set headers and send
              Object.keys(proxyRes.headers).forEach(h => {
                if (h.toLowerCase() === 'content-length') delete proxyRes.headers[h];
              });
              res2.writeHead(proxyRes.statusCode, proxyRes.headers);
              res2.end(replaced);
            } catch (e) {
              res2.writeHead(proxyRes.statusCode, proxyRes.headers);
              res2.end();
            }
          });
        } else {
          // non-html: pipe straight through but ensure headers updated
          res2.writeHead(proxyRes.statusCode, proxyRes.headers);
          proxyRes.pipe(res2);
        }
      } else {
        // Not masking: just forward the response with frame headers removed
        res2.writeHead(proxyRes.statusCode, proxyRes.headers);
        proxyRes.pipe(res2);
      }
    }
  });
  return proxy(req, res, next);
});

// Initialize and start server
setupOidc().then(() => {
  app.listen(PORT, () => {
    console.log(`Auth proxy listening on http://localhost:${PORT}`);
  });
}).catch(err => {
  console.error('Failed to setup OIDC clients:', err);
  process.exit(1);
});
