import type { CompanyModel } from "~/prisma/client/models";
import { AgeRatingOrganization, MetadataSource } from "~/prisma/client/enums";
import {
  ESRBRating,
  PEGIRating,
  CEROrating,
  USKRating,
  GRACRating,
  ClassIndRating,
  ACBRating,
} from "~/utils/ageRatings";
import type { MetadataProvider } from ".";
import { MissingMetadataProviderConfig } from ".";
import type {
  GameMetadataSearchResult,
  GameMetadataAgeRating,
  _FetchGameMetadataParams,
  GameMetadata,
  _FetchCompanyMetadataParams,
  CompanyMetadata,
} from "./types";
import { DateTime } from "luxon";
import * as jdenticon from "jdenticon";
import type { TaskRunContext } from "../tasks";
import { logger } from "~/server/internal/logging";
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";

type IGDBID = number;

interface TwitchAuthResponse {
  access_token: string;
  expires_in: number;
  token_type: string; // likely 'bearer'
}

interface IGDBErrorResponse {
  title: string;
  status: number;
  cause: string;
}

interface IGDBItem {
  id: IGDBID;
}

interface IGDBGenre extends IGDBItem {
  name: string;
  slug: string;
  url: string;
}

// denotes role a company had in a game
interface IGDBInvolvedCompany extends IGDBItem {
  company: IGDBID;
  game: IGDBID;

  developer: boolean;
  porting: boolean;
  publisher: boolean;
  supporting: boolean;

  created_at: number;
  updated_at: number;
}

interface IGDBCompany extends IGDBItem {
  name: string;
  country: number; // ISO 3166-1 country code
  description: string;
  logo: IGDBID;
  parent: IGDBID;
  slug: string;
  start_date: number;
  status: IGDBID;
  websites: IGDBID[];
}

interface IGDBCompanyWebsite extends IGDBItem {
  trusted: boolean;
  url: string;
}

interface IGDBCover extends IGDBItem {
  image_id: string;
}

interface IGDBSearchStub extends IGDBItem {
  name: string;
  cover?: IGDBID;
  first_release_date?: number; // unix timestamp
  summary: string;
}

interface IGDBAgeRating extends IGDBItem {
  category: number; // 1=ESRB, 2=PEGI, 3=CERO, 4=USK, 5=GRAC, 6=CLASS_IND, 7=ACB
  rating: number; // Specific rating level enum value
  rating_cover_url?: string;
}

const IGDB_CATEGORY_TO_ORG: Record<number, AgeRatingOrganization> = {
  1: AgeRatingOrganization.ESRB,
  2: AgeRatingOrganization.PEGI,
  3: AgeRatingOrganization.CERO,
  4: AgeRatingOrganization.USK,
  5: AgeRatingOrganization.GRAC,
  6: AgeRatingOrganization.ClassInd,
  7: AgeRatingOrganization.ACB,
};

const IGDB_RATING_TO_STRING: Record<number, string> = {
  // PEGI
  1: PEGIRating["3"],
  2: PEGIRating["7"],
  3: PEGIRating["12"],
  4: PEGIRating["16"],
  5: PEGIRating["18"],
  // ESRB
  7: ESRBRating.EC,
  8: ESRBRating.E,
  9: ESRBRating.E10,
  10: ESRBRating.T,
  11: ESRBRating.M,
  12: ESRBRating.AO,
  // CERO
  13: CEROrating.A,
  14: CEROrating.B,
  15: CEROrating.C,
  16: CEROrating.D,
  17: CEROrating.Z,
  // USK
  18: USKRating["0"],
  19: USKRating["6"],
  20: USKRating["12"],
  21: USKRating["16"],
  22: USKRating["18"],
  // GRAC
  23: GRACRating.ALL,
  24: GRACRating["12"],
  25: GRACRating["15"],
  26: GRACRating["18"],
  // CLASS_IND
  28: ClassIndRating.L,
  29: ClassIndRating["10"],
  30: ClassIndRating["12"],
  31: ClassIndRating["14"],
  32: ClassIndRating["16"],
  33: ClassIndRating["18"],
  // ACB
  34: ACBRating.G,
  35: ACBRating.PG,
  36: ACBRating.M,
  37: ACBRating.MA15,
  38: ACBRating.R18,
  39: ACBRating.RC,
};

