/**
 * Portal-specific CLI authentication via cielaracode.com.
 *
 * Uses the portal's CLI authorization code flow with PKCE:
 *   1. Generate PKCE pair + state
 *   2. Open browser to /cli-login with code_challenge + redirect_uri
 *   3. User signs in/up on the portal
 *   4. Portal redirects back to localhost with one-time code
 *   5. CLI exchanges code + code_verifier for session tokens
 *   6. Tokens stored in OS keychain via credential-store.js
 */

const crypto = require('crypto');
const http = require('http');
const { spawn } = require('child_process');

const {
  getConfiguredPortalUrl,
  normalizeBaseUrl,
  readConfig,
  upsertConnection,
  writeConfig,
} = require('./config');
const { storeCredentials, retrieveCredentials, removeCredentials } = require('./credential-store');

const DEV_PORTAL_URL = 'https://portal.cielara-dev.net';
const PROD_PORTAL_URL = 'https://cielaracode.com';
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
const REFRESH_SKEW_MS = 5 * 60 * 1000;

// Default portal for `cielara login` when none is configured. Dev/beta builds
// (DEV_BUILD=true) default to the dev portal so internal testers hit it without
// extra config; prod builds (DEV_BUILD flipped to false by the release pipeline)
// default to cielaracode.com. `cielara config set portal-url <url>` overrides
// either way — both builds accept logins to both portals. The require is lazy
// to avoid a module-load cycle with license.js.
function defaultPortalUrl() {
  const { DEV_BUILD } = require('./license');
  return DEV_BUILD ? DEV_PORTAL_URL : PROD_PORTAL_URL;
}

function resolvePortalUrl() {
  return getConfiguredPortalUrl(readConfig()) || defaultPortalUrl();
}

function base64url(buffer) {
  return Buffer.from(buffer)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

function makePKCE() {
  const verifier = base64url(crypto.randomBytes(32));
  const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
  return { verifier, challenge };
}

function openBrowser(url) {
  const platform = process.platform;
  let command, args;
  if (platform === 'darwin') {
    command = 'open';
    args = [url];
  } else if (platform === 'win32') {
    command = 'cmd';
    args = ['/c', 'start', '', url.replace(/&/g, '^&')];
  } else {
    command = 'xdg-open';
    args = [url];
  }
  try {
    const child = spawn(command, args, { detached: true, stdio: 'ignore' });
    child.on('error', () => {});
    child.unref();
    return true;
  } catch {
    return false;
  }
}

function startCallbackServer(expectedState, portalUrl) {
  let settle;
  let closed = false;
  const result = new Promise((resolve, reject) => {
    settle = { resolve, reject };
  });
  const close = () => {
    if (closed) return Promise.resolve();
    closed = true;
    // unref first: Chrome/Edge on Windows open speculative preconnect sockets
    // to the loopback port that arrive AFTER closeAllConnections and keep
    // server.close() from completing for ~5s (Node's default keepAliveTimeout).
    // Without unref, those lingering sockets block process exit even though we
    // don't need the server anymore.
    server.unref();
    server.closeAllConnections?.();
    return new Promise((resolveClose) => {
      try {
        server.close(() => resolveClose());
        // Belt-and-suspenders: never block the login flow on a zombie socket.
        // If a lingering Windows preconnect socket keeps server.close()'s
        // callback from firing, resolve anyway after 1s. Unref'd so the timer
        // itself never holds the process open.
        setTimeout(resolveClose, 1000).unref?.();
      } catch {
        resolveClose();
      }
    });
  };

  const server = http.createServer((req, res) => {
    const reqUrl = new URL(req.url || '/', 'http://127.0.0.1');
    if (reqUrl.pathname !== '/callback') {
      res.writeHead(404, { 'content-type': 'text/plain', connection: 'close' });
      res.end('Not found');
      return;
    }

    const state = reqUrl.searchParams.get('state') || '';
    const error = reqUrl.searchParams.get('error');
    const code = reqUrl.searchParams.get('code');

    const redirect = (status, location) => {
      res.writeHead(status, { location, connection: 'close' });
      res.end();
    };

    if (state !== expectedState) {
      redirect(302, `${portalUrl}/cli-login?error=state_mismatch`);
      settle.reject(new Error('State mismatch'));
      return;
    }
    if (error) {
      redirect(302, `${portalUrl}/cli-login?error=${encodeURIComponent(error)}`);
      settle.reject(new Error(`Authorization failed: ${error}`));
      return;
    }
    if (!code) {
      redirect(302, `${portalUrl}/cli-login?error=no_code`);
      settle.reject(new Error('No authorization code returned'));
      return;
    }

    redirect(302, `${portalUrl}/cli-login/success`);
    settle.resolve(code);
  });

  const listening = new Promise((resolve, reject) => {
    server.once('error', reject);
    server.listen(0, '127.0.0.1', () => {
      const address = server.address();
      resolve({
        redirectUri: `http://127.0.0.1:${address.port}/callback`,
        // Deliver the code as soon as the callback arrives; do NOT gate it on
        // the server fully closing. server.close() can be delayed by lingering
        // browser preconnect sockets (Windows), and the caller closes the
        // server in its own finally block regardless.
        waitForCode: result,
        close,
      });
    });
  });

  return listening;
}

async function postJSON(url, body, headers = {}) {
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      accept: 'application/json',
      'content-type': 'application/json',
      ...headers,
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });
  const text = await res.text();
  let data;
  try {
    data = text ? JSON.parse(text) : {};
  } catch {
    data = { message: text };
  }
  if (!res.ok) {
    const errObj = data.error || {};
    const message = errObj.message || data.message || `HTTP ${res.status}`;
    throw new Error(message);
  }
  return data;
}

