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

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}` : '';
}

// Minimal shapes for returned data
export interface CuratedSongInfo {
  hash: string;
  key: string;
  songName: string;
  date?: string;
}

export interface MapperMapInfo extends CuratedSongInfo {
  date?: string;
}

export interface EnvironmentMapInfo extends CuratedSongInfo {
  environment?: string;
}

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

export class BeatSaverAPI {
  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: BeatSaverApiOptions = {}) {
    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();
  }

  // Public API
  async getCuratedSongs(
    useCache: boolean = true,
    options: { maxPages?: number; tolerateErrors?: boolean } = {}
  ): Promise<CuratedSongInfo[]> {
    const cachePath = this.pathJoin(this.cacheDir, 'curated_songs.json');
    if (useCache) {
      const cached = await this.readCache<CuratedSongInfo[]>(cachePath);
      if (cached) return cached;
    }

    const processed: CuratedSongInfo[] = [];
    let page = 0;
    const maxPages = options.maxPages ?? undefined;

    while (true) {
      const url = `${BASE_URL}/search/text/${page}${buildQuery({ sortOrder: 'Curated', curated: 'true' })}`;
      const res = await this.request(url);
      if (!res.ok) {
        if (options.tolerateErrors) break;
        throw new Error(`BeatSaver getCuratedSongs failed: ${res.status}`);
      }
      const data: any = await res.json();

      for (const song of data?.docs ?? []) {
        for (const version of song?.versions ?? []) {
          processed.push({
            hash: version?.hash,
            key: song?.id,
            songName: song?.metadata?.songName,
            date: song?.lastPublishedAt ?? song?.uploaded ?? song?.createdAt
          });
        }
      }

      const totalPages: number = data?.info?.pages ?? 0;
      if (page >= totalPages - 1) break;
      page += 1;
      if (maxPages !== undefined && page >= maxPages) break;
      await this.sleep(1000);
    }

    // Do not expire curated songs cache (mirror Python approach). We still write once and always use it if present.
    await this.writeCache(cachePath, processed);
    return processed;
  }

  async getFollowedMappers(userId: number = 243016, useCache: boolean = true): Promise<unknown> {
    const cachePath = this.pathJoin(this.cacheDir, `followed_mappers_${userId}.json`);
    if (useCache && (await this.isCacheValid(cachePath))) {
      const cached = await this.readCache<unknown>(cachePath);
      if (cached) return cached;
    }

    const url = `${BASE_URL}/users/followedBy/${encodeURIComponent(String(userId))}/0`;
    const res = await this.request(url);
    if (!res.ok) throw new Error(`BeatSaver getFollowedMappers failed: ${res.status}`);
    const mappers = await res.json();
    await this.writeCache(cachePath, mappers);
    return mappers;
  }

  async getMapperMaps(mapperId: number, useCache: boolean = true): Promise<MapperMapInfo[]> {
    const cachePath = this.pathJoin(this.cacheDir, `mapper_${mapperId}_maps.json`);
    if (useCache && (await this.isCacheValid(cachePath))) {
      const cached = await this.readCache<MapperMapInfo[]>(cachePath);
      if (cached) return cached;
    }

    const processed: MapperMapInfo[] = [];
    let page = 0;
    while (true) {
      const url = `${BASE_URL}/search/text/${page}${buildQuery({ collaborator: String(mapperId), automapper: 'true', sortOrder: 'Latest' })}`;
      const res = await this.request(url);
      if (!res.ok) throw new Error(`BeatSaver getMapperMaps failed: ${res.status}`);
      const data: any = await res.json();

      for (const song of data?.docs ?? []) {
        for (const version of song?.versions ?? []) {
          processed.push({
            hash: version?.hash,
            key: song?.id,
            songName: song?.metadata?.songName,
            date: song?.lastPublishedAt
          });
        }
      }

      const totalPages: number = data?.info?.pages ?? 0;
      if (page >= totalPages - 1) break;
      page += 1;
      await this.sleep(1000);
    }

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

  async getMapsByEnvironment(
    environmentName: string,
    options: { maxPages?: number; useCache?: boolean } = {}
  ): Promise<EnvironmentMapInfo[]> {
    const useCache = options.useCache ?? true;
    const cachePath = this.pathJoin(this.cacheDir, `environment_${environmentName}_maps.json`);
    if (useCache && (await this.isCacheValid(cachePath))) {
      const cached = await this.readCache<EnvironmentMapInfo[]>(cachePath);
      if (cached) return cached;
    }

    const processed: EnvironmentMapInfo[] = [];
    let page = 0;
    const maxPages = options.maxPages ?? undefined;

    while (true) {
      const url = `${BASE_URL}/search/text/${page}${buildQuery({ environments: `${environmentName}Environment` })}`;
      const res = await this.request(url);
      if (!res.ok) throw new Error(`BeatSaver getMapsByEnvironment failed: ${res.status}`);
      const data: any = await res.json();

      for (const song of data?.docs ?? []) {
        for (const version of song?.versions ?? []) {
          processed.push({
            hash: version?.hash,
            key: song?.id,
            songName: song?.metadata?.songName,
            environment: environmentName
          });
        }
      }

      const totalPages: number = data?.info?.pages ?? 0;
      if (page >= totalPages - 1) break;
      page += 1;
      if (maxPages !== undefined && page >= maxPages) break;
      await this.sleep(1000);
    }

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

  async getMapByHash(mapHash: string): Promise<unknown> {
    const url = `${BASE_URL}/maps/hash/${encodeURIComponent(mapHash)}`;
    const res = await this.request(url);
    if (!res.ok) throw new Error(`BeatSaver getMapByHash failed: ${res.status}`);
    return res.json();
  }

  // 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/beatsaver, 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 beatsaver = path.join(saberlist, 'beatsaver');
      return beatsaver;
    }
    return this.pathJoin(process.cwd(), '.cache');
  }

  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 createBeatSaverAPI(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}): BeatSaverAPI {
  return new BeatSaverAPI(fetchFn, options);
}


