// @ts-ignore // i will not inspect further, please do it if you want to. PRs are welcome.
import sckey from "soundcloud-key-fetch";

import getInfo, {
  getSetInfo,
  Transcoding,
  getTrackInfoByID,
  TrackInfo,
  User,
  SetInfo,
} from "./info";
import filterMedia, { FilterPredicateObject } from "./filter-media";
import { download, fromMediaObj } from "./download";

import isValidURL, {
  convertFirebaseURL,
  isFirebaseURL,
  isPersonalizedTrackURL,
  isPlaylistURL,
  stripMobilePrefix,
} from "./url";

import STREAMING_PROTOCOLS, { _PROTOCOLS } from "./protocols";
import FORMATS, { _FORMATS } from "./formats";
import { search, related, SearchOptions, RelatedResponse } from "./search";
import { downloadPlaylist } from "./download-playlist";
import m3u8stream from "m3u8stream";
import axios, { AxiosInstance } from "axios";

import * as path from "path";
import * as fs from "fs";
import { PaginatedQuery } from "./util";
import { GetLikesOptions, getLikes, Like } from "./likes";
import { getUser } from "./user";

/** @internal */
const downloadFormat = async (
  url: string,
  clientID: string,
  format: FORMATS,
  axiosInstance: AxiosInstance
): Promise<NodeJS.ReadableStream | m3u8stream.Stream> => {
  const info = await getInfo(url, clientID, axiosInstance);
  if (!info.media) {
    throw new Error("Media is undefined");
  }
  const filtered = filterMedia(info.media.transcodings, { format: format });
  if (filtered.length === 0)
    throw new Error(`Could not find media with specified format: (${format})`);
  return await fromMediaObj(filtered[0], clientID, axiosInstance);
};

interface ClientIDData {
  clientID: string;
  date: Date;
}

export interface SCDLOptions {
  // Set a custom client ID to use
  clientID?: string;
  // Set to true to save client ID to file
  saveClientID?: boolean;
  // File path to save client ID, defaults to '../client_id.json"
  filePath?: string;
  // Custom axios instance to use
  axiosInstance?: AxiosInstance;
  // Whether or not to automatically convert mobile links to regular links, defaults to true
  stripMobilePrefix?: boolean;
  // Whether or not to automatically convert SoundCloud Firebase links copied from the mobile app
  // (e.g. https://soundcloud.app.goo.gl/xxxxxxxxx), defaults to true.
  convertFirebaseLinks?: boolean;
}

export class SCDL {
  STREAMING_PROTOCOLS: { [key: string]: STREAMING_PROTOCOLS };
  FORMATS: { [key: string]: FORMATS };

  private _clientID?: string;
  private _filePath?: string;

  axios: AxiosInstance;
  saveClientID = process.env.SAVE_CLIENT_ID
    ? process.env.SAVE_CLIENT_ID.toLowerCase() === "true"
    : false;

  stripMobilePrefix: boolean;
  convertFirebaseLinks: boolean;

  constructor(options?: SCDLOptions) {
    if (!options) options = {};
    if (options.saveClientID) {
      this.saveClientID = options.saveClientID;
      if (options.filePath) this._filePath = options.filePath;
    } else {
      if (options.clientID) {
        this._clientID = options.clientID;
      }
    }

    if (options.axiosInstance) {
      this.setAxiosInstance(options.axiosInstance);
    } else {
      this.setAxiosInstance(axios);
    }

    if (!options.stripMobilePrefix) options.stripMobilePrefix = true;
    if (!options.convertFirebaseLinks) options.convertFirebaseLinks = true;

    this.stripMobilePrefix = options.stripMobilePrefix;
    this.convertFirebaseLinks = options.convertFirebaseLinks;
  }

  /**
   * Returns a media Transcoding that matches the given predicate object
   * @param media - The Transcodings to filter
   * @param predicateObj - The desired Transcoding object to match
   * @returns An array of Transcodings that match the predicate object
   */
  filterMedia(
    media: Transcoding[],
    predicateObj: FilterPredicateObject
  ): Transcoding[] {
    return filterMedia(media, predicateObj);
  }