// https://api-docs.igdb.com/?shell#game
interface IGDBGameFull extends IGDBSearchStub {
  age_ratings?: IGDBID[];
  aggregated_rating?: number;
  aggregated_rating_count?: number;
  alternative_names?: IGDBID[];
  artworks?: IGDBID[];
  bundles?: IGDBID[];
  checksum?: string;
  collections?: IGDBID[];
  created_at: number; // unix timestamp
  dlcs?: IGDBID[];
  expanded_games?: IGDBID[];
  expansions?: IGDBID[];
  external_games?: IGDBID[];
  forks?: IGDBID[];
  franchise?: IGDBID;
  franchises?: IGDBID[];
  game_engines?: IGDBID[];
  game_localizations?: IGDBID[];
  game_modes?: IGDBID[];
  game_status?: IGDBID;
  game_type?: IGDBID;
  genres?: IGDBID[];
  hypes?: number;
  involved_companies?: IGDBID[];
  keywords?: IGDBID[];
  language_supports?: IGDBID[];
  multiplayer_modes?: IGDBID[];
  platforms?: IGDBID[];
  player_perspectives?: IGDBID[];
  ports?: IGDBID[];
  rating?: number;
  rating_count?: number;
  release_dates?: IGDBID[];
  remakes?: IGDBID[];
  remasters?: IGDBID[];
  screenshots?: IGDBID[];
  similar_games?: IGDBID[];
  slug: string;
  standalone_expansions?: IGDBID[];
  storyline?: string;
  tags?: IGDBID[];
  themes?: IGDBID[];
  total_rating?: number;
  total_rating_count?: number;
  updated_at: number;
  url: string;
  version_parent?: IGDBID;
  version_title?: string;
  videos?: IGDBID[];
  websites?: IGDBID[];
}

// Api Docs: https://api-docs.igdb.com/
export class IGDBProvider implements MetadataProvider {
  private clientId: string;
  private clientSecret: string;
  private accessToken: string;
  private accessTokenExpiry: DateTime;

  constructor() {
    const client_id = process.env.IGDB_CLIENT_ID;
    if (!client_id)
      throw new MissingMetadataProviderConfig("IGDB_CLIENT_ID", this.name());
    const client_secret = process.env.IGDB_CLIENT_SECRET;
    if (!client_secret)
      throw new MissingMetadataProviderConfig(
        "IGDB_CLIENT_SECRET",
        this.name(),
      );

    this.clientId = client_id;
    this.clientSecret = client_secret;

    this.accessToken = "";
    this.accessTokenExpiry = DateTime.now().minus({
      year: 1,
    });
  }

  private async authWithTwitch() {
    logger.info("IGDB authorizing with twitch");
    const params = new URLSearchParams({
      client_id: this.clientId,
      client_secret: this.clientSecret,
      grant_type: "client_credentials",
    });

    const response = await $fetch<TwitchAuthResponse>(
      `https://id.twitch.tv/oauth2/token?${params.toString()}`,
      {
        method: "POST",
      },
    );

    this.accessToken = response.access_token;
    this.accessTokenExpiry = DateTime.now().plus({
      seconds: response.expires_in,
    });

    logger.info("IGDB done authorizing with twitch");
  }

  private async refreshCredentials() {
    const futureTime = DateTime.now().plus({
      day: 1,
    });

    // if the token expires in less than a day
    if (this.accessTokenExpiry < futureTime) await this.authWithTwitch();
  }

  private async request<T extends object>(
    resource: string,
    body: string,
    options?: NitroFetchOptions<NitroFetchRequest, "post">,
  ) {
    await this.refreshCredentials();

    // prevent calling api before auth is complete
    if (this.accessToken.length <= 0)
      throw new Error(
        "IGDB either failed to authenticate, or has not done so yet",
      );

    const finalURL = `https://api.igdb.com/v4/${resource}`;

    const overlay: NitroFetchOptions<NitroFetchRequest, "post"> = {
      baseURL: "",
      method: "POST",
      body,
      headers: {
        Accept: "application/json",
        "Client-ID": this.clientId,
        Authorization: `Bearer ${this.accessToken}`,
        "content-type": "text/plain",
      },
    };
    const response = await $fetch<T[] | IGDBErrorResponse[]>(
      finalURL,
      Object.assign({}, options, overlay),
    );

    // should not have an error object if the status code is 200
    return <T[]>response;
  }

