const express = require('express');
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../src/utilities/constants');
const { AuthenticationError, NotFoundError } = require('../../src/utilities/errors');
const { ok } = require('../../src/utils/responses');

/**
 * Auth SSO gate routes factory
 * @param {Object} deps - Explicit dependencies (includes session helpers)
 * @returns {express.Router}
 */
module.exports = function(deps) {
  const router = express.Router();

  // Extract dependencies
  const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager, siteConfig } = deps;

  // Create ctx-like object for compatibility
  const ctx = {
    credentialManager,
    fetchT,
    getServiceById,
    licenseManager,
    servicesStateManager
  };

  // Caddy forward_auth gate: checks TOTP session + injects service credentials
  router.get('/auth/gate/:serviceId', asyncHandler(async (req, res) => {
    res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
    const serviceId = req.params.serviceId;

    // SECURITY [DC-026]: Session is required whenever TOTP is enabled, regardless
    // of sessionDuration. Previously the check was gated on `!== 'never'`, which
    // meant an admin setting TOTP to never-expire accidentally created an
    // authentication-free path to credential injection. Even with a non-expiring
    // session, the request itself must still present a valid session cookie.
    if (totpConfig.enabled) {
      const valid = session.isValid(req);
      if (!valid) return errorResponse(res, 401, 'Session expired or invalid', { authenticated: false });
    }

    // Session valid (or TOTP disabled) - inject credentials if premium SSO is active
    let injected = false;
    const ssoEnabled = ctx.licenseManager.hasFeature('sso');
    if (!ssoEnabled) {
      // Free tier: TOTP gate passes but no credential injection
      return res.status(200).json({ authenticated: true, credentialsInjected: false, premiumRequired: true });
    }
    try {
      const services = await ctx.servicesStateManager.read();
      const service = services.find(s => s.id === serviceId);

      // External services: inject seedhost Basic Auth
      if (service && service.isExternal) {
        const sharedUser = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null);
        const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null);
        const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null);
        const password = svcPass || sharedPass;
        if (sharedUser && password) {
          const basicAuth = Buffer.from(`${sharedUser}:${password}`).toString('base64');
          res.setHeader('Authorization', `Basic ${basicAuth}`);
          injected = true;
          if (service.externalUrl) {
            const appCookies = await getAppSession(serviceId, service.externalUrl, sharedUser, password);
            if (appCookies) res.setHeader('X-App-Cookie', appCookies);
          }
        }
      }

      // Non-external services: check per-service Basic Auth
      if (!service || !service.isExternal) {
        const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
        const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
        if (username && password) {
          const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
          res.setHeader('Authorization', `Basic ${basicAuth}`);
          injected = true;
          if (service && service.url) {
            const appCookies = await getAppSession(serviceId, service.url, username, password);
            if (appCookies) res.setHeader('X-App-Cookie', appCookies);
            if (serviceId === 'plex') {
              const plexCached = appSessionCache.get('plex');
              if (plexCached && plexCached.token) res.setHeader('X-Plex-Token', plexCached.token);
            }
            if (serviceId === 'jellyfin' || serviceId === 'emby') {
              const mediaCached = appSessionCache.get(serviceId);
              if (mediaCached && mediaCached.token) res.setHeader('X-Emby-Token', mediaCached.token);
            }
          }
        }
      }

      // Inject API key
      const arrKey = await ctx.credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
      const svcKey = await ctx.credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
      const apiKey = arrKey || svcKey;
      if (apiKey) { res.setHeader('X-Api-Key', apiKey); injected = true; }
    } catch (e) {
      log.warn('auth', 'Credential error', { serviceId, error: e.message });
    }

    res.status(200).json({ authenticated: true, credentialsInjected: injected });
  }, 'auth-gate'));

  // Return cached app session token for client-side auth (Premium SSO feature)
  router.get('/auth/app-token/:serviceId', ctx.licenseManager.requirePremium('sso'), asyncHandler(async (req, res) => {
    const { serviceId } = req.params;

    // SECURITY [DC-026]: Same gate fix as /auth/gate — drop the sessionDuration
    // exception. TOTP-enabled means session is required, period.
    if (totpConfig.enabled) {
      if (!session.isValid(req)) throw new AuthenticationError('Not authenticated');
    }

    // Jellyfin/Emby: separate browser-specific token
    if (serviceId === 'jellyfin' || serviceId === 'emby') {
      const browserCacheKey = `${serviceId}_browser`;
      const browserCached = appSessionCache.get(browserCacheKey);
      if (browserCached && browserCached.exp > Date.now()) {
        if (browserCached.failed) return errorResponse(res, 500, 'Login recently failed');
        if (browserCached.token) {
          const resp = { token: browserCached.token };
          if (browserCached.tokenData) Object.assign(resp, browserCached.tokenData);
          return res.json(resp);
        }
      }
      try {
        const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
        const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
        if (!username || !password) {
          return errorResponse(res, 428, '[DC-500] No credentials stored', {
            credentialsRequired: true,
            serviceId,
          });
        }
        const service = await ctx.getServiceById(serviceId);
        const baseUrl = service?.url;
        if (!baseUrl) throw new NotFoundError('No service URL');
        const mediaAuth = buildMediaAuth(APP.DEVICE_IDS.BROWSER);
        const authResp = await ctx.fetchT(`${baseUrl}/Users/AuthenticateByName`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'X-Emby-Authorization': mediaAuth },
          body: JSON.stringify({ Username: username, Pw: password }),
        }, TIMEOUTS.HTTP_LONG);
        const authData = await authResp.json();
        if (authData.AccessToken) {
          const tokenData = { userId: authData.User?.Id, serverId: authData.ServerId, serverName: authData.User?.ServerName || serviceId };
          appSessionCache.set(browserCacheKey, { token: authData.AccessToken, tokenData, exp: Date.now() + SESSION_TTL.TOKEN_SESSION });
          return res.json({ token: authData.AccessToken, ...tokenData });
        }
        return errorResponse(res, 500, '[DC-501] Authentication failed');
      } catch (e) {
        log.warn('auth', 'Browser token error', { serviceId, error: e.message });
        return errorResponse(res, 500, e.message);
      }
    }

    // Check cache first
    const cached = appSessionCache.get(serviceId);
    if (cached && cached.exp > Date.now()) {
      if (cached.failed) return errorResponse(res, 500, '[DC-501] Login recently failed, retrying in a few minutes');
      if (cached.token) {
        const resp = { token: cached.token };
        if (cached.tokenData) Object.assign(resp, cached.tokenData);
        return res.json(resp);
      }
      const m = cached.cookies.match(/^token=(.+)$/);
      if (m) return res.json({ token: m[1] });
      return res.json({ cookies: cached.cookies });
    }

    // No cache — get fresh session
    try {
      const service = await ctx.getServiceById(serviceId);
      if (!service) throw new NotFoundError('Service not found');
      const baseUrl = service.externalUrl || service.url;
      if (!baseUrl) throw new NotFoundError('No service URL');

      let username, password;
      if (service.isExternal) {
        username = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null);
        const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null);
        const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null);
        password = svcPass || sharedPass;
      } else {
        username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
        password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
      }

      if (!username || !password) {
        return errorResponse(res, 428, '[DC-500] No credentials stored', {
          credentialsRequired: true,
          serviceId,
        });
      }

      const appCookies = await getAppSession(serviceId, baseUrl, username, password);
      if (appCookies) {
        const freshCached = appSessionCache.get(serviceId);
        if (freshCached && freshCached.token) {
          const resp = { token: freshCached.token };
          if (freshCached.tokenData) Object.assign(resp, freshCached.tokenData);
          return res.json(resp);
        }
        const m = appCookies.match(/^token=(.+)$/);
        if (m) return res.json({ token: m[1] });
        return res.json({ cookies: appCookies });
      }

      errorResponse(res, 500, '[DC-501] Login failed');
    } catch (e) {
      log.warn('auth', 'App-token error', { error: e.message });
      errorResponse(res, 500, e.message);
    }
  }, 'auth-app-token'));

  // A browser that already has a valid status.sami session must not be asked
  // for TOTP again just because it opened another private-TLD service host.
  // Mint a fresh one-time token that the target host can exchange for its own
  // host-only cookie. This route is intentionally session-protected both by
  // the global middleware and here (defence in depth).
  router.get('/auth/sso-handoff', (req, res) => {
    res.setHeader('Cache-Control', 'no-store');
    if (!session.isValid(req)) {
      return errorResponse(res, 401, 'Session expired or invalid');
    }
    const serviceId = String(req.query.serviceId || '');
    if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) {
      return errorResponse(res, 400, 'Valid serviceId is required');
    }
    const suffix = String(siteConfig?.tld || '.sami');
    const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
    ok(res, { ssoToken: session.createHandoffToken(expectedHost) });
  });

  // Cross-subdomain SSO handoff: exchanges a short-lived single-use token
  // (minted by /totp/verify or /auth/sso-handoff) for a HOST-ONLY session
  // cookie on whichever
  // *.sami origin calls this. Needed because Domain=.sami cookies are
  // silently rejected by real browsers (.sami is an unregistered TLD, so
  // browsers treat "sami" as the effective public suffix and refuse to set
  // a cookie scoped to it) — see middleware.js for the full explanation.
  // Public route (no session required to call it) since a fresh visitor to
  // a gated service has no session yet by definition; the token itself is
  // the credential, and it's one-time-use with a 60s TTL.
  router.get('/auth/sso-exchange', (req, res) => {
    res.setHeader('Cache-Control', 'no-store');
    const token = req.query.token;
    const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '')
      .split(',')[0].trim().replace(/:\d+$/, '').toLowerCase();
    if (!session.redeemHandoffToken(token, forwardedHost)) {
      return errorResponse(res, 401, 'Invalid or expired handoff token');
    }
    session.setCookieHostOnly(res, totpConfig.sessionDuration);
    if (req.query.return) {
      let returnPath = '/';
      try {
        const parsed = new URL(req.query.return, 'https://dashcaddy.invalid');
        if (parsed.origin === 'https://dashcaddy.invalid') {
          returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
        }
      } catch (_) {
        // Invalid or cross-origin return values fall back to the service root.
      }
      return res.redirect(303, returnPath);
    }
    ok(res, { authenticated: true });
  });

  // Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
  router.get('/auth/login-page', (req, res) => {
    const service = (req.query.service || '').replace(/[^a-z]/g, '');
    const configuredHost = siteConfig?.dashboardHost;
    const dashboardOrigin = typeof configuredHost === 'string'
      && /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(configuredHost)
      ? `https://${configuredHost}`
      : 'https://status.sami';
    const html = buildLoginPage(service, dashboardOrigin);
    if (!html) return res.status(404).send('Unknown service');
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.setHeader('Cache-Control', 'no-store');
    // This page is a server-rendered shell whose entire auto-login logic runs
    // in an inline <script> (no external bundle - it's built per-service in
    // buildLoginPage()). The app-wide Helmet CSP sets script-src 'self' with
    // no inline exception, which silently blocks that script from ever
    // running - no console-visible error on the page, no JS timeout fires,
    // it just sits on "Signing in to ..." forever. Relax script-src for this
    // one response only; every other route keeps the strict app-wide policy.
    res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data:; object-src 'none'; media-src 'self'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'");
    res.send(html);
  });

  return router;
};

