import { join } from 'node:path';
import { homedir, userInfo } from 'node:os';
import { getState, updateState } from './state';
import { ANTHROPIC_PROXY_PORT as PROXY_PORT } from '../../officer-url.mjs';

const ANTHROPIC_API_BASE = 'https://api.anthropic.com';
const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';

// Buffer: refresh 5 minutes before expiry
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;

/*
 macOS keeps Claude Code's OAuth credentials in the login Keychain, NOT in ~/.claude/.credentials.json.

 This proxy used to read the file only, which on a Mac is a file Claude Code never writes. The
 workaround was to copy the Keychain blob into it by hand — and that is a snapshot, so it dies the
 first time the real client rotates the token. Worse than dying: a refresh ROTATES the refresh token
 and revokes the previous one, so two stores holding what looks like the same credential are actually
 racing, and whichever refreshes second gets `401 OAuth access token has been revoked`. That is the
 failure this file now exists to prevent, and it took out every mobile chat turn for six hours on
 2026-08-08 while the terminal CLI beside it worked fine.

 So on darwin the Keychain is the authority and the file is a mirror. Elsewhere — every Linux server —
 the file is the authority and nothing here changes.
*/
const IS_MACOS = process.platform === 'darwin';
const KEYCHAIN_SERVICE = 'Claude Code-credentials';

/** Which store a credential came from, so a refresh is written back to the one that owns it. */
type CredentialSource = 'keychain' | 'file';
type LoadedCredentials = { creds: CredentialsFile; source: CredentialSource };

/*
 How far ahead the watchdog works.

 It CHECKS every 30 minutes and refreshes only when the token has under an hour left, rather than
 refreshing on a blind schedule. The distinction matters: every refresh rotates the token, so a
 pointless refresh is not free — it is another chance for the two stores to disagree. This keeps a
 valid token permanently ahead of the next request without spending one to do it.
*/
const WATCHDOG_INTERVAL_MS = 30 * 60 * 1000;
const WATCHDOG_REFRESH_WITHIN_MS = 60 * 60 * 1000;

type OAuthCredentials = {
  accessToken?: string;
  refreshToken?: string;
  expiresAt?: number;
};

type CredentialsFile = {
  claudeAiOauth?: OAuthCredentials;
};

async function readCredentials(): Promise<CredentialsFile | null> {
  try {
    const file = Bun.file(CREDENTIALS_PATH);
    if (!(await file.exists())) return null;
    return (await file.json()) as CredentialsFile;
  } catch {
    return null;
  }
}

async function writeCredentials(creds: CredentialsFile): Promise<void> {
  try {
    await Bun.write(CREDENTIALS_PATH, JSON.stringify(creds, null, 2));
  } catch (err) {
    console.error('[claude:proxy] failed to write credentials:', err);
  }
}

/** Run `security`, returning stdout on success and null on any failure. Never throws. */
async function runSecurity(args: string[]): Promise<string | null> {
  try {
    const proc = Bun.spawn(['security', ...args], { stdout: 'pipe', stderr: 'pipe' });
    const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
    return code === 0 ? out : null;
  } catch {
    // No `security` binary, or spawning is not permitted. Both mean "no Keychain here".
    return null;
  }
}

/*
 The Keychain account the item is filed under — the macOS short username in practice.

 Read from the existing item rather than assumed, because `add-generic-password -U` matches on the
 service AND account pair: guess the account wrong and it silently creates a SECOND item instead of
 updating the one Claude Code reads, which would look like a write that worked and changed nothing.
*/
let keychainAccount: string | null = null;
async function getKeychainAccount(): Promise<string> {
  if (keychainAccount) return keychainAccount;
  const out = await runSecurity(['find-generic-password', '-s', KEYCHAIN_SERVICE]);
  keychainAccount = out?.match(/"acct"<blob>="([^"]*)"/)?.[1] || userInfo().username;
  return keychainAccount;
}

