/**
 * oidc-flow.ts — modfolio-connect SSO 의 OIDC PKCE 브라우저 플로우 (Phase 2.8).
 *
 * `commands/login.ts` 에서 분리 (2026-06-02 리팩토링) — 프로토콜 무관 OAuth redirect 기계 부분
 * (PKCE 생성 → loopback callback → /token 교환)을 독립 모듈로. login.ts 는 athsra 고유 셸
 * (keyring·config·worker /auth/sso 교환·master pw)만 유지. 동작·출력·exit code 보존 (순수 추출).
 *
 * `openBrowser` 는 device-login (login.ts deviceLoginCmd) 도 사용 — 여기서 export (중복 사본 금지).
 */

import { spawn } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { errMessage, isRecord } from './err.ts';

/** OIDC PKCE 플로우 엔드포인트 + 파라미터 (endpoint 는 discovery 로 해석 후 주입). */
export interface OidcFlowOptions {
  /** Connect authorize endpoint (discovery 로 해석된 값) */
  authorizeUrl: string;
  /** Connect token endpoint (discovery 로 해석된 값) */
  tokenUrl: string;
  /** Connect 측 CLI client (PKCE public, no secret) */
  clientId: string;
  /** OIDC scope */
  scope: string;
  /** callback timeout (ms) — 사용자 browser 작업 대기 */
  callbackTimeoutMs: number;
}

/**
 * SSO 기본 설정 — endpoint 는 **discovery 로 해석**하므로 하드코딩하지 않는다.
 *
 * connect 는 OIDC 를 `/sso/*` 아래에서만 서빙하고 discovery 문서로 정확히 광고한다.
 * 과거 bare `/authorize` 하드코딩이 404 의 근본 원인이었다 (connect → athsra 메시지 2026-06-15).
 */
export interface SsoConfig {
  /** OIDC/OAuth discovery 문서 URL (OIDC Discovery / RFC 8414). connect 가 `/sso/*` 를 광고. */
  discoveryUrl: string;
  /** 기대 issuer (discovery host 와 분리됨 — RFC 8414). connect 의 issuer 식별자. */
  issuer: string;
  /** Connect 측 CLI client (PKCE public, no secret). connect 에 `athsra-cli` 로 등록. */
  clientId: string;
  scope: string;
  callbackTimeoutMs: number;
}

export const SSO_DEFAULTS: SsoConfig = {
  discoveryUrl: 'https://login.modfolio.io/.well-known/openid-configuration',
  issuer: 'https://connect.modfolio.io',
  clientId: 'athsra-cli',
  scope: 'openid profile email',
  callbackTimeoutMs: 5 * 60 * 1000,
};

/** discovery 로 해석된 endpoint 쌍. */
export interface ResolvedEndpoints {
  authorizeUrl: string;
  tokenUrl: string;
}

/** OIDC discovery 문서 중 우리가 쓰는 필드만의 타입 가드 (외부 입력 → unknown 검증). */
function hasOidcEndpoints(
  v: unknown,
): v is { authorization_endpoint: string; token_endpoint: string } {
  return (
    isRecord(v) &&
    typeof v.authorization_endpoint === 'string' &&
    typeof v.token_endpoint === 'string'
  );
}

/** discovery 실패 시 폴백 — discovery URL 의 origin 에 connect 의 안정 계약(`/sso/*`) 을 붙인다. */
function fallbackEndpoints(discoveryUrl: string): ResolvedEndpoints {
  const { origin } = new URL(discoveryUrl);
  return { authorizeUrl: `${origin}/sso/authorize`, tokenUrl: `${origin}/sso/token` };
}

/**
 * authorize/token endpoint 를 OIDC **discovery 로 해석**한다 (정공법 — 하드코딩 경로 금지).
 *
 * 우선순위: 명시 override(둘 다 설정 시) > discovery 문서 > 폴백(`/sso/*`).
 * 어떤 환경(dev/staging/prod)·향후 경로 변경에도 discovery 가 정답을 광고하므로 자동 적응한다.
 * 부분 override(authorize 또는 token 한쪽)는 그 쪽만 고정하고 나머지는 discovery/폴백에서 채운다.
 */