  /**
   * Get the audio of a given track. It returns the first format found.
   *
   * @param url - The URL of the Soundcloud track
   * @param useDirectLink - Whether or not to use the download link if the artist has set the track to be downloadable. This has erratic behaviour on some environments.
   * @returns A ReadableStream containing the audio data
   */
  async download(
    url: string,
    useDirectLink = true
  ): Promise<NodeJS.ReadableStream | m3u8stream.Stream> {
    return download(
      await this.prepareURL(url),
      await this.getClientID(),
      this.axios,
      useDirectLink
    );
  }

  /**
   *  Get the audio of a given track with the specified format
   * @param url - The URL of the Soundcloud track
   * @param format - The desired format
   */
  async downloadFormat(
    url: string,
    format: FORMATS
  ): Promise<NodeJS.ReadableStream | m3u8stream.Stream> {
    return downloadFormat(
      await this.prepareURL(url),
      await this.getClientID(),
      format,
      this.axios
    );
  }

  /**
   * Returns info about a given track.
   * @param url - URL of the Soundcloud track
   * @returns Info about the track
   */
  async getInfo(url: string): Promise<TrackInfo> {
    return getInfo(
      await this.prepareURL(url),
      await this.getClientID(),
      this.axios
    );
  }

  /**
   * Returns info about the given track(s) specified by ID.
   * @param ids - The ID(s) of the tracks
   * @returns Info about the track
   */
  async getTrackInfoByID(
    ids: number[],
    playlistID?: number,
    playlistSecretToken?: string
  ): Promise<TrackInfo[]> {
    return getTrackInfoByID(
      await this.getClientID(),
      this.axios,
      ids,
      playlistID,
      playlistSecretToken
    );
  }

  /**
   * Returns info about the given set
   * @param url - URL of the Soundcloud set
   * @returns Info about the set
   */
  async getSetInfo(url: string): Promise<SetInfo> {
    return getSetInfo(
      await this.prepareURL(url),
      await this.getClientID(),
      this.axios
    );
  }

  /**
   * Searches for tracks/playlists for the given query
   * @param options - The search option
   * @returns SearchResponse
   */
  async search(
    options: SearchOptions
  ): Promise<PaginatedQuery<User | SetInfo | TrackInfo>> {
    return search(options, this.axios, await this.getClientID());
  }

  /**
   * Finds related tracks to the given track specified by ID
   * @param id - The ID of the track
   * @param limit - The number of results to return
   * @param offset - Used for pagination, set to 0 if you will not use this feature.
   */
  async related(
    id: number,
    limit: number,
    offset = 0
  ): Promise<RelatedResponse<TrackInfo>> {
    return related(id, limit, offset, this.axios, await this.getClientID());
  }

  /**
   * Returns the audio streams and titles of the tracks in the given playlist.
   * @param url - The url of the playlist
   */
  async downloadPlaylist(
    url: string
  ): Promise<[NodeJS.ReadableStream[], String[]]> {
    return downloadPlaylist(
      await this.prepareURL(url),
      await this.getClientID(),
      this.axios
    );
  }

  /**
   * Returns track information for a user's likes
   * @param options - Can either be the profile URL of the user, or their ID
   * @returns - An array of tracks
   */
  async getLikes(options: GetLikesOptions): Promise<PaginatedQuery<Like>> {
    let id: number;
    const clientID = await this.getClientID();
    if (options.id) {
      id = options.id;
    } else if (options.profileUrl) {
      const user = await getUser(
        await this.prepareURL(options.profileUrl),
        clientID,
        this.axios
      );
      id = user.id;
    } else if (options.nextHref) {
      return await getLikes(options, clientID, this.axios);
    } else {
      throw new Error("options.id or options.profileURL must be provided.");
    }
    options.id = id;

    return getLikes(options, clientID, this.axios);
  }

  /**
   * Returns information about a user
   * @param url - The profile URL of the user
   */
  async getUser(url: string): Promise<User> {
    return getUser(
      await this.prepareURL(url),
      await this.getClientID(),
      this.axios
    );
  }

  /**
   * Sets the instance of Axios to use to make requests to SoundCloud API
   * @param instance - An instance of Axios
   */
  setAxiosInstance(instance: AxiosInstance): void {
    this.axios = instance;
  }

  /**
   * Returns whether or not the given URL is a valid Soundcloud URL
   * @param url - URL of the Soundcloud track
   */
  isValidUrl(url: string): boolean {
    return isValidURL(url, this.convertFirebaseLinks, this.stripMobilePrefix);
  }