async function readKeychainCredentials(): Promise<CredentialsFile | null> {
  if (!IS_MACOS) return null;
  const raw = await runSecurity(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']);
  if (!raw?.trim()) return null;
  try {
    const parsed = JSON.parse(raw.trim()) as CredentialsFile;
    // An item with no access token is not a credential. Fall through to the file rather than
    // adopting it — an empty Keychain entry must not shadow a working one on disk.
    return parsed.claudeAiOauth?.accessToken ? parsed : null;
  } catch {
    return null;
  }
}

async function writeKeychainCredentials(creds: CredentialsFile): Promise<boolean> {
  if (!IS_MACOS) return false;
  const account = await getKeychainAccount();
  // The blob goes through argv, which is the only interface `security` offers for a non-interactive
  // write — it has no stdin form. Acceptable here: this is a single-user laptop whose Keychain the
  // caller already holds, and the alternative is leaving Claude Code's own store stale.
  const out = await runSecurity([
    'add-generic-password',
    '-U',
    '-a',
    account,
    '-s',
    KEYCHAIN_SERVICE,
    '-w',
    JSON.stringify(creds),
  ]);
  return out !== null;
}

/**
 * The credentials to work from, and where they came from.
 *
 * Keychain first on macOS: it is what the real Claude Code reads and writes, so it is the copy that
 * is current even when nothing here has run for hours.
 */
async function loadCredentials(): Promise<LoadedCredentials | null> {
  const fromKeychain = await readKeychainCredentials();
  if (fromKeychain) return { creds: fromKeychain, source: 'keychain' };
  const fromFile = await readCredentials();
  return fromFile ? { creds: fromFile, source: 'file' } : null;
}

/**
 * Persist a refreshed credential to the store that owns it, and mirror it to the file.
 *
 * The mirror is deliberate. Both copies then hold the SAME token rather than two rotations of it,
 * which is the difference between a redundant copy and the race this file was written to end.
 */
async function saveCredentials(creds: CredentialsFile, source: CredentialSource): Promise<void> {
  if (source === 'keychain' && !(await writeKeychainCredentials(creds))) {
    console.error('[claude:proxy] could not write the Keychain — the file mirror is now the only copy');
  }
  await writeCredentials(creds);
}

async function refreshOAuthToken(refreshToken: string): Promise<OAuthCredentials | null> {
  try {
    console.log('[claude:proxy] refreshing OAuth token...');
    const res = await fetch(TOKEN_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: CLIENT_ID,
      }),
    });

    if (!res.ok) {
      const text = await res.text();
      console.error(`[claude:proxy] token refresh failed (${res.status}): ${text}`);
      return null;
    }

    const data = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number };
    if (!data.access_token) {
      console.error('[claude:proxy] token refresh response missing access_token');
      return null;
    }

    const expiresAt = data.expires_in ? Date.now() + data.expires_in * 1000 : undefined;
    console.log(`[claude:proxy] token refreshed, expires in ${data.expires_in ?? '?'}s`);

    return {
      accessToken: data.access_token,
      refreshToken: data.refresh_token ?? refreshToken,
      expiresAt,
    };
  } catch (err) {
    console.error('[claude:proxy] token refresh error:', err);
    return null;
  }
}

async function getValidToken(): Promise<string | null> {
  const loaded = await loadCredentials();
  const oauth = loaded?.creds.claudeAiOauth;
  if (!loaded || !oauth) return null;

  const { creds, source } = loaded;
  const now = Date.now();

  // Check if token is still valid (with buffer)
  if (oauth.accessToken && oauth.expiresAt && oauth.expiresAt - now > EXPIRY_BUFFER_MS) {
    return oauth.accessToken.trim();
  }

  // Token expired or about to expire — try refresh
  if (oauth.refreshToken) {
    const refreshed = await refreshOAuthToken(oauth.refreshToken);
    if (refreshed?.accessToken) {
      creds.claudeAiOauth = { ...oauth, ...refreshed };
      await saveCredentials(creds, source);
      return refreshed.accessToken.trim();
    }
  }

  // Fallback: return current token even if potentially expired (let upstream decide)
  return oauth.accessToken?.trim() || null;
}

async function forceRefresh(): Promise<string | null> {
  const loaded = await loadCredentials();
  const oauth = loaded?.creds.claudeAiOauth;
  if (!loaded || !oauth?.refreshToken) return null;

  const refreshed = await refreshOAuthToken(oauth.refreshToken);
  if (!refreshed?.accessToken) return null;

  loaded.creds.claudeAiOauth = { ...oauth, ...refreshed };
  await saveCredentials(loaded.creds, loaded.source);
  return refreshed.accessToken.trim();
}

/**
 * Recover from an upstream 401, in the order that costs least.
 *
 * Re-reading comes FIRST, and on macOS that is the whole fix: when a token has genuinely been revoked,
 * the machine usually already holds a good one — Claude Code refreshed it into the Keychain minutes
 * ago and never touched our copy. Spending our own refresh token in that situation is not just
 * wasteful, it is how the two stores got out of step to begin with.
 *
 * Only when nobody else has moved do we refresh ourselves.
 */
async function recoverFromUnauthorized(usedToken: string): Promise<string | null> {
  const loaded = await loadCredentials();
  const current = loaded?.creds.claudeAiOauth?.accessToken?.trim();
  if (current && current !== usedToken) {
    console.log('[claude:proxy] 401 — a newer token was already on disk, retrying with it');
    return current;
  }
  console.log('[claude:proxy] 401 — refreshing');
  return forceRefresh();
}

/**
 * Keep a valid token ahead of the next request, so nobody discovers an expired one by being told to
 * wait three minutes for a chat that then fails to authenticate.
 *
 * Runs once at start-up and every {@link WATCHDOG_INTERVAL_MS} after. Every failure is logged and
 * swallowed: a refresh that cannot happen must not take the proxy down with it, because the token on
 * hand may still have hours left.
 */