export async function resolveOidcEndpoints(
  cfg: Pick<SsoConfig, 'discoveryUrl'>,
  overrides: { authorizeUrl?: string; tokenUrl?: string } = {},
): Promise<ResolvedEndpoints> {
  // 명시 override 가 둘 다 있으면 discovery 생략 (오프라인/엣지 E2E).
  if (overrides.authorizeUrl && overrides.tokenUrl) {
    return { authorizeUrl: overrides.authorizeUrl, tokenUrl: overrides.tokenUrl };
  }
  try {
    const res = await fetch(cfg.discoveryUrl, { headers: { accept: 'application/json' } });
    if (res.ok) {
      const doc: unknown = await res.json();
      if (hasOidcEndpoints(doc)) {
        return {
          authorizeUrl: overrides.authorizeUrl ?? doc.authorization_endpoint,
          tokenUrl: overrides.tokenUrl ?? doc.token_endpoint,
        };
      }
    }
  } catch {
    // 네트워크/파싱 실패 → 폴백 (connect 의 `/sso/*` 는 안정 계약).
  }
  const fb = fallbackEndpoints(cfg.discoveryUrl);
  return {
    authorizeUrl: overrides.authorizeUrl ?? fb.authorizeUrl,
    tokenUrl: overrides.tokenUrl ?? fb.tokenUrl,
  };
}

/** 콜백 최종 결과 — 토큰 교환이 끝난 뒤에야 확정된다(조기 성공표시 #3 차단). */
export interface CallbackOutcome {
  ok: boolean;
  /** 실패 시 사용자에게 보여줄 안내 메시지(성공 시 무시). */
  message?: string;
}

