import * as nodeFs from 'node:fs';
import * as nodePath from 'node:path';
import * as nodeOs from 'node:os';

const BASE_URL = 'https://api.beatsaver.com';
// Many API calls are undocumented and only work on the web API, e.g. searchPlaylists
const WEB_API_BASE_URL = 'https://beatsaver.com/api';
const CACHE_ROOT = '.data/beatsaver-cache';

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;
}

type DateInput = string | number | Date;

export interface BeatSaverUser {
  id: number;
  name: string;
  avatar: string;
  description?: string | null;
  hash?: string | null;
  verifiedMapper?: boolean;
  [key: string]: unknown;
}

export interface PlaylistStats {
  totalMaps: number;
  mapperCount: number;
  totalDuration: number;
  minNps: number;
  maxNps: number;
  upVotes: number;
  downVotes: number;
  avgScore: number;
  [key: string]: unknown;
}

export interface PlaylistBasic {
  playlistId: number;
  playlistImage: string;
  name: string;
  type: string;
  owner: number;
  config?: unknown;
  [key: string]: unknown;
}

export interface PlaylistFull {
  playlistId: number;
  name: string;
  description: string;
  playlistImage: string;
  playlistImage512?: string | null;
  owner: BeatSaverUser;
  curator?: BeatSaverUser | null;
  stats?: PlaylistStats | null;
  createdAt?: string;
  updatedAt?: string;
  songsChangedAt?: string | null;
  curatedAt?: string | null;
  deletedAt?: string | null;
  downloadURL: string;
  type: string;
  config?: unknown;
  [key: string]: unknown;
}

export interface MapMetadata {
  bpm: number;
  duration: number;
  songName: string;
  songSubName: string;
  songAuthorName: string;
  levelAuthorName: string;
  [key: string]: unknown;
}

export interface MapStats {
  plays: number;
  downloads: number;
  upvotes: number;
  downvotes: number;
  score: number;
  reviews?: number;
  sentiment?: unknown;
  [key: string]: unknown;
}

export interface MapDifficulty {
  njs: number;
  offset: number;
  notes: number;
  bombs: number;
  obstacles: number;
  nps: number;
  length: number;
  characteristic: string;
  difficulty: string;
  events: number;
  chroma: boolean;
  me: boolean;
  ne: boolean;
  cinema: boolean;
  vivify?: boolean;
  seconds: number;
  stars?: number;
  maxScore: number;
  label?: string | null;
  blStars?: number | null;
  environment?: string | null;
  [key: string]: unknown;
}

export interface MapVersion {
  hash: string;
  key?: string | null;
  state: string;
  createdAt: string;
  downloadURL: string;
  coverURL: string;
  previewURL: string;
  diffs?: MapDifficulty[];
  feedback?: string | null;
  testplayAt?: string | null;
  testplays?: unknown;
  scheduledAt?: string | null;
  [key: string]: unknown;
}

export interface MapDetail {
  id: string;
  name: string;
  description: string;
  uploader: BeatSaverUser;
  metadata: MapMetadata;
  stats: MapStats;
  versions: MapVersion[];
  curator?: BeatSaverUser | null;
  curatedAt?: string | null;
  createdAt?: string;
  updatedAt?: string;
  lastPublishedAt?: string | null;
  automapper?: boolean;
  ranked?: boolean;
  qualified?: boolean;
  tags?: unknown;
  bookmarked?: boolean | null;
  collaborators?: BeatSaverUser[] | null;
  declaredAi?: unknown;
  blRanked?: boolean;
  blQualified?: boolean;
  nsfw?: boolean;
  [key: string]: unknown;
}

export interface MapDetailWithOrder {
  map: MapDetail;
  order: number;
}

export interface PlaylistPage {
  playlist: PlaylistFull | null;
  maps: MapDetailWithOrder[] | null;
}

export interface PlaylistSearchResponse<T = PlaylistFull> {
  docs: T[];
  info?: unknown;
}

export interface PlaylistSearchParams {
  query?: string;
  page?: number;
  sortOrder?: string;
  order?: string;
  ascending?: boolean;
  includeEmpty?: boolean;
  curated?: boolean;
  verified?: boolean;
  minNps?: number;
  maxNps?: number;
  from?: DateInput;
  to?: DateInput;
}

export interface PlaylistDetailOptions {
  page?: number | null;
  useCache?: boolean;
}

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 searchPlaylists(params: PlaylistSearchParams = {}): Promise<PlaylistSearchResponse> {
    const {
      query = '',
      page = 0,
      sortOrder = 'Relevance',
      order,
      ascending,
      includeEmpty,
      curated,
      verified,
      minNps,
      maxNps,
      from,
      to
    } = params;

    const normalizeDate = (value?: DateInput): string | undefined => {
      if (value === undefined || value === null) return undefined;
      if (value instanceof Date) return value.toISOString();
      if (typeof value === 'number') return new Date(value).toISOString();
      return value;
    };

    const url = `${WEB_API_BASE_URL}/playlists/search/${page}${buildQuery({
      q: query,
      sortOrder,
      order,
      ascending,
      includeEmpty,
      curated,
      verified,
      minNps,
      maxNps,
      from: normalizeDate(from),
      to: normalizeDate(to)
    })}`;

    const res = await this.request(url);
    if (!res.ok) throw new Error(`BeatSaver searchPlaylists failed: ${res.status}`);
    return (await res.json()) as PlaylistSearchResponse;
  }

  async getPlaylistDetail(
    playlistId: number | string,
    options: PlaylistDetailOptions = {}
  ): Promise<PlaylistPage> {
    const { page = null, useCache = true } = options;
    const rawId = String(playlistId);
    const safeId = encodeURIComponent(rawId);
    const cacheSafeId = rawId.replace(/[^a-zA-Z0-9_-]/g, '_');
    const pageSuffix = page === null ? 'detail' : `page_${page}`;
    const cachePath = this.pathJoin(this.cacheDir, `playlist_${cacheSafeId}_${pageSuffix}.json`);

    if (useCache && (await this.isCacheValid(cachePath))) {
      const cached = await this.readCache<PlaylistPage>(cachePath);
      if (cached) return cached;
    }

    const endpoint =
      page === null
        ? `${WEB_API_BASE_URL}/playlists/id/${safeId}`
        : `${WEB_API_BASE_URL}/playlists/id/${safeId}/${page}`;

    const res = await this.request(endpoint);
    if (!res.ok) throw new Error(`BeatSaver getPlaylistDetail failed: ${res.status}`);
    const payload = (await res.json()) as PlaylistPage;

    if (useCache) {
      await this.writeCache(cachePath, payload);
    }

    return payload;
  }

  async getPlaylistMaps(
    playlistId: number | string,
    page = 0,
    options: { useCache?: boolean } = {}
  ): Promise<MapDetailWithOrder[]> {
    const { useCache = true } = options;
    const detail = await this.getPlaylistDetail(playlistId, { page, useCache });
    return detail.maps ?? [];
  }

  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 {
    const path = this.pathModule();
    return this.pathJoin(process.cwd(), CACHE_ROOT);
  }

  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() {
    return nodeFs.promises;
  }
  private fsModule() {
    return nodeFs;
  }
  private pathModule() {
    return nodePath;
  }
  private osModule() {
    return nodeOs;
  }
}

export function createBeatSaverAPI(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}): BeatSaverAPI {
  const mergedOptions: BeatSaverApiOptions = {
    ...options,
    cacheExpiryDays: options.cacheExpiryDays ?? 90
  };

  return new BeatSaverAPI(fetchFn, mergedOptions);
}