export function startCredentialWatchdog(): void {
  const tick = async () => {
    const loaded = await loadCredentials();
    const oauth = loaded?.creds.claudeAiOauth;
    if (!loaded || !oauth?.refreshToken) return;

    const remaining = (oauth.expiresAt ?? 0) - Date.now();
    if (remaining > WATCHDOG_REFRESH_WITHIN_MS) return;

    const refreshed = await refreshOAuthToken(oauth.refreshToken);
    if (!refreshed?.accessToken) {
      console.error('[claude:proxy] watchdog refresh failed — the next request will try again');
      return;
    }
    loaded.creds.claudeAiOauth = { ...oauth, ...refreshed };
    await saveCredentials(loaded.creds, loaded.source);
    console.log(`[claude:proxy] watchdog refreshed the token from the ${loaded.source}`);
  };

  const run = () => {
    tick().catch((err) => console.error('[claude:proxy] watchdog error:', err));
  };
  run();
  setInterval(run, WATCHDOG_INTERVAL_MS).unref?.();
}

export function getProxySecret(): string {
  return getState().proxySecret;
}

export function ensureProxySecret(): string {
  const state = getState();
  if (state.proxySecret) return state.proxySecret;

  // Generate once, persist forever
  const secret = `sk-ant-api03-${crypto.randomUUID()}`;
  updateState({ proxySecret: secret });
  return secret;
}

export function startAnthropicProxy() {
  const secret = ensureProxySecret();

  Bun.serve({
    port: PROXY_PORT,
    hostname: '127.0.0.1',
    idleTimeout: 0,

    async fetch(req) {
      const url = new URL(req.url);

      // Internal refresh endpoint (no auth needed — localhost only)
      if (url.pathname === '/refresh') {
        const token = await forceRefresh();
        if (token) {
          return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
        }
        return new Response(JSON.stringify({ error: 'Refresh failed' }), {
          status: 502,
          headers: { 'Content-Type': 'application/json' },
        });
      }

      // Validate proxy secret
      const incomingKey = req.headers.get('x-api-key');
      if (incomingKey !== secret) {
        return new Response(JSON.stringify({ error: 'Unauthorized' }), {
          status: 401,
          headers: { 'Content-Type': 'application/json' },
        });
      }

      // Get valid token (refreshes if needed)
      const token = await getValidToken();
      if (!token) {
        return new Response(JSON.stringify({ error: 'No OAuth token available' }), {
          status: 502,
          headers: { 'Content-Type': 'application/json' },
        });
      }

      // Build upstream URL
      const upstream = `${ANTHROPIC_API_BASE}${url.pathname}${url.search}`;

      // Clone headers, swap proxy secret for the OAuth bearer token. Anthropic
      // only accepts Claude Pro/Max OAuth tokens via Authorization + the oauth beta header.
      // Preserve any anthropic-beta values Claude Code sent (context-management, etc.)
      // and append oauth-2025-04-20 alongside them.
      const headers = new Headers(req.headers);
      headers.delete('x-api-key');
      headers.set('Authorization', `Bearer ${token}`);
      const existingBeta = headers.get('anthropic-beta');
      const betas = existingBeta
        ? existingBeta
            .split(',')
            .map((s) => s.trim())
            .filter(Boolean)
        : [];
      if (!betas.includes('oauth-2025-04-20')) betas.push('oauth-2025-04-20');
      headers.set('anthropic-beta', betas.join(','));
      headers.delete('host');

      // Read request body fully before forwarding
      const body = req.body ? await req.arrayBuffer() : null;

      // Forward request
      const upstreamRes = await fetch(upstream, {
        method: req.method,
        headers,
        body,
      });

      // If upstream returns 401, recover the credential and retry once
      if (upstreamRes.status === 401) {
        const recovered = await recoverFromUnauthorized(token);
        if (recovered) {
          headers.set('Authorization', `Bearer ${recovered}`);
          const retryBody = body ? new Uint8Array(body) : null;
          const retryRes = await fetch(upstream, {
            method: req.method,
            headers,
            body: retryBody,
          });

          const retryHeaders = new Headers();
          for (const [key, value] of retryRes.headers) {
            const lower = key.toLowerCase();
            if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue;
            retryHeaders.set(key, value);
          }

          return new Response(retryRes.body, {
            status: retryRes.status,
            statusText: retryRes.statusText,
            headers: retryHeaders,
          });
        }
      }

      // Build clean response headers
      const resHeaders = new Headers();
      for (const [key, value] of upstreamRes.headers) {
        const lower = key.toLowerCase();
        if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue;
        resHeaders.set(key, value);
      }

      return new Response(upstreamRes.body, {
        status: upstreamRes.status,
        statusText: upstreamRes.statusText,
        headers: resHeaders,
      });
    },
  });

  console.log(`[claude:proxy] listening on 127.0.0.1:${PROXY_PORT}`);
  if (IS_MACOS) console.log(`[claude:proxy] macOS — reading credentials from the "${KEYCHAIN_SERVICE}" Keychain item`);
  startCredentialWatchdog();
}