/** 외부 텍스트(에러 메시지 등)를 HTML 에 넣기 전 escape — 무신뢰 입력 가드(정공법). */
function escapeHtml(s: string): string {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

/**
 * 콜백 결과 페이지(athsra cute-alism 브랜드 정합 — `docs/brand-passport.md` 토큰 인라인).
 *
 * CLI loopback 페이지라 외부 asset/폰트 파이프라인이 없어 self-contained 다(눈대중 금지 — 색·섀도우·
 * 보더는 brand-passport 실값). **성공 페이지는 토큰 교환이 실제로 성공한 뒤에만 렌더된다**(#3 차단).
 */
export function renderResultPage(outcome: CallbackOutcome): string {
  const ok = outcome.ok;
  // brand-passport.md Color System
  const cream = '#fdf5e6';
  const card = '#ffffff';
  const ink = '#33294a';
  const ink2 = '#6f6489';
  const accent = ok ? '#57c596' : '#ff8f8f'; // synced / drift
  const accentDeep = ok ? '#1d7a55' : '#b8332a'; // synced-deep / drift-deep
  const title = ok ? 'athsra 로그인 완료' : 'athsra 로그인 실패';
  const subtitle = ok
    ? '터미널로 돌아가세요. 이 창은 닫아도 됩니다.'
    : '터미널의 안내를 확인하세요. 이 창은 닫아도 됩니다.';
  const detail =
    !ok && outcome.message ? `\n      <p class="detail">${escapeHtml(outcome.message)}</p>` : '';
  const glyph = ok ? '<path d="M5 13l4 4L19 7" />' : '<path d="M6 6l12 12M18 6L6 18" />';
  return `<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>${title}</title>
  <style>
    :root { color-scheme: light only; }
    * { box-sizing: border-box; }
    body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 1.5rem;
      background: ${cream}; color: ${ink};
      font-family: 'Pretendard Variable', Pretendard, system-ui, -apple-system, 'Segoe UI', sans-serif; }
    .card { background: ${card}; border: 2.5px solid ${ink}; border-radius: 22px;
      box-shadow: 4px 4px 0 ${ink}; padding: 2.5rem 2.25rem; max-width: 25rem; width: 100%; text-align: center; }
    .badge { width: 4rem; height: 4rem; margin: 0 auto 1.25rem; border-radius: 9999px;
      border: 2.5px solid ${ink}; box-shadow: 3px 3px 0 ${ink}; background: ${accent}; display: grid; place-items: center; }
    .badge svg { width: 2rem; height: 2rem; stroke: #fff; stroke-width: 3; fill: none; stroke-linecap: round; stroke-linejoin: round; }
    h1 { margin: 0 0 0.5rem; font-size: 1.4rem; color: ${accentDeep}; letter-spacing: -0.01em; }
    p { margin: 0.25rem 0 0; font-size: 0.95rem; line-height: 1.55; color: ${ink2}; }
    .detail { margin-top: 1rem; font-size: 0.85rem; color: ${accentDeep};
      background: #fff0ef; border-radius: 12px; padding: 0.6rem 0.8rem; word-break: break-word; }
    .brand { margin-top: 1.75rem; font-size: 0.8rem; color: ${ink2}; letter-spacing: 0.02em; }
  </style>
</head>
<body>
  <main class="card" role="status" aria-live="polite">
    <div class="badge" aria-hidden="true"><svg viewBox="0 0 24 24">${glyph}</svg></div>
    <h1>${title}</h1>
    <p>${subtitle}</p>${detail}
    <p class="brand">athsra · E2EE secret store</p>
  </main>
</body>
</html>`;
}

/** 유효 콜백 핸들 — 응답을 보류한 채 호출부에 code 를 넘기고, 토큰 교환 후 finish 로 최종 페이지 렌더. */
export interface CallbackHandle {
  code: string;
  state: string;
  /**
   * 토큰 교환 결과로 브라우저 페이지를 렌더하고 서버를 닫는다(멱등 — 첫 호출만 유효).
   * ok:true 는 교환 성공 뒤에만 전달되므로 조기 성공표시(#3)가 원천적으로 불가능.
   */
  finish(outcome: CallbackOutcome): void;
}

/**
 * ephemeral loopback HTTP 콜백 서버.
 *
 * 유효 콜백 수신 시 **응답을 보류**하고 handle 을 resolve → 호출부가 /token 교환 후
 * `handle.finish(결과)` 로 최종 페이지를 렌더한다. 그 결과 브라우저는 한 번의 렌더로 진실된 상태
 * (완료/실패)만 보게 되어 "교환 전 ✓ 완료" 버그(#3)가 사라진다. 인가 에러/형식오류/state 불일치는
 * 토큰 교환 이전에 확정되므로 즉시 브랜드 에러 페이지 + reject.
 */
export async function waitForCallback(
  expectedState: string,
  port: number,
  timeoutMs: number,
): Promise<CallbackHandle> {
  return await new Promise<CallbackHandle>((resolve, reject) => {
    let settled = false;
    let timer: ReturnType<typeof setTimeout> | undefined;
    const server = createServer((req: IncomingMessage, res: ServerResponse) => {
      const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
      if (url.pathname !== '/callback') {
        res.statusCode = 404;
        res.end('not found');
        return;
      }
      const code = url.searchParams.get('code');
      const state = url.searchParams.get('state');
      const error = url.searchParams.get('error');
      const rejectWith = (status: number, userMsg: string, err: Error): void => {
        settled = true;
        if (timer) clearTimeout(timer);
        res.statusCode = status;
        res.setHeader('content-type', 'text/html; charset=utf-8');
        res.end(renderResultPage({ ok: false, message: userMsg }));
        server.close();
        reject(err);
      };
      if (error) {
        rejectWith(400, `인가 실패: ${error}`, new Error(`Connect authorize error: ${error}`));
        return;
      }
      if (!code || !state) {
        rejectWith(400, 'code 또는 state 누락', new Error('Missing code or state in callback'));
        return;
      }
      if (state !== expectedState) {
        rejectWith(400, 'state 불일치 — CSRF 가능성', new Error('State mismatch'));
        return;
      }
      // 유효 콜백 — 응답 보류. 토큰 교환 후 finish() 가 최종 페이지를 렌더한다.
      settled = true;
      if (timer) clearTimeout(timer);
      let finished = false;
      const finish = (outcome: CallbackOutcome): void => {
        if (finished) return; // 멱등 — 중복 호출(에러 경로 + 상위 catch) 무해
        finished = true;
        res.statusCode = outcome.ok ? 200 : 400;
        res.setHeader('content-type', 'text/html; charset=utf-8');
        res.end(renderResultPage(outcome));
        server.close();
      };
      resolve({ code, state, finish });
    });
    server.on('error', (err: unknown) => {
      if (settled) return;
      settled = true;
      if (timer) clearTimeout(timer);
      reject(err instanceof Error ? err : new Error(String(err)));
    });
    server.listen(port, '127.0.0.1');
    timer = setTimeout(() => {
      if (settled) return;
      settled = true;
      server.close();
      reject(new Error(`callback timeout after ${timeoutMs / 1000}s`));
    }, timeoutMs);
  });
}

function base64urlBuf(buf: Buffer): string {
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export function openBrowser(url: string): void {
  const platform = process.platform;
  // WSL — Linux 플랫폼이지만 xdg-open 이 호스트 브라우저로 연결 안 됨. wslview/powershell 폴백.
  if (platform === 'linux' && isWsl()) {
    openWsl(url);
    return;
  }
  const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
  const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
  spawnQuiet(cmd, args);
}

/** WSL(Windows Subsystem for Linux) 호스트 감지 — env 우선, /proc/version 폴백. */
function isWsl(): boolean {
  if (process.env.WSL_DISTRO_NAME) return true;
  try {
    return readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
  } catch {
    return false;
  }
}

/** detached spawn + error 무음 — 자동 열기 실패해도 URL 은 호출 전 출력됨(사용자 수동 복사). */
function spawnQuiet(cmd: string, args: string[]): void {
  const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
  child.on('error', () => {
    /* URL 이미 출력됨 */
  });
  child.unref();
}

/** WSL — Windows 호스트 기본 브라우저. wslview(wslu) 우선, 실패 시 powershell.exe Start-Process. */
function openWsl(url: string): void {
  const child = spawn('wslview', [url], { detached: true, stdio: 'ignore' });
  child.on('error', () => {
    const ps = spawn(
      'powershell.exe',
      ['-NoProfile', '-Command', `Start-Process '${url.replace(/'/g, "''")}'`],
      { detached: true, stdio: 'ignore' },
    );
    ps.on('error', () => {
      /* 둘 다 실패 — URL 이미 출력됨 */
    });
    ps.unref();
  });
  child.unref();
}

export async function findFreePort(): Promise<number> {
  // 49152-65535 ephemeral range. createServer port=0 자동 할당.
  return await new Promise<number>((resolve, reject) => {
    const server = createServer();
    server.on('error', reject);
    server.listen(0, '127.0.0.1', () => {
      const address = server.address();
      if (typeof address !== 'object' || address === null) {
        server.close();
        reject(new Error('no port'));
        return;
      }
      const port = address.port;
      server.close(() => resolve(port));
    });
  });
}

/**
 * 브라우저 안내 + **전체 authorize URL**(truncate 금지).
 *
 * #1 후속: 자동 열기가 실패하는 환경(원격 SSH, 헤드리스, WSL 폴백 미설치 등)에서도 사용자가 URL
 * 전체를 복사해 직접 열 수 있어야 한다. 과거 `slice(0, 80)` truncate 는 수동 복붙을 불가능하게 했다.
 */
export function authPromptLines(authUrl: string, redirectUri: string): string[] {
  return [
    '브라우저에서 로그인하세요. 자동으로 열리지 않으면 아래 URL 을 복사해 여세요:',
    `  ${authUrl}`,
    `callback: ${redirectUri}`,
    '(브라우저에서 로그인을 마치면 터미널이 자동으로 진행됩니다)\n',
  ];
}

/**
 * OIDC PKCE 플로우 실행 (login.ts ssoLoginCmd 의 step 4-8):
 *   4) PKCE code_verifier + challenge + state 생성
 *   5) localhost callback HTTP server start (ephemeral port)
 *   6) browser open → Connect /authorize
 *   7) callback ?code= 대기
 *   8) POST Connect /token (code + verifier) → access_token
 *
 * 실패 시 throw (호출부가 `✗ ${message}` 출력 + exit 1). 진행 로그는 stdout.
 */
export async function runOidcPkceFlow(opts: OidcFlowOptions): Promise<{ accessToken: string }> {
  // 4. PKCE + state 생성
  const codeVerifier = base64urlBuf(randomBytes(48));
  const codeChallenge = base64urlBuf(createHash('sha256').update(codeVerifier).digest());
  const state = base64urlBuf(randomBytes(16));

  // 5. callback server start
  let port: number;
  try {
    port = await findFreePort();
  } catch (err) {
    throw new Error(`cannot allocate callback port: ${errMessage(err)}`);
  }
  const redirectUri = `http://127.0.0.1:${port}/callback`;

  // 6. browser open
  const params = new URLSearchParams({
    client_id: opts.clientId,
    response_type: 'code',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state,
    redirect_uri: redirectUri,
    scope: opts.scope,
  });
  const authUrl = `${opts.authorizeUrl}?${params.toString()}`;
  for (const line of authPromptLines(authUrl, redirectUri)) console.log(line);
  openBrowser(authUrl);

  // 7. wait for callback (응답은 보류됨 — 토큰 교환 결과로 최종 페이지를 렌더한다)
  let handle: CallbackHandle;
  try {
    handle = await waitForCallback(state, port, opts.callbackTimeoutMs);
  } catch (err) {
    throw new Error(`callback failed: ${errMessage(err)}`);
  }
  console.log('✓ callback received, exchanging code...');

  // 8. token exchange (Connect /token) — 결과를 브라우저 페이지에 진실되게 반영(#3).
  //    실패해도 브라우저가 무한 대기로 남지 않도록 finish() 로 에러 페이지를 렌더한 뒤 throw.
  let accessToken: string;
  try {
    const tokenRes = await fetch(opts.tokenUrl, {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: handle.code,
        code_verifier: codeVerifier,
        client_id: opts.clientId,
        redirect_uri: redirectUri,
      }).toString(),
    });
    if (!tokenRes.ok) {
      const body = await tokenRes.text();
      handle.finish({ ok: false, message: `토큰 교환 실패 (HTTP ${tokenRes.status})` });
      throw new Error(`Connect token exchange failed: ${tokenRes.status} ${body}`);
    }
    const tokenBody: unknown = await tokenRes.json();
    const at =
      isRecord(tokenBody) && typeof tokenBody.access_token === 'string'
        ? tokenBody.access_token
        : null;
    if (!at) {
      handle.finish({ ok: false, message: '토큰 응답 형식 오류 (access_token 없음)' });
      throw new Error('Connect token response missing access_token');
    }
    accessToken = at;
  } catch (err) {
    // 네트워크 등 예외 포함 — 멱등 finish 로 브라우저 에러 페이지 보장 후 상위로 throw.
    handle.finish({ ok: false, message: '토큰 교환 중 오류' });
    throw err instanceof Error ? err : new Error(String(err));
  }
  handle.finish({ ok: true });
  console.log('✓ Connect access_token received');
  return { accessToken };
}
