/**
 * Claude Usage API
 *
 * Fetches usage data directly from the Anthropic OAuth API.
 * Supports multiple tokens for rotation-aware usage checking.
 */

import fs from 'fs';
import os from 'os';
import path from 'path';

import { DATA_DIR } from './config.js';
import { logger } from './logger.js';
import {
  getAllTokens,
  getConfiguredClaudeTokens,
  getCurrentToken,
} from './token-rotation.js';
import { readJsonFile, writeJsonFile } from './utils.js';

const USAGE_CACHE_FILE = path.join(DATA_DIR, 'claude-usage-cache.json');

const PROFILE_ENDPOINT = 'https://api.anthropic.com/api/oauth/profile';

export interface ClaudeUsageData {
  five_hour?: { utilization: number; resets_at: string };
  seven_day?: { utilization: number; resets_at: string };
  seven_day_sonnet?: { utilization: number; resets_at: string };
  seven_day_opus?: { utilization: number; resets_at: string };
}

const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
const FETCH_TIMEOUT_MS = 10_000;

interface UsageApiResponse {
  five_hour?: { utilization: number; resets_at?: string };
  seven_day?: { utilization: number; resets_at?: string };
  seven_day_sonnet?: { utilization: number; resets_at?: string };
  seven_day_opus?: { utilization: number; resets_at?: string };
}

function mapWindow(w?: {
  utilization: number;
  resets_at?: string;
}): { utilization: number; resets_at: string } | undefined {
  if (!w) return undefined;
  return { utilization: w.utilization, resets_at: w.resets_at || '' };
}

// ── Disk cache for usage data (survives restarts, 429s) ──

interface UsageCacheEntry {
  usage: ClaudeUsageData;
  /** Timestamp of last *successful* API fetch (use for stale detection). */
  fetchedAt: number;
  /** Timestamp of last API *attempt* including failures (use for throttling). */
  lastAttemptAt?: number;
  /**
   * HTTP status of the most recent *failed* attempt (e.g. 429), cleared on a
   * successful fetch. Used so the dashboard can render a live error indicator
   * instead of a stale value.
   */
  lastErrorStatus?: number;
  /**
   * Epoch-ms before which we MUST NOT retry, derived from a 429 `Retry-After`
   * header (or a default backoff when the header is absent). Cleared on a
   * successful fetch. Without this we re-poll every MIN_FETCH_INTERVAL_MS (60s)
   * even though the endpoint's rate-limit window is longer (~90s+), so the
   * cooldown never clears and the 429s become self-sustaining.
   */
  cooldownUntil?: number;
}

/**
 * Outcome of a usage fetch. `rateLimited` is true when the most recent attempt
 * hit a 429 — callers use it to surface an error in the UI rather than the
 * last cached value.
 */
export interface UsageFetchResult {
  usage: ClaudeUsageData | null;
  rateLimited: boolean;
}

let usageDiskCache: Record<string, UsageCacheEntry> = {};
let diskCacheLoaded = false;

function loadUsageDiskCache(): void {
  if (diskCacheLoaded) return;
  diskCacheLoaded = true;
  usageDiskCache =
    readJsonFile<Record<string, UsageCacheEntry>>(USAGE_CACHE_FILE) ?? {};
}

function saveUsageDiskCache(): void {
  try {
    writeJsonFile(USAGE_CACHE_FILE, usageDiskCache);
  } catch {
    /* best effort */
  }
}

function legacyTokenCacheKey(token: string): string {
  // Use last 8 chars — prefix is always "sk-ant-oat01-" for all tokens
  return token.slice(-8);
}

function accountCacheKey(accountIndex: number): string {
  return `account-${accountIndex}`;
}

export function getUsageCacheWriteKey(
  token: string,
  accountIndex?: number,
): string {
  return accountIndex != null
    ? accountCacheKey(accountIndex)
    : legacyTokenCacheKey(token);
}

