const BASE_URL = 'https://scoresaber.com/api';

type QueryParams = Record<string, string | number | boolean | undefined | null>;

function buildQuery(params: QueryParams): string {
  const searchParams = new URLSearchParams();
  Object.entries(params).forEach(([key, value]) => {
    if (value === undefined || value === null || value === '') return;
    searchParams.set(key, String(value));
  });
  const qs = searchParams.toString();
  return qs ? `?${qs}` : '';
}

interface ScoreSaberApiOptions {
  cacheExpiryDays?: number;
  cacheDir?: string;
  maxRetries?: number;
  initialBackoffMs?: number;
  maxBackoffMs?: number;
  backoffFactor?: number;
}

export class ScoreSaberAPI {
  private readonly fetchFn: typeof fetch;
  private readonly cacheExpiryMs: number;
  private readonly cacheDir: string;
  private readonly maxRetries: number;
  private readonly initialBackoffMs: number;
  private readonly maxBackoffMs: number;
  private readonly backoffFactor: number;

  constructor(fetchFn: typeof fetch, options: ScoreSaberApiOptions = {}) {
    this.fetchFn = fetchFn;
    this.cacheExpiryMs = (options.cacheExpiryDays ?? 1) * 24 * 60 * 60 * 1000;
    this.maxRetries = options.maxRetries ?? 5;
    this.initialBackoffMs = options.initialBackoffMs ?? 1000;
    this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
    this.backoffFactor = options.backoffFactor ?? 2;
    this.cacheDir = options.cacheDir ?? this.determineCacheDir();
    this.ensureCacheDir();
  }

  async getRankedMaps(params: {
    minStar?: number;
    maxStar?: number;
    useCache?: boolean;
    maxPages?: number;
    delayMsBetweenPages?: number;
  } = {}): Promise<unknown[]> {
    const minStar = params.minStar ?? 5;
    const maxStar = params.maxStar ?? 10;
    const useCache = params.useCache ?? true;
    const maxPages = params.maxPages ?? undefined;
    const delayMs = params.delayMsBetweenPages ?? 500;

    const cachePath = this.pathJoin(this.cacheDir, `ranked_maps_${minStar}_${maxStar}.json`);
    if (useCache && (await this.isCacheValid(cachePath))) {
      const cached = await this.readCache<unknown[]>(cachePath);
      if (cached) return cached;
    }

    const all: unknown[] = [];
    let page = 1;
    while (true) {
      if (maxPages !== undefined && page > maxPages) break;
      const url = `${BASE_URL}/leaderboards${buildQuery({
        minStar,
        maxStar,
        unique: 'true',
        ranked: 'true',
        page
      })}`;
      const res = await this.request(url);
      if (!res.ok) throw new Error(`ScoreSaber getRankedMaps failed: ${res.status}`);
      const data: any = await res.json();

      const leaderboards: unknown[] = data?.leaderboards ?? [];
      if (!leaderboards || leaderboards.length === 0) break;
      all.push(...leaderboards);

      page += 1;
      await this.sleep(delayMs);
    }

    await this.writeCache(cachePath, all);
    return all;
  }

  async clearCache(minStar?: number, maxStar?: number): Promise<void> {
    const fs = this.fsPromises();
    if (minStar !== undefined && maxStar !== undefined) {
      const file = this.pathJoin(this.cacheDir, `ranked_maps_${minStar}_${maxStar}.json`);
      try {
        await fs.unlink(file);
      } catch {
        // ignore
      }
      return;
    }

    try {
      const entries = await fs.readdir(this.cacheDir, { withFileTypes: true });
      await Promise.all(
        entries
          .filter((entry: import('fs').Dirent) => entry.isFile())
          .map((entry: import('fs').Dirent) => fs.unlink(this.pathJoin(this.cacheDir, entry.name)))
      );
    } catch {
      // ignore
    }
  }

  getCacheDir(): string {
    return this.cacheDir;
  }

  // Internal utilities
  private async request(url: string, init?: RequestInit): Promise<Response> {
    let attempt = 0;
    let backoffMs = this.initialBackoffMs;

    while (true) {
      try {
        const res = await this.fetchFn(url, init);
        if (res.status === 429) {
          attempt += 1;
          if (attempt > this.maxRetries) return res; // surface 429 if retries exceeded

          const retryAfterHeader = res.headers.get('Retry-After');
          const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN;
          const waitMs = Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : backoffMs;
          await this.sleep(waitMs);
          backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
          continue;
        }
        return res;
      } catch (err) {
        attempt += 1;
        if (attempt > this.maxRetries) throw err;
        await this.sleep(backoffMs);
        backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
      }
    }
  }

  private async isCacheValid(filePath: string): Promise<boolean> {
    try {
      const stat = await this.fsPromises().stat(filePath);
      const ageMs = Date.now() - stat.mtimeMs;
      return ageMs < this.cacheExpiryMs;
    } catch {
      return false;
    }
  }

  private async readCache<T>(filePath: string): Promise<T | null> {
    try {
      const data = await this.fsPromises().readFile(filePath, 'utf-8');
      return JSON.parse(data) as T;
    } catch {
      return null;
    }
  }

  private async writeCache(filePath: string, data: unknown): Promise<void> {
    const fs = this.fsPromises();
    try {
      await fs.writeFile(filePath, JSON.stringify(data));
    } catch {
      // best-effort cache write; ignore
    }
  }

  private determineCacheDir(): string {
    // Prefer ~/.cache/saberlist/scoresaber, fallback to CWD .cache
    const os = this.osModule();
    const path = this.pathModule();
    const home = os.homedir?.();
    const homeCache = home ? path.join(home, '.cache') : null;
    if (homeCache) {
      const saberlist = path.join(homeCache, 'saberlist');
      const scoresaber = path.join(saberlist, 'scoresaber');
      return scoresaber;
    }
    return this.pathJoin(process.cwd(), '.cache', 'scoresaber');
  }

  private ensureCacheDir(): void {
    const fs = this.fsModule();
    const path = this.pathModule();
    const base = this.cacheDir;
    const parts = base.split(path.sep);
    let cur = parts[0] || path.sep;
    for (let i = 1; i < parts.length; i++) {
      cur = this.pathJoin(cur, parts[i]);
      if (!fs.existsSync(cur)) {
        try {
          fs.mkdirSync(cur);
        } catch {
          // ignore
        }
      }
    }
  }

  private sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  private pathJoin(...segments: string[]): string {
    return this.pathModule().join(...segments);
  }

  // Lazy require Node builtins to keep SSR-friendly import graph
  private fsPromises() {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    const fs = require('fs');
    return fs.promises as typeof import('fs').promises;
  }
  private fsModule() {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    return require('fs') as typeof import('fs');
  }
  private pathModule() {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    return require('path') as typeof import('path');
  }
  private osModule() {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    return require('os') as typeof import('os');
  }
}

export function createScoreSaberAPI(
  fetchFn: typeof fetch,
  options: ScoreSaberApiOptions = {}
): ScoreSaberAPI {
  return new ScoreSaberAPI(fetchFn, options);
}