/**
 * Login to the portal via browser-based PKCE flow.
 * @param {object} options
 * @param {string} [options.name='default'] — connection name
 * @returns {{ name: string, connection: object }}
 */
async function portalLogin(options = {}) {
  const name = options.name || 'default';
  const portalUrl = resolvePortalUrl();

  const state = base64url(crypto.randomBytes(24));
  const { verifier, challenge } = makePKCE();
  const callback = await startCallbackServer(state, portalUrl);

  const loginUrl = new URL(`${portalUrl}/cli-login`);
  loginUrl.searchParams.set('code_challenge', challenge);
  loginUrl.searchParams.set('state', state);
  loginUrl.searchParams.set('redirect_uri', callback.redirectUri);

  process.stderr.write(`Opening browser for Cielara login:\n${loginUrl.toString()}\n\n`);
  if (!openBrowser(loginUrl.toString())) {
    process.stderr.write('Could not open a browser automatically. Open the URL above.\n');
  }

  let timeoutHandle;
  const timeout = new Promise((_, reject) => {
    timeoutHandle = setTimeout(() => reject(new Error('Timed out waiting for browser login')), LOGIN_TIMEOUT_MS);
  });

  let code;
  try {
    code = await Promise.race([callback.waitForCode, timeout]);
  } finally {
    clearTimeout(timeoutHandle);
    // Fire-and-forget: lingering browser preconnect sockets on Windows can
    // keep server.close()'s callback from ever firing even with
    // closeAllConnections() (race, or unavailable on Node <18.2). The OS
    // reclaims the port when the process exits after auth.
    callback.close().catch(() => {});
  }

  const cred = await postJSON(`${portalUrl}/v1/auth/cli-exchange`, {
    code,
    code_verifier: verifier,
  });

  const credentials = {
    accessToken: cred.access_token,
    refreshToken: cred.refresh_token,
    expiresAt: cred.expires_at,
    refreshExpiresAt: cred.refresh_expires_at,
    installationID: cred.installation_id,
    user: cred.user,
    portalUrl,
    updatedAt: new Date().toISOString(),
  };

  await storeCredentials(name, credentials);

  // Only store non-secret metadata in connector.json. Tokens live in keychain.
  const config = readConfig();
  const next = upsertConnection(config, name, {
    portalUrl,
    userEmail: cred.user?.email,
    installationID: cred.installation_id,
    expiresAt: cred.expires_at,
    updatedAt: new Date().toISOString(),
  });
  writeConfig(next);

  return { name, connection: credentials };
}

function portalNeedsRefresh(credentials) {
  if (!credentials?.accessToken || !credentials?.expiresAt) return true;
  const expires = Date.parse(credentials.expiresAt);
  if (Number.isNaN(expires)) return true;
  return expires - Date.now() < REFRESH_SKEW_MS;
}

async function portalRefresh(name, credentials) {
  if (!portalNeedsRefresh(credentials)) {
    return { credentials, refreshed: false };
  }
  if (!credentials.refreshToken) {
    throw new Error('No refresh token stored. Run `cielara login` again.');
  }

  const portalUrl = normalizeBaseUrl(
    credentials.portalUrl || getConfiguredPortalUrl(readConfig()) || defaultPortalUrl(),
  );
  const cred = await postJSON(`${portalUrl}/v1/refresh`, {
    refresh_token: credentials.refreshToken,
  });

  const next = {
    ...credentials,
    accessToken: cred.access_token,
    refreshToken: cred.refresh_token || credentials.refreshToken,
    expiresAt: cred.expires_at,
    refreshExpiresAt: cred.refresh_expires_at,
    installationID: cred.installation_id || credentials.installationID,
    portalUrl,
    updatedAt: new Date().toISOString(),
  };

  await storeCredentials(name, next);
  return { credentials: next, refreshed: true };
}

async function portalEnsureFresh(name) {
  name = name || 'default';
  let credentials = await retrieveCredentials(name);
  if (!credentials) {
    throw new Error('Not logged in. Run `cielara login` to authenticate.');
  }
  const result = await portalRefresh(name, credentials);
  return { name, credentials: result.credentials };
}

async function portalLogout(name) {
  name = name || 'default';
  await removeCredentials(name);
}

module.exports = {
  defaultPortalUrl,
  resolvePortalUrl,
  portalLogin,
  portalLogout,
  portalNeedsRefresh,
  portalRefresh,
  portalEnsureFresh,
};