export function getUsageCacheReadKeys(
  token: string,
  accountIndex?: number,
  credentialsAccessToken?: string | null,
): string[] {
  const keys: string[] = [];
  if (accountIndex != null) keys.push(accountCacheKey(accountIndex));

  if (credentialsAccessToken) {
    const credsKey = legacyTokenCacheKey(credentialsAccessToken);
    if (!keys.includes(credsKey)) keys.push(credsKey);
  }

  const tokenKey = legacyTokenCacheKey(token);
  if (!keys.includes(tokenKey)) keys.push(tokenKey);

  return keys;
}

// Rate limit: at most one API call per token per 1 minute.
// We poll usage every minute (was 5) so the 95% exhaustion gate has tight
// resolution — at worst the gate fires ~60s after a window crosses 95%.
const MIN_FETCH_INTERVAL_MS = 60_000;

// Small margin added on top of a server-provided Retry-After so we wait until
// just *after* the cooldown window closes rather than racing its edge.
const RATE_LIMIT_MARGIN_MS = 5_000;
// Fallback cooldown when a 429 carries no usable Retry-After header.
const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 5 * 60_000;

/**
 * Parse an HTTP `Retry-After` header into milliseconds from now.
 * Accepts either a delta-seconds integer ("93") or an HTTP-date.
 * Returns null when the header is missing or unparseable.
 */
export function parseRetryAfterMs(header: string | null): number | null {
  if (!header) return null;
  const trimmed = header.trim();
  if (/^\d+$/.test(trimmed)) {
    return Number(trimmed) * 1000;
  }
  const dateMs = Date.parse(trimmed);
  if (Number.isFinite(dateMs)) {
    return Math.max(0, dateMs - Date.now());
  }
  return null;
}