  private async _getMediaInternal(
    mediaID: IGDBID,
    type: string,
    size: string = "t_thumb",
  ) {
    if (mediaID === undefined)
      throw new Error(
        `IGDB mediaID when getting item of type ${type} was undefined`,
      );

    const body = `where id = ${mediaID}; fields image_id;`;
    const response = await this.request<IGDBCover>(type, body);

    if (!response.length || !response[0].image_id) {
      throw new Error(`No image_id found for ${type} with id ${mediaID}`);
    }

    const imageId = response[0].image_id;
    const result = `https://images.igdb.com/igdb/image/upload/${size}/${imageId}.jpg`;

    return result;
  }

  private async getCoverURL(id: IGDBID) {
    return await this._getMediaInternal(id, "covers", "t_cover_big");
  }

  private async getArtworkURL(id: IGDBID) {
    return await this._getMediaInternal(id, "artworks", "t_1080p");
  }

  private async getScreenshotURL(id: IGDBID) {
    return await this._getMediaInternal(id, "screenshots", "t_1080p");
  }

  private async getIconURL(id: IGDBID) {
    return await this._getMediaInternal(id, "covers", "t_thumb");
  }

  private async getCompanyLogoURl(id: IGDBID) {
    return await this._getMediaInternal(id, "company_logos", "t_original");
  }

  private trimMessage(msg: string, len: number) {
    return msg.length > len ? msg.substring(0, 280) + "..." : msg;
  }

  private async _getGenreInternal(genreID: IGDBID) {
    if (genreID === undefined) throw new Error(`IGDB genreID was undefined`);

    const body = `where id = ${genreID}; fields slug,name,url;`;
    const response = await this.request<IGDBGenre>("genres", body);

    let result = "";

    response.forEach((genre) => {
      result = genre.name;
    });

    return result;
  }

  private async getGenres(genres: IGDBID[] | undefined): Promise<string[]> {
    if (genres === undefined) return [];

    const results: string[] = [];
    for (const genre of genres) {
      results.push(await this._getGenreInternal(genre));
    }

    return results;
  }

  private async getAgeRatings(
    ageRatingIds: IGDBID[] | undefined,
  ): Promise<GameMetadataAgeRating[]> {
    if (!ageRatingIds?.length) return [];

    const results: GameMetadataAgeRating[] = [];
    for (const id of ageRatingIds) {
      const response = await this.request<IGDBAgeRating>(
        "age_ratings",
        `where id = ${id}; fields category,rating,rating_cover_url;`,
      );

      for (const ar of response) {
        const organization = IGDB_CATEGORY_TO_ORG[ar.category];
        const rating = IGDB_RATING_TO_STRING[ar.rating];
        if (!organization || !rating) continue;

        results.push({
          organization,
          rating,
          ratingCoverUrl: ar.rating_cover_url,
        });
      }
    }

    return results;
  }

  name() {
    return "IGDB";
  }
  source() {
    return MetadataSource.IGDB;
  }