function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
  // Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
  // disabled or blocked. The cookie is sent automatically because we hit the
  // same origin (plex.sami); if the API returns 200 the user has a valid
  // session and we render the auto-login body; if 401, the meta-refresh kicks
  // in and sends them to status.sami to authenticate first.
  const SHELL = (body) => `<!DOCTYPE html>
  <html><head><meta http-equiv="Cache-Control" content="no-store"><title>__TITLE__</title>
  <style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
  </head><body><p id="m">__TITLE__</p><div id="d"></div>
  <script>(function(){
  var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m'),dashboardOrigin=__DASHBOARD_ORIGIN__;
  // 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
  // (default 8s) so a hung upstream can NEVER leave the page stuck on
  // "Signing in to Plex..." indefinitely. Also: if check-session returns
  // authenticated but app-token fails for any reason (no creds stored,
  // upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
  // stale token exists in localStorage, instead of failing silently.
  function go(u){setTimeout(function(){location.replace(u)},300)}
  function authUrl(){return dashboardOrigin+'?auth=required&return='+encodeURIComponent(location.href)}
  function authLink(label){return '<a href="'+authUrl()+'">'+label+'</a>'}
  function vault(svc){go(dashboardOrigin+'?credentials='+encodeURIComponent(svc)+'&return='+encodeURIComponent(location.href))}
  function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
  function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
  function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
  function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
  // Belt-and-suspenders hard timeout: if nothing in this script succeeds
  // within 15s, force-redirect to status.sami so the user can re-auth.
  var overallTimer=setTimeout(function(){go(authUrl())},15000);
  // Cross-subdomain SSO handoff: status.sami can't share its session cookie
  // with this origin (Domain=.sami cookies are silently rejected by real
  // browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
  // effective public suffix). Instead status.sami hands us a one-time token
  // in the URL after a successful TOTP verify; exchange it here for a cookie
  // scoped to just this host, then strip it from the URL so it can't be
  // reused or leak via history/referrer. If there's no token (or the
  // exchange fails - expired, already used, etc.) this is a no-op and we
  // fall through to the normal check-session flow below exactly as before.
  var dcParams=new URLSearchParams(location.search);
  var dcToken=dcParams.get('dc_token');
  var preExchange=Promise.resolve();
  if(dcToken){
    dcParams.delete('dc_token');
    var dcQs=dcParams.toString();
    try{history.replaceState({},'',location.pathname+(dcQs?'?'+dcQs:''))}catch(_){}
    preExchange=fetch('/dashcaddy-api/api/auth/sso-exchange?token='+encodeURIComponent(dcToken),{credentials:'include',signal:withTimeout(5000)}).catch(function(){});
  }
  // Pre-check session before attempting auto-login. If the user is not logged
  // in, redirect to status.sami for TOTP auth first. The return= param sends
  // them back to this login page after authenticating so auto-login can run.
  preExchange.then(function(){
  return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
  }).then(function(r){return r.json()}).then(function(st){
    if(!st||!st.success||!st.authenticated){go(authUrl());return}
  ${body}
  }).catch(function(e){fail('Could not reach DashCaddy. '+authLink('Sign in at DashCaddy'),'Auth check error: '+(e&&e.message||'unknown'))})
  })()</script></body></html>`;

  const pages = {
    chat: {
      title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
      body: `d.textContent='Fetching token from DashCaddy...';
ft('chat').then(function(r){return r.text()}).then(function(t){
  try{var j=JSON.parse(t);if(j.credentialsRequired){vault('chat');return}if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
  // No token but chat is reachable — fall through to manual UI link below
  fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
  catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/?direct=1">Open Chat manually</a>','Fetch error: '+(e&&e.message||'unknown'))})`
    },
    plex: {
      title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
      body: `ft('plex').then(function(r){return r.json()}).then(function(j){
  if(j.credentialsRequired){vault('plex');return}if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
  // No token returned. Three fallbacks in priority order:
  // 1. Stale token in localStorage — Plex may still accept it.
  if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
  // 2. Manual link so the user is never trapped on this page.
  fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
    },
    jellyfin: {
      title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
      body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
  if(j.credentialsRequired){vault('jellyfin');return}if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
  if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
  fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
    },
    emby: {
      title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
      body: `ft('emby').then(function(r){return r.json()}).then(function(j){
  if(j.credentialsRequired){vault('emby');return}if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
  if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
  fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
    },
  };

  const cfg = pages[service];
  if (!cfg) return null;
  return SHELL(cfg.body)
    .replace(/__TITLE__/g, cfg.title)
    .replace('__BG__', cfg.bg)
    .replace('__ACCENT__', cfg.accent)
    .replace('__DASHBOARD_ORIGIN__', JSON.stringify(dashboardOrigin));
}