async function fetchUsageForToken(
  token: string,
  accountIndex?: number,
  refreshAttempted = false,
): Promise<UsageFetchResult> {
  loadUsageDiskCache();

  // Return cached data if attempted recently (avoid API rate-limit)
  const writeKey = getUsageCacheWriteKey(token, accountIndex);
  const readKeys = getUsageCacheReadKeys(
    token,
    accountIndex,
    accountIndex != null ? readCredentialsAccessToken(accountIndex) : null,
  );
  let cachedKey: string | null = null;
  let cached: UsageCacheEntry | undefined;
  for (const key of readKeys) {
    const entry = usageDiskCache[key];
    if (!entry) continue;
    cachedKey = key;
    cached = entry;
    break;
  }
  if (
    cached &&
    cachedKey &&
    cachedKey !== writeKey &&
    !usageDiskCache[writeKey]
  ) {
    usageDiskCache[writeKey] = { ...cached };
    cached = usageDiskCache[writeKey];
    saveUsageDiskCache();
  }

  /**
   * Record this attempt's timestamp (and error status) at the write key,
   * creating the entry if it does not exist yet. Recording even when there is
   * no prior cached usage is what makes the MIN_FETCH_INTERVAL_MS throttle
   * apply to *failures* too — without it an empty cache means every dashboard
   * refresh hits the API, which itself sustains the very 429s we then report.
   */
  const recordAttempt = (
    errorStatus?: number,
    cooldownUntil?: number,
  ): void => {
    const now = Date.now();
    const entry: UsageCacheEntry = usageDiskCache[writeKey] ?? {
      usage: {},
      fetchedAt: 0,
    };
    entry.lastAttemptAt = now;
    entry.lastErrorStatus = errorStatus;
    entry.cooldownUntil = cooldownUntil;
    usageDiskCache[writeKey] = entry;
    saveUsageDiskCache();
  };

  // Honour an active rate-limit cooldown from a prior 429's Retry-After. This
  // is what actually breaks the self-sustaining 429 loop: the endpoint hands
  // back a ~90s+ window, so retrying on the 60s MIN_FETCH_INTERVAL would keep
  // hitting it mid-cooldown and never recover.
  if (
    !refreshAttempted &&
    cached?.cooldownUntil &&
    Date.now() < cached.cooldownUntil
  ) {
    return { usage: null, rateLimited: true };
  }

  const lastAttempt = cached?.lastAttemptAt ?? cached?.fetchedAt ?? 0;
  if (
    !refreshAttempted &&
    cached &&
    Date.now() - lastAttempt < MIN_FETCH_INTERVAL_MS
  ) {
    // Within the throttle window: replay the last outcome without an API call.
    // On a 429 we report rateLimited and DO NOT serve the stale value.
    if (cached.lastErrorStatus === 429) {
      return { usage: null, rateLimited: true };
    }
    return { usage: cached.usage ?? null, rateLimited: false };
  }
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);

  try {
    const res = await fetch(USAGE_ENDPOINT, {
      method: 'GET',
      headers: {
        Authorization: `Bearer ${token}`,
        'anthropic-beta': 'oauth-2025-04-20',
        'User-Agent': 'ejclaw/1.0',
      },
      signal: controller.signal,
    });

    if (res.status === 401 || res.status === 403) {
      // 401 = token expired; 403 = token lacks `user:profile` scope.
      // Both are recoverable by exchanging refresh_token for a fresh access
      // token (mint includes current default scope set). Retry once.
      if (accountIndex != null && !refreshAttempted) {
        try {
          const { forceRefreshToken } = await import('./token-refresh.js');
          const newToken = await forceRefreshToken(accountIndex);
          if (newToken && newToken !== token) {
            logger.info(
              {
                account: accountIndex + 1,
                status: res.status,
                cacheKey: writeKey,
              },
              `Claude usage API: ${res.status} → refreshed access token, retrying`,
            );
            return fetchUsageForToken(newToken, accountIndex, true);
          }
        } catch (err) {
          logger.warn(
            { err, account: accountIndex + 1 },
            'Claude usage API: force-refresh failed',
          );
        }
      }
      logger.warn(
        {
          account: accountIndex != null ? accountIndex + 1 : '?',
          tokenKey: legacyTokenCacheKey(token),
          cacheKey: writeKey,
        },
        res.status === 401
          ? 'Claude usage API: token expired or invalid (401)'
          : 'Claude usage API: forbidden — token missing required scope (403)',
      );
      recordAttempt(res.status);
      return { usage: cached?.usage ?? null, rateLimited: false };
    }
    if (res.status === 429) {
      const staleMs = cached ? Date.now() - cached.fetchedAt : 0;
      const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));
      const cooldownMs = retryAfterMs ?? DEFAULT_RATE_LIMIT_COOLDOWN_MS;
      const cooldownUntil = Date.now() + cooldownMs + RATE_LIMIT_MARGIN_MS;
      logger.warn(
        {
          account: accountIndex != null ? accountIndex + 1 : '?',
          tokenKey: legacyTokenCacheKey(token),
          cacheKey: writeKey,
          staleMinutes: Math.round(staleMs / 60_000),
          retryAfterSec:
            retryAfterMs != null ? Math.round(retryAfterMs / 1000) : null,
          cooldownSec: Math.round(cooldownMs / 1000),
        },
        'Claude usage API: rate limited (429)',
      );
      // Back off until the server-provided Retry-After window closes (plus a
      // margin) so we stop re-tripping the limit. Surfaces a 429 indicator and
      // does NOT serve the stale value.
      recordAttempt(429, cooldownUntil);
      return { usage: null, rateLimited: true };
    }
    if (!res.ok) {
      logger.warn(
        {
          account: accountIndex != null ? accountIndex + 1 : '?',
          status: res.status,
          tokenKey: legacyTokenCacheKey(token),
          cacheKey: writeKey,
        },
        `Claude usage API: unexpected status ${res.status}`,
      );
      recordAttempt(res.status);
      return { usage: cached?.usage ?? null, rateLimited: false };
    }

    const data = (await res.json()) as UsageApiResponse;

    const result: ClaudeUsageData = {
      five_hour: mapWindow(data.five_hour),
      seven_day: mapWindow(data.seven_day),
      seven_day_sonnet: mapWindow(data.seven_day_sonnet),
      seven_day_opus: mapWindow(data.seven_day_opus),
    };

    // Persist to disk cache — success: update fetchedAt + lastAttemptAt and
    // clear any prior error status.
    const now = Date.now();
    usageDiskCache[writeKey] = {
      usage: result,
      fetchedAt: now,
      lastAttemptAt: now,
      lastErrorStatus: undefined,
    };
    saveUsageDiskCache();

    logger.debug(
      {
        account: accountIndex != null ? accountIndex + 1 : '?',
        tokenKey: legacyTokenCacheKey(token),
        cacheKey: writeKey,
        h5: result.five_hour?.utilization,
        d7: result.seven_day?.utilization,
      },
      'Claude usage API: fetched successfully',
    );

    return { usage: result, rateLimited: false };
  } catch (err) {
    if ((err as Error).name === 'AbortError') {
      logger.warn(
        {
          account: accountIndex != null ? accountIndex + 1 : '?',
          tokenKey: legacyTokenCacheKey(token),
          cacheKey: writeKey,
        },
        'Claude usage API: request timed out',
      );
    } else {
      logger.warn(
        {
          err,
          account: accountIndex != null ? accountIndex + 1 : '?',
          tokenKey: legacyTokenCacheKey(token),
          cacheKey: writeKey,
        },
        'Claude usage API: fetch failed',
      );
    }
    // Record attempt time so we back off on persistent failures
    recordAttempt();
    return { usage: cached?.usage ?? null, rateLimited: false };
  } finally {
    clearTimeout(timer);
  }
}