  async search(query: string): Promise<GameMetadataSearchResult[]> {
    const body = `search "${query}"; fields name,cover,first_release_date,summary; limit 3;`;
    const response = await this.request<IGDBSearchStub>("games", body);

    const results: GameMetadataSearchResult[] = [];
    for (let i = 0; i < response.length; i++) {
      let icon = "";
      const cover = response[i].cover;
      if (cover !== undefined) {
        icon = await this.getIconURL(cover);
      } else {
        icon = "";
      }

      const firstReleaseDate = response[i].first_release_date;
      results.push({
        id: "" + response[i].id,
        name: response[i].name,
        icon,
        description: response[i].summary,
        year:
          firstReleaseDate === undefined
            ? 0
            : DateTime.fromSeconds(firstReleaseDate).year,
      });
    }

    return results;
  }
  async fetchGame(
    { id, company, createObject }: _FetchGameMetadataParams,
    context?: TaskRunContext,
  ): Promise<GameMetadata> {
    const body = `where id = ${id}; fields *;`;
    const currentGame = (await this.request<IGDBGameFull>("games", body)).at(0);
    if (!currentGame) throw new Error("No game found on IGDB with that id");

    context?.logger.info("Using IGDB provider.");

    let iconRaw, coverRaw;
    const cover = currentGame.cover;

    if (cover !== undefined) {
      context?.logger.info("Found cover URL, using...");
      iconRaw = await this.getIconURL(cover);
      coverRaw = await this.getCoverURL(cover);
    } else {
      context?.logger.info("Missing cover URL, using fallback...");
      iconRaw = jdenticon.toPng(id, 512);
      coverRaw = iconRaw;
    }

    const icon = createObject(iconRaw);
    const coverID = createObject(coverRaw);
    let banner;

    const images = [coverID];
    for (const art of currentGame.artworks ?? []) {
      const objectId = createObject(await this.getArtworkURL(art));
      if (!banner) {
        banner = objectId;
      }
      images.push(objectId);
    }

    if (!banner) {
      banner = createObject(jdenticon.toPng(id, 512));
    }

    for (const screenshot of currentGame.screenshots ?? []) {
      const objectId = createObject(await this.getScreenshotURL(screenshot));
      images.push(objectId);
    }

    context?.progress(20);

    const publishers: CompanyModel[] = [];
    const developers: CompanyModel[] = [];
    for (const involvedCompany of currentGame.involved_companies ?? []) {
      // get details about the involved company
      const involved_company_response = await this.request<IGDBInvolvedCompany>(
        "involved_companies",
        `where id = ${involvedCompany}; fields *;`,
      );
      for (const foundInvolved of involved_company_response) {
        // now we need to get the actual company so we can get the name
        const findCompanyResponse = await this.request<
          { name: string } & IGDBItem
        >("companies", `where id = ${foundInvolved.company}; fields name;`);

        for (const companyData of findCompanyResponse) {
          context?.logger.info(
            `Found involved company "${company.name}" as: ${foundInvolved.developer ? "developer, " : ""}${foundInvolved.publisher ? "publisher" : ""}`,
          );

          const res = await company(companyData.name);
          if (res === undefined) {
            context?.logger.warn(
              `Failed to import company "${companyData.name}"`,
            );
            continue;
          }

          // if company was a dev or publisher
          // CANNOT use else since a company can be both
          if (foundInvolved.developer) {
            context?.logger.info(`Imported developer "${companyData.name}"`);
            developers.push(res);
          }

          if (foundInvolved.publisher) {
            context?.logger.info(`Imported publisher "${companyData.name}"`);
            publishers.push(res);
          }
        }
      }
    }

    context?.progress(80);

    const firstReleaseDate = currentGame.first_release_date;
    const released =
      firstReleaseDate === undefined
        ? new Date()
        : DateTime.fromSeconds(firstReleaseDate).toJSDate();

    const review = {
      metadataId: currentGame.id.toString(),
      metadataSource: MetadataSource.IGDB,
      mReviewCount: currentGame.total_rating_count ?? 0,
      mReviewRating: (currentGame.total_rating ?? 0) / 100,
      mReviewHref: currentGame.url,
    };

    const genres = await this.getGenres(currentGame.genres);
    const ageRatings = await this.getAgeRatings(currentGame.age_ratings);

    let description = "";
    let shortDescription = "";

    if (currentGame.summary.length > (currentGame.storyline?.length ?? 0)) {
      description = currentGame.summary;
      shortDescription = this.trimMessage(
        currentGame.storyline ?? currentGame.summary,
        280,
      );
    } else {
      description = currentGame.storyline ?? currentGame.summary;
      shortDescription = this.trimMessage(currentGame.summary, 280);
    }

    const metadata = {
      id: currentGame.id.toString(),
      name: currentGame.name,
      shortDescription,
      description,
      released,

      genres,
      reviews: [review],
      ageRatings,

      publishers,
      developers,

      tags: [],

      icon,
      bannerId: banner,
      coverId: coverID,
      images,
    };

    context?.logger.info("IGDB provider finished.");
    context?.progress(100);

    return metadata;
  }
  async fetchCompany({
    query,
    createObject,
  }: _FetchCompanyMetadataParams): Promise<CompanyMetadata | undefined> {
    const response = await this.request<IGDBCompany>(
      "companies",
      `where name = "${query}"; fields *; limit 1;`,
    );

    for (const company of response) {
      const logo = createObject(await this.getCompanyLogoURl(company.logo));

      let company_url = "";
      for (const companySite of company.websites) {
        const companySiteRes = await this.request<IGDBCompanyWebsite>(
          "company_websites",
          `where id = ${companySite}; fields *;`,
        );

        for (const site of companySiteRes) {
          if (company_url.length <= 0) company_url = site.url;
        }
      }
      const metadata: CompanyMetadata = {
        id: "" + company.id,
        name: company.name,
        shortDescription: this.trimMessage(company.description, 280),
        description: company.description,
        website: company_url,

        logo: logo,
        banner: logo,
      };

      return metadata;
    }

    return undefined;
  }
}