  /**
   * Returns whether or not the given URL is a valid playlist SoundCloud URL
   * @param url - The URL to check
   */
  isPlaylistURL(url: string): boolean {
    return isPlaylistURL(url);
  }

  /**
   * Returns true if the given URL is a personalized track URL. (of the form https://soundcloud.com/discover/sets/personalized-tracks::user-sdlkfjsldfljs:847104873)
   * @param url - The URL to check
   */
  isPersonalizedTrackURL(url: string): boolean {
    return isPersonalizedTrackURL(url);
  }

  /**
   * Returns true if the given URL is a Firebase URL (of the form https://soundcloud.app.goo.gl/XXXXXXXX)
   * @param url - The URL to check
   */
  isFirebaseURL(url: string): boolean {
    return isFirebaseURL(url);
  }

  async getClientID(): Promise<string> {
    let clientId = this._clientID;
    if (!clientId) {
      clientId = await this.setClientID();
    }

    return clientId;
  }

  /** @internal */
  async setClientID(clientID?: string): Promise<string> {
    if (clientID) {
      this._clientID = clientID;
      return clientID;
    }

    if (!this._clientID) {
      if (!this.saveClientID) {
        this._clientID = await sckey.fetchKey();
      } else {
        const filename = path.resolve(
          __dirname,
          this._filePath ? this._filePath : "../client_id.json"
        );
        const c = await this._getClientIDFromFile(filename);
        if (c) {
          this._clientID = c;
        } else {
          this._clientID = await sckey.fetchKey();
          const data = {
            clientID: this._clientID,
            date: new Date().toISOString(),
          };
          fs.writeFile(filename, JSON.stringify(data), {}, (err) => {
            if (err) console.log("Failed to save client_id to file: " + err);
          });
        }
      }
    }

    const clientId = this._clientID;
    if (!clientId) {
      throw new Error("Failed to get client ID");
    }

    return clientId;
  }

  /** @internal */
  private async _getClientIDFromFile(filename: string): Promise<string> {
    return new Promise((resolve, reject) => {
      if (!fs.existsSync(filename)) return resolve("");

      fs.readFile(
        filename,
        "utf8",
        (err: NodeJS.ErrnoException, data: string) => {
          if (err) return reject(err);
          let c: ClientIDData;
          try {
            c = JSON.parse(data);
          } catch (err) {
            return reject(err);
          }
          if (!c.date && !c.clientID)
            return reject(
              new Error(
                "Property 'data' or 'clientID' missing from client_id.json"
              )
            );
          if (typeof c.clientID !== "string")
            return reject(
              new Error("Property 'clientID' is not a string in client_id.json")
            );
          if (typeof c.date !== "string")
            return reject(
              new Error("Property 'date' is not a string in client_id.json")
            );
          const d = new Date(c.date);
          if (Number.isNaN(d.getDay()))
            return reject(
              new Error("Invalid date object from 'date' in client_id.json")
            );
          const dayMs = 60 * 60 * 24 * 1000;
          if (new Date().getTime() - d.getTime() >= dayMs) {
            // Older than a day, delete
            fs.unlink(filename, (err) => {
              if (err) console.log("Failed to delete client_id.json: " + err);
            });
            return resolve("");
          } else {
            return resolve(c.clientID);
          }
        }
      );
    });
  }

  /**
   * Prepares the given URL by stripping its mobile prefix (if this.stripMobilePrefix is true)
   * and converting it to a regular URL (if this.convertFireBaseLinks is true.)
   * @param url
   */
  async prepareURL(url: string): Promise<string> {
    if (this.stripMobilePrefix) url = stripMobilePrefix(url);
    if (this.convertFirebaseLinks) {
      if (isFirebaseURL(url)) url = await convertFirebaseURL(url, this.axios);
    }

    return url;
  }
}

// SCDL instance with default configutarion
const scdl = new SCDL();

// Creates an instance of SCDL with custom configuration
const create = (options: SCDLOptions): SCDL => new SCDL(options);

export { create };

scdl.STREAMING_PROTOCOLS = _PROTOCOLS;
scdl.FORMATS = _FORMATS;

export default scdl;