/**
 * Fetch Claude usage via the OAuth API.
 * Uses the current active token from rotation.
 */
export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
  // Prefer the access token in ~/.claude/.credentials.json when present.
  // The static .env token (CLAUDE_CODE_OAUTH_TOKEN) was issued before the
  // `user:profile` scope was required for /api/oauth/usage and so always 403s.
  // The credentials file is the canonical source written by `claude auth
  // login` and kept fresh by token-refresh.ts — its accessToken carries the
  // full scope set including user:profile.
  const credsToken = readCredentialsAccessToken(0);
  const token =
    credsToken || getCurrentToken() || getConfiguredClaudeTokens()[0];
  if (!token) {
    logger.debug('No Claude OAuth token available for usage check');
    return null;
  }
  return (await fetchUsageForToken(token, undefined)).usage;
}

export interface ClaudeAccountProfile {
  email: string;
  planType: string; // "max", "pro", "free"
}

const profileCache = new Map<number, ClaudeAccountProfile>();

/**
 * Read planType from credentials file as fallback when profile API fails.
 * Account 0: ~/.claude/.credentials.json
 * Account 1+: ~/.claude-accounts/{index}/.credentials.json
 */
function readCredentialsPlanType(accountIndex: number): string | null {
  try {
    const credsPath =
      accountIndex === 0
        ? path.join(os.homedir(), '.claude', '.credentials.json')
        : path.join(
            os.homedir(),
            '.claude-accounts',
            String(accountIndex),
            '.credentials.json',
          );
    if (!fs.existsSync(credsPath)) return null;
    const data = readJsonFile<{
      claudeAiOauth?: { subscriptionType?: string };
    }>(credsPath);
    return data?.claudeAiOauth?.subscriptionType || null;
  } catch {
    return null;
  }
}

function readCredentialsAccessToken(accountIndex: number): string | null {
  try {
    const credsPath =
      accountIndex === 0
        ? path.join(os.homedir(), '.claude', '.credentials.json')
        : path.join(
            os.homedir(),
            '.claude-accounts',
            String(accountIndex),
            '.credentials.json',
          );
    if (!fs.existsSync(credsPath)) return null;
    const data = readJsonFile<{
      claudeAiOauth?: { accessToken?: string };
    }>(credsPath);
    return data?.claudeAiOauth?.accessToken || null;
  } catch {
    return null;
  }
}

async function fetchProfileForToken(
  token: string,
): Promise<ClaudeAccountProfile | null> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
  try {
    const res = await fetch(PROFILE_ENDPOINT, {
      method: 'GET',
      headers: {
        Authorization: `Bearer ${token}`,
        'anthropic-beta': 'oauth-2025-04-20',
      },
      signal: controller.signal,
    });
    if (!res.ok) return null;
    const data = (await res.json()) as {
      account?: {
        email?: string;
        has_claude_max?: boolean;
        has_claude_pro?: boolean;
      };
      organization?: { organization_type?: string };
    };
    const orgType = data.organization?.organization_type || '';
    const planType = data.account?.has_claude_max
      ? 'max'
      : data.account?.has_claude_pro
        ? 'pro'
        : orgType.replace('claude_', '') || '?';
    return {
      email: data.account?.email || '?',
      planType,
    };
  } catch {
    return null;
  } finally {
    clearTimeout(timer);
  }
}

/**
 * Fetch profiles for all Claude tokens (cached, called once on startup).
 */
export async function fetchAllClaudeProfiles(): Promise<void> {
  const allTokens = getAllTokens();
  for (const t of allTokens) {
    let profile = await fetchProfileForToken(t.token);

    // Fallback: if profile API failed or returned unknown plan, use credentials file
    if (!profile || profile.planType === '?') {
      const credsPlan = readCredentialsPlanType(t.index);
      if (credsPlan) {
        profile = {
          email: profile?.email || '?',
          planType: credsPlan,
        };
        logger.info(
          { account: t.index + 1, plan: credsPlan, source: 'credentials' },
          `Claude account #${t.index + 1}: ${credsPlan} (from credentials)`,
        );
      }
    }

    if (profile) {
      profileCache.set(t.index, profile);
      logger.info(
        { account: t.index + 1, plan: profile.planType, email: profile.email },
        `Claude account #${t.index + 1}: ${profile.planType}`,
      );
    }
  }
}

export function getClaudeProfile(
  index: number,
): ClaudeAccountProfile | undefined {
  return profileCache.get(index);
}

export interface ClaudeAccountUsage {
  index: number;
  masked: string;
  isActive: boolean;
  isRateLimited: boolean;
  usage: ClaudeUsageData | null;
  /**
   * True when the most recent usage-API attempt for this account hit a 429.
   * The dashboard renders a "429" indicator instead of a stale value.
   */
  usageRateLimited?: boolean;
}

/**
 * Fetch usage for ALL configured tokens.
 * Returns per-account usage for dashboard display.
 */
export async function fetchAllClaudeUsage(): Promise<ClaudeAccountUsage[]> {
  const allTokens = getAllTokens();
  logger.debug({ tokenCount: allTokens.length }, 'fetchAllClaudeUsage called');
  if (allTokens.length === 0) {
    const credsToken = readCredentialsAccessToken(0);
    const token = credsToken || getConfiguredClaudeTokens()[0];
    if (!token) return [];
    const { usage, rateLimited } = await fetchUsageForToken(token, 0);
    return [
      {
        index: 0,
        masked: `${token.slice(0, 20)}...${token.slice(-4)}`,
        isActive: true,
        isRateLimited: false,
        usage,
        usageRateLimited: rateLimited,
      },
    ];
  }

  const results: ClaudeAccountUsage[] = [];
  for (const t of allTokens) {
    // Prefer the per-account credentials.json access token when present.
    // The static .env CLAUDE_CODE_OAUTH_TOKEN(S) values were issued without
    // the `user:profile` scope and so are rejected by /api/oauth/usage.
    // The credentials file is rewritten by `claude auth login` and refreshed
    // by token-refresh.ts, so it always carries the full scope set.
    const credsToken = readCredentialsAccessToken(t.index);
    const tokenForFetch = credsToken || t.token;
    const { usage, rateLimited } = await fetchUsageForToken(
      tokenForFetch,
      t.index,
    );
    results.push({
      index: t.index,
      masked: t.masked,
      isActive: t.isActive,
      isRateLimited: t.isRateLimited,
      usage,
      usageRateLimited: rateLimited,
    });
  }
  return results;
}

// Legacy alias
export const fetchClaudeUsageViaCli = fetchClaudeUsage;

// ── Exhaustion gate ───────────────────────────────────────────────────────
//
// Threshold above which a Claude account is considered "out of budget" for
// the purpose of admitting new turns. A turn is blocked only when *every*
// Claude account is either at/over this threshold on either window or
// already in a rate-limit cooldown.

export const CLAUDE_EXHAUSTION_THRESHOLD_PCT = 95;

export interface UsageExhaustionInfo {
  exhausted: boolean;
  /** ISO timestamp of the soonest reset that would relieve exhaustion. */
  nextResetAt: string | null;
}

/** Normalise the API's utilization field (which can be fractional or percent). */
function normaliseUtilization(u: number | undefined): number {
  if (u == null || !Number.isFinite(u)) return 0;
  return u >= 0 && u < 1 ? u * 100 : u;
}

function parseResetMs(s: string | undefined | null): number | null {
  if (!s) return null;
  const t = Date.parse(s);
  return Number.isFinite(t) ? t : null;
}

/**
 * Reports whether every configured Claude account is either ≥ 95% on a
 * window or in a rate-limit cooldown, and — when so — the soonest moment at
 * which any one account is expected to recover.
 *
 * Reads the on-disk usage cache (refreshed every minute by the dashboard
 * renderer); performs no network I/O. Returns `exhausted: false` when there
 * are no configured tokens, or when usage data is missing for any account
 * (err on letting work through).
 */
export function getClaudeUsageExhaustion(): UsageExhaustionInfo {
  loadUsageDiskCache();
  const allTokens = getAllTokens();
  if (allTokens.length === 0) return { exhausted: false, nextResetAt: null };

  let earliestRecoveryMs = Infinity;

  for (const t of allTokens) {
    if (t.isRateLimited) {
      // Rate-limit recovery time is not exposed on the token snapshot here;
      // we treat it as "unknown but eventually". Skip from the min calc.
      continue;
    }
    const credsToken = readCredentialsAccessToken(t.index);
    const readKeys = getUsageCacheReadKeys(t.token, t.index, credsToken);
    let entry: UsageCacheEntry | undefined;
    for (const key of readKeys) {
      if (usageDiskCache[key]) {
        entry = usageDiskCache[key];
        break;
      }
    }
    if (!entry) return { exhausted: false, nextResetAt: null };

    const h5 = normaliseUtilization(entry.usage.five_hour?.utilization);
    const d7 = normaliseUtilization(entry.usage.seven_day?.utilization);
    if (Math.max(h5, d7) < CLAUDE_EXHAUSTION_THRESHOLD_PCT) {
      return { exhausted: false, nextResetAt: null };
    }

    // This account is exhausted. It becomes available again when whichever
    // window(s) are over the threshold drop back below it. We approximate
    // that as max(reset of each window currently ≥ threshold).
    const h5ResetMs =
      h5 >= CLAUDE_EXHAUSTION_THRESHOLD_PCT
        ? parseResetMs(entry.usage.five_hour?.resets_at)
        : null;
    const d7ResetMs =
      d7 >= CLAUDE_EXHAUSTION_THRESHOLD_PCT
        ? parseResetMs(entry.usage.seven_day?.resets_at)
        : null;
    const candidates = [h5ResetMs, d7ResetMs].filter(
      (v): v is number => v != null,
    );
    if (candidates.length > 0) {
      const accountRecovery = Math.max(...candidates);
      if (accountRecovery < earliestRecoveryMs) {
        earliestRecoveryMs = accountRecovery;
      }
    }
  }

  const nextResetAt = Number.isFinite(earliestRecoveryMs)
    ? new Date(earliestRecoveryMs).toISOString()
    : null;
  return { exhausted: true, nextResetAt };
}

/** Backwards-compat boolean wrapper. */
export function isClaudeUsageExhausted(): boolean {
  return getClaudeUsageExhaustion().exhausted;
}
