import { AgeRatingOrganization, MetadataSource } from "~/prisma/client/enums";
import {
  ESRBRating,
  PEGIRating,
  USKRating,
  ACBRating,
} from "~/utils/ageRatings";
import type { MetadataProvider } from ".";
import type {
  GameMetadataSearchResult,
  _FetchGameMetadataParams,
  GameMetadata,
  _FetchCompanyMetadataParams,
  CompanyMetadata,
  GameMetadataRating,
  GameMetadataAgeRating,
} from "./types";
import type { TaskRunContext } from "../tasks";
import * as jdenticon from "jdenticon";
import { load } from "cheerio";

/**
 * Note: The Steam API is largely undocumented.
 * Helpful resources for reverse engineering and understanding endpoints:
 * - The GOAT xPaw: https://steamapi.xpaw.me/
 * - RJackson and the Team Fortress Community: https://wiki.teamfortress.com/wiki/User:RJackson/StorefrontAPI
 *
 * These community-driven resources provide valuable insights into Steam's internal APIs.
 *
 * Most Steam API endpoints accept a 'language' or 'l' query parameter for localization.
 * Some endpoints require a cc (country code) parameter to filter region-specific game availability.
 *
 * There is no public known endpoint for searching companies, so we scrape the developer page instead.
 * We're geussing the developer page by calling `https://store.steampowered.com/developer/{developer_name}/`.
 * This as a high chance of failing, because the developer name is not always the same as the URL slug.
 * Alternatively, we could use the link on a game's store page, but this redirects often to the publisher.
 */

interface SteamItem {
  appid: string;
}

interface SteamSearchStub extends SteamItem {
  name: string;
  icon: string; // Ratio 1:1
  logo: string; // Ratio 8:3
}

interface SteamAppDetailsSmall extends SteamItem {
  item_type: number;
  id: number;
  success: number;
  visible: boolean;
  name: string;
  store_url_path: string;
  type: number;
  categories: {
    supported_player_categoryids: number[];
    featured_categoryids: number[];
    controller_categoryids: number[];
  };
  basic_info: {
    short_description: string;
    publishers: {
      name: string;
      creator_clan_account_id: number;
    }[];
    developers: {
      name: string;
      creator_clan_account_id: number;
    }[];
    capsule_headline: string;
  };
  release: {
    steam_release_date: number; // UNIX timestamp in seconds
  };
  best_purchase_option: {
    packageid: number;
    purchase_option_name: string;
    final_price_in_cents: string;
    formatted_final_price: string;
    usert_can_purchase_as_gift: boolean;
    hide_discount_pct_for_compliance: boolean;
    included_game_count: number;
  };
}

interface SteamAppDetailsLarge extends SteamAppDetailsSmall {
  tagids: number[];
  reviews: {
    summary_filtered: {
      review_count: number;
      percent_positive: number;
      review_score: number;
      review_score_label: string;
    };
    summary_language_specific: {
      review_count: number;
      percent_positive: number;
      review_score: number;
      review_score_label: string;
    }[];
  };
  tags: {
    tagid: number;
    weight: number;
  }[];
  assets: {
    asset_url_format: string;
    main_capsule: string;
    small_capsule: string;
    header: string;
    page_background: string;
    hero_capsule: string;
    library_capsule: string;
    library_capsule_2x: string;
    library_hero: string;
    community_icon: string;
    page_background_path: string;
    raw_page_background: string;
  };
  screenshots: {
    all_ages_screenshots: {
      filename: string;
      ordinal: number;
    }[];
    mature_content_screenshots: {
      filename: string;
      ordinal: number;
    }[];
  };
  full_description: string;
}

interface SteamAppDetailsPackage {
  response: {
    store_items: SteamAppDetailsSmall[] | SteamAppDetailsLarge[];
  };
}

interface SteamTags {
  tagid: number;
  name: string;
}

interface SteamTagsPackage {
  response: {
    version_hash: string;
    tags: SteamTags[];
  };
}

interface SteamRatingEntry {
  rating: string;
  descriptors?: string;
  required_age?: string;
}

interface SteamWebAppDetailsSmall {
  type: string;
  name: string;
  steam_appid: number;
  required_age: string;
  is_free: boolean;
  dlc: number[];
  detailed_description: string;
  about_the_game: string;
  short_description: string;
  supported_languages: string;
  header_image: string;
  capsule_image: string;
  capsule_imagev5: string;
  website: string;
  pc_requirements: { minimum: string; recommended: string };
  mac_requirements: { minimum: string; recommended: string };
  linux_requirements: { minimum: string; recommended: string };
  legal_notice: string;
  ratings?: Record<string, SteamRatingEntry>;
}

interface SteamWebAppDetailsLarge extends SteamWebAppDetailsSmall {
  metacritic: {
    score: number;
    url: string;
  };
}

interface SteamWebAppDetailsPackage {
  [key: string]: {
    success: boolean;
    data: SteamWebAppDetailsSmall | SteamWebAppDetailsLarge;
  };
}

const STEAM_RATING_KEY_TO_ORG: Record<string, AgeRatingOrganization> = {
  esrb: AgeRatingOrganization.ESRB,
  pegi: AgeRatingOrganization.PEGI,
  usk: AgeRatingOrganization.USK,
  oflc: AgeRatingOrganization.ACB,
};

function lowercaseNormMap(
  ratings: Record<string, string>,
): Record<string, string> {
  return Object.fromEntries(
    Object.values(ratings).map((v) => [v.toLowerCase(), v]),
  );
}

const STEAM_RATING_NORMALIZE: Record<string, Record<string, string>> = {
  esrb: lowercaseNormMap(ESRBRating),
  pegi: lowercaseNormMap(PEGIRating),
  usk: lowercaseNormMap(USKRating),
  oflc: lowercaseNormMap(ACBRating),
};

export class SteamProvider implements MetadataProvider {
  name() {
    return "Steam";
  }

  source(): MetadataSource {
    return MetadataSource.Steam;
  }

  async search(query: string): Promise<GameMetadataSearchResult[]> {
    const response = await $fetch<SteamSearchStub[]>(
      `https://steamcommunity.com/actions/SearchApps/${query}`,
    );

    if (!response || response.length === 0) {
      return [];
    }

    const result: GameMetadataSearchResult[] = response.map((item) => ({
      id: item.appid,
      name: item.name,
      icon: item.icon || "",
      description: "",
      year: 0,
    }));

    const ids = response.map((i) => i.appid);

    const detailsResponse = await this._fetchGameDetails(ids, {
      include_basic_info: true,
      include_release: true,
    });

    const detailsMap = new Map<string, SteamAppDetailsSmall>();
    for (const item of detailsResponse) {
      detailsMap.set(item.appid.toString(), item);
    }

    for (const resItem of result) {
      const details = detailsMap.get(resItem.id);

      if (!details) continue;
      resItem.description = details.basic_info.short_description || "";

      if (!details.release?.steam_release_date) continue;
      const date = new Date(details.release.steam_release_date * 1000);
      resItem.year = date.getFullYear();
    }

    return result;
  }

  async fetchGame(
    { id, company, createObject }: _FetchGameMetadataParams,
    context?: TaskRunContext,
  ): Promise<GameMetadata> {
    context?.logger.info(`Starting Steam metadata fetch for game ID: ${id}`);
    context?.progress(0);

    context?.logger.info("Fetching game details from Steam API...");
    const response = await this._fetchGameDetails([id], {
      include_assets: true,
      include_basic_info: true,
      include_release: true,
      include_screenshots: true,
      include_tag_count: 100,
      include_full_description: true,
      include_reviews: true,
    });

    if (response.length === 0) {
      context?.logger.error(`No game found on Steam with ID: ${id}`);
      throw new Error(`No game found on Steam with id: ${id}`);
    }

    const currentGame = response[0] as SteamAppDetailsLarge;

    context?.logger.info(`Found game: "${currentGame.name}" on Steam`);
    context?.progress(10);

    context?.logger.info("Processing game images and assets...");
    const { icon, cover, banner, images } = this._processImages(
      currentGame,
      createObject,
      context,
    );

    const released = currentGame.release?.steam_release_date
      ? new Date(currentGame.release.steam_release_date * 1000)
      : new Date();

    if (currentGame.release?.steam_release_date) {
      context?.logger.info(`Release date: ${released.toLocaleDateString()}`);
    } else {
      context?.logger.warn(
        "No release date found, using current date as fallback",
      );
    }

    context?.progress(60);

    context?.logger.info(
      `Fetching tags from Steam (${currentGame.tagids?.length || 0} tags to process)...`,
    );
    const tags = await this._getTagNames(currentGame.tagids || []);

    context?.logger.info(
      `Successfully fetched ${tags.length} tags: ${tags.slice(0, 5).join(", ")}${tags.length > 5 ? "..." : ""}`,
    );
    context?.progress(70);

    context?.logger.info("Processing publishers and developers...");
    const storePage = await $fetch<string>(
      `https://store.steampowered.com/app/${id}/`,
    );
    const $ = load(storePage);

    const companyLinks = $("a")
      .toArray()
      .filter(
        (v) =>
          v.attribs["href"]?.startsWith(
            "https://store.steampowered.com/developer/",
          ) ||
          v.attribs["href"]?.startsWith(
            "https://store.steampowered.com/publisher/",
          ),
      )
      .map((v) => v.attribs.href)
      .filter((v) => v !== undefined);

    const companies: {
      [key: string]: {
        pub: boolean;
        dev: boolean;
      };
    } = {};

    companyLinks.forEach((v) => {
      const [type, name] = v
        .substring("https://store.steampowered.com/".length, v.indexOf("?"))
        .split("/");

      if (!type || !name) return;

      companies[name] ??= { pub: false, dev: false };
      switch (type) {
        case "publisher":
          companies[name].pub = true;
          break;
        case "developer":
          companies[name].dev = true;
          break;
      }
    });

    const publishers = [];
    const developers = [];

    for (const [companyName, types] of Object.entries(companies)) {
      context?.logger.info(`Processing company: "${companyName}"`);
      const comp = await company(companyName);

      if (types.dev) {
        developers.push(comp);
        context?.logger.info(
          `Successfully imported developer: "${companyName}"`,
        );
      }
      if (types.pub) {
        publishers.push(comp);
        context?.logger.info(
          `Successfully imported publisher: "${companyName}"`,
        );
      }
    }

    context?.logger.info(
      `Company processing complete: ${publishers.length} publishers, ${developers.length} developers`,
    );
    context?.progress(80);

    context?.logger.info("Fetching detailed description and reviews...");
    const webAppDetails = (await this._getWebAppDetails(
      id,
      "metacritic,ratings",
    )) as SteamWebAppDetailsLarge;

    const detailedDescription =
      webAppDetails?.detailed_description ||
      webAppDetails?.about_the_game ||
      "";

    let description;
    if (detailedDescription) {
      context?.logger.info("Converting HTML description to Markdown...");
      const converted = this._htmlToMarkdown(detailedDescription, createObject);
      images.push(...converted.objects);
      description = converted.markdown;
      context?.logger.info(
        `Description converted, ${converted.objects.length} images embedded`,
      );
    } else {
      context?.logger.info("Using fallback description from basic game info");
      description = currentGame.full_description;
    }

    context?.progress(90);

    context?.logger.info("Processing review ratings...");
    const reviews = [
      {
        metadataId: id,
        metadataSource: MetadataSource.Steam,
        mReviewCount: currentGame.reviews?.summary_filtered?.review_count || 0,
        mReviewHref: `https://store.steampowered.com/app/${id}`,
        mReviewRating:
          (currentGame.reviews?.summary_filtered?.percent_positive || 0) / 100,
      },
    ] as GameMetadataRating[];

    const steamReviewCount =
      currentGame.reviews?.summary_filtered?.review_count || 0;
    const steamRating =
      currentGame.reviews?.summary_filtered?.percent_positive || 0;
    context?.logger.info(
      `Steam reviews: ${steamReviewCount} reviews, ${steamRating}% positive`,
    );

    const ageRatings = this._extractAgeRatings(webAppDetails?.ratings);
    if (ageRatings.length > 0) {
      context?.logger.info(
        `Found ${ageRatings.length} age ratings: ${ageRatings.map((r) => `${r.organization}: ${r.rating}`).join(", ")}`,
      );
    }

    if (webAppDetails?.metacritic) {
      reviews.push({
        metadataId: id,
        metadataSource: MetadataSource.Metacritic,
        mReviewCount: 0,
        mReviewHref: webAppDetails.metacritic.url,
        mReviewRating: webAppDetails.metacritic.score / 100,
      });
      context?.logger.info(
        `Metacritic score: ${webAppDetails.metacritic.score}/100`,
      );
    }

    context?.logger.info(
      `Review processing complete: ${reviews.length} rating sources found`,
    );
    context?.progress(100);

    context?.logger.info("Steam metadata fetch completed successfully!");

    return {
      id: currentGame.appid.toString(),
      name: currentGame.name,
      shortDescription: currentGame.basic_info.short_description || "",
      description,
      released,
      publishers,
      developers,
      tags,
      reviews,
      ageRatings,
      icon,
      bannerId: banner,
      coverId: cover,
      images,
    } as GameMetadata;
  }

  async fetchCompany({
    query,
    createObject,
  }: _FetchCompanyMetadataParams): Promise<CompanyMetadata | undefined> {
    const searchParams = new URLSearchParams({
      l: "english",
    });

    const url = `https://store.steampowered.com/developer/${encodeURIComponent(query)}/?${searchParams.toString()}`;
    const response = await $fetch<string>(url);

    if (!response) {
      return undefined;
    }

    const html = response;

    // Extract metadata from HTML meta tags
    const metadata = this._extractMetaTagsFromHtml(html);

    if (!metadata.title || metadata.title == "Steam Search") {
      return undefined;
    }

    // Extract company name from title (format: "Steam Developer: CompanyName")
    const companyName = metadata.title
      .replace(/^Steam Developer:\s*/i, "")
      .trim();

    if (!companyName) {
      return undefined;
    }

    let logoRaw;
    if (metadata.image) {
      logoRaw = metadata.image;
    } else {
      logoRaw = jdenticon.toPng(companyName, 512);
    }

    const logo = createObject(logoRaw);

    let bannerRaw;
    if (metadata.banner) {
      bannerRaw = metadata.banner;
    } else {
      bannerRaw = jdenticon.toPng(companyName, 512);
    }

    const banner = createObject(bannerRaw);

    return {
      id: query.replaceAll(" ", ""),
      name: companyName,
      shortDescription: metadata.description || "",
      description: "",
      logo,
      banner,
      website:
        metadata.url ||
        `https://store.steampowered.com/developer/${query.replaceAll(" ", "")}`,
    } as CompanyMetadata;
  }

  private _extractMetaTagsFromHtml(html: string): {
    title?: string;
    description?: string;
    image?: string;
    url?: string;
    banner?: string;
  } {
    const metadata: {
      title?: string;
      description?: string;
      image?: string;
      url?: string;
      banner?: string;
    } = {};

    const title = this._extractTitle(html);
    if (title) metadata.title = title;

    const description = this._extractDescription(html);
    if (description) metadata.description = description;

    const image = this._extractImage(html);
    if (image) metadata.image = image;

    const url = this._extractUrl(html);
    if (url) metadata.url = url;

    const banner = this._extractBanner(html);
    if (banner) metadata.banner = banner;

    return metadata;
  }

  private _extractTitle(html: string): string | undefined {
    const ogTitleRegex =
      /<meta\s+property\s*=\s*["']og:title["']\s+content\s*=\s*["']([^"']+)["']/i;
    const titleTagRegex = /<title[^>]*>([^<]+)<\/title>/i;

    let titleMatch = ogTitleRegex.exec(html);
    titleMatch ??= titleTagRegex.exec(html);

    return titleMatch && titleMatch[1]
      ? this._decodeHtmlEntities(titleMatch[1])
      : undefined;
  }

  private _extractDescription(html: string): string | undefined {
    const ogDescRegex =
      /<meta\s+property\s*=\s*"(?:og:description|twitter:description)"\s+content\s*=\s*"([^"]+)"\s*\/?>/i;
    const nameDescRegex =
      /<meta\s+name\s*=\s*"(?:Description|description)"\s+content\s*=\s*"([^"]+)"\s*\/?>/i;

    let descMatch = ogDescRegex.exec(html);
    descMatch ??= nameDescRegex.exec(html);

    return descMatch && descMatch[1]
      ? this._decodeHtmlEntities(descMatch[1])
      : undefined;
  }

  private _extractImage(html: string): string | undefined {
    const ogImageRegex =
      /<meta\s+property\s*=\s*["'](?:og:image|twitter:image)["']\s+content\s*=\s*["']([^"']+)["']/i;
    const imageSrcRegex =
      /<link\s+rel\s*=\s*["']image_src["']\s+href\s*=\s*["']([^"']+)["']/i;

    let imageMatch = ogImageRegex.exec(html);
    imageMatch ??= imageSrcRegex.exec(html);

    return imageMatch ? imageMatch[1] : undefined;
  }

  private _extractUrl(html: string): string | undefined {
    const curatorUrlRegex =
      /<a[^>]*class\s*=\s*["'][^"']*curator_url[^"']*["'][^>]*href\s*=\s*["']https:\/\/steamcommunity\.com\/linkfilter\/\?u=([^"'&]+)["']/i;
    const linkfilterRegex =
      /<a[^>]*href\s*=\s*["']https:\/\/steamcommunity\.com\/linkfilter\/\?u=([^"'&]+)["'][^>]*(?:target=["']_blank["']|rel=["'][^"']*["'])/i;

    let curatorUrlMatch = curatorUrlRegex.exec(html);
    curatorUrlMatch ??= linkfilterRegex.exec(html);

    if (!curatorUrlMatch) return undefined;
    if (!curatorUrlMatch[1]) return undefined;

    try {
      return decodeURIComponent(curatorUrlMatch[1]);
    } catch {
      return curatorUrlMatch[1];
    }
  }

  private _extractBanner(html: string): string | undefined {
    const bannerRegex =
      /background-image:\s*url\(['"]([^'"]*(?:\/clan\/\d+|\/app\/\d+|background|header)[^'"]*)\??[^'"]*['"][^}]*\)/i;
    const backgroundImageRegex =
      /style\s*=\s*["'][^"']*background-image:\s*url\(([^)]+)\)[^"']*/i;

    let bannerMatch = bannerRegex.exec(html);
    bannerMatch ??= backgroundImageRegex.exec(html);

    if (!bannerMatch) return undefined;
    if (!bannerMatch[1]) return undefined;

    let bannerUrl = bannerMatch[1].replace(/['"]/g, "");
    // Clean up the URL
    if (bannerUrl.includes("?")) {
      bannerUrl = bannerUrl.split("?")[0]!;
    }
    return bannerUrl;
  }

  private _decodeHtmlEntities(text: string): string {
    return text
      .replace(/&nbsp;/g, " ")
      .replace(/&amp;/g, "&")
      .replace(/&lt;/g, "<")
      .replace(/&gt;/g, ">")
      .replace(/&quot;/g, '"')
      .replace(/&#39;/g, "'")
      .replace(/&#x([0-9A-Fa-f]+);/g, (_, hex) =>
        String.fromCharCode(parseInt(hex, 16)),
      )
      .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)));
  }

  private async _fetchGameDetails(
    gameIds: string[],
    dataRequest: object,
    language = "english",
    country_code = "US",
  ): Promise<SteamAppDetailsSmall[] | SteamAppDetailsLarge[]> {
    const searchParams = new URLSearchParams({
      input_json: JSON.stringify({
        ids: gameIds.map((id) => ({
          appid: parseInt(id),
        })),
        context: {
          language,
          country_code,
        },
        data_request: dataRequest,
      }),
    });

    const request = await $fetch<SteamAppDetailsPackage>(
      `https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?${searchParams.toString()}`,
    );

    const result = [];
    const storeItems = request.response?.store_items ?? [];

    for (const item of storeItems) {
      if (item.success !== 1) continue;
      result.push(item);
    }

    return result;
  }

  private _processImages(
    game: SteamAppDetailsLarge,
    createObject: (input: string | Buffer) => string,
    context?: TaskRunContext,
  ): { icon: string; cover: string; banner: string; images: string[] } {
    const imageURLFormat = game.assets?.asset_url_format;

    context?.logger.info("Processing game icon...");
    let iconRaw;
    if (game.assets?.community_icon) {
      context?.logger.info("Found community icon on Steam");
      iconRaw = `https://cdn.fastly.steamstatic.com/steamcommunity/public/images/apps/${game.appid}/${game.assets.community_icon}.jpg`;
    } else {
      context?.logger.info("No icon found, generating fallback icon");
      iconRaw = jdenticon.toPng(game.appid, 512);
    }

    const icon = createObject(iconRaw);
    context?.progress(20);

    context?.logger.info("Processing game cover art...");
    let coverRaw;
    if (game.assets?.library_capsule_2x) {
      context?.logger.info("Found high-resolution cover art");
      coverRaw = this._getImageUrl(
        game.assets.library_capsule_2x,
        imageURLFormat,
      );
    } else if (game.assets?.library_capsule) {
      context?.logger.info("Found standard resolution cover art");
      coverRaw = this._getImageUrl(game.assets.library_capsule, imageURLFormat);
    } else {
      context?.logger.info("No cover art found, generating fallback cover");
      coverRaw = jdenticon.toPng(game.appid, 512);
    }

    const cover = createObject(coverRaw);
    context?.progress(30);

    context?.logger.info("Processing game banner...");
    let bannerRaw;
    if (game.assets?.library_hero) {
      context?.logger.info("Found library hero banner");
      bannerRaw = this._getImageUrl(game.assets.library_hero, imageURLFormat);
    } else {
      context?.logger.info("No banner found, generating fallback banner");
      bannerRaw = jdenticon.toPng(game.appid, 512);
    }

    const banner = createObject(bannerRaw);
    context?.progress(40);

    const images = [cover, banner];

    const screenshots = game.screenshots?.all_ages_screenshots || [];
    screenshots.push(...(game.screenshots?.mature_content_screenshots || []));
    screenshots.sort((a, b) => a.ordinal - b.ordinal);

    context?.logger.info(`Processing ${screenshots.length} screenshots...`);

    for (const image of screenshots) {
      const imageUrl = this._getImageUrl(image.filename);
      images.push(createObject(imageUrl));
    }

    context?.logger.info(
      `Image processing complete: icon, cover, banner and ${screenshots.length} screenshots`,
    );
    context?.progress(50);

    return { icon, cover, banner, images };
  }

  private async _getTagNames(
    tagIds: number[],
    language = "english",
  ): Promise<string[]> {
    if (tagIds.length === 0) return [];

    const searchParams = new URLSearchParams({
      language,
    });

    const request = await $fetch<SteamTagsPackage>(
      `https://api.steampowered.com/IStoreService/GetTagList/v1/?${searchParams.toString()}`,
    );

    if (!request.response?.tags) return [];

    const tagMap = new Map<number, string>();
    for (const tag of request.response.tags) {
      tagMap.set(tag.tagid, tag.name);
    }

    const result = [];
    for (const tagId of tagIds) {
      const tagName = tagMap.get(tagId);
      if (!tagName) continue;

      result.push(tagName);
    }

    return result;
  }

  private async _getWebAppDetails(
    appid: string,
    dataRequest: string, // Seperated by commas
    language = "english",
  ): Promise<SteamWebAppDetailsLarge | SteamWebAppDetailsSmall | undefined> {
    const searchParams = new URLSearchParams({
      appids: appid,
      filter: "basic," + dataRequest,
      l: language,
    });

    const request = await $fetch<SteamWebAppDetailsPackage>(
      `https://store.steampowered.com/api/appdetails?${searchParams.toString()}`,
    );

    const appData = request[appid]?.data;
    if (!appData) {
      return undefined;
    }

    return appData;
  }

  private _extractAgeRatings(
    ratings: Record<string, SteamRatingEntry> | undefined,
  ): GameMetadataAgeRating[] {
    if (!ratings) return [];

    const results: GameMetadataAgeRating[] = [];
    for (const [key, entry] of Object.entries(ratings)) {
      const org = STEAM_RATING_KEY_TO_ORG[key];
      if (!org) continue;

      const normalized =
        STEAM_RATING_NORMALIZE[key]?.[entry.rating] ??
        entry.rating.toUpperCase();
      results.push({ organization: org, rating: normalized });
    }
    return results;
  }

  private _getImageUrl(filename: string, format?: string): string {
    if (!filename || filename.trim().length === 0) return "";

    const url = "https://shared.fastly.steamstatic.com/store_item_assets/";

    if (format) {
      format = format.replace("${FILENAME}", filename);
      return url + format;
    }

    return url + filename;
  }

  private _htmlToMarkdown(
    html: string,
    createObject: (input: string | Buffer) => string,
  ): { markdown: string; objects: string[] } {
    if (!html || html.trim().length === 0) return { markdown: "", objects: [] };

    let markdown = html;
    const objects: string[] = [];
    const imageReplacements: { placeholder: string; imageId: string }[] = [];

    markdown = this._convertBasicHtmlElements(markdown);

    // Replace images with placheholders
    markdown = markdown.replace(
      /<img[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi,
      (match, src) => {
        const imageId = createObject(src);
        objects.push(imageId);
        const placeholder = `__IMG_${imageReplacements.length}__`;
        imageReplacements.push({ placeholder, imageId });
        return placeholder;
      },
    );

    markdown = this._convertRemainingHtmlElements(markdown);

    markdown = this._stripHtmlTags(markdown);

    markdown = this._cleanupBasicFormatting(markdown);

    markdown = this._processImagePlaceholders(markdown, imageReplacements);

    markdown = this._finalCleanup(markdown);

    return { markdown, objects };
  }

  private _convertBasicHtmlElements(markdown: string): string {
    // Remove HTML comments
    markdown = markdown.replace(/<!--[\s\S]*?-->/g, "");

    // Convert the bullet points and tabs to markdown list format
    markdown = markdown.replace(/•\s*\t+/g, "\n- ");

    // Handle numbered enumeration (1.\t, 2.\t, etc.)
    markdown = markdown.replace(/(\d+)\.\s*\t+/g, "\n$1. ");

    // Convert bold text
    markdown = markdown.replace(
      /<(strong|b)[^>]*>(.*?)<\/(strong|b)>/gi,
      "**$2**",
    );

    // Convert headers (h1-h6) with Steam's bb_tag class
    markdown = markdown.replace(
      /<h([1-6])(?:\s+class="bb_tag")?[^>]*>(.*?)<\/h[1-6]>/gi,
      (_, level, content) => {
        const headerLevel = "#".repeat(parseInt(level));
        const cleanContent = this._stripHtmlTags(content).trim();
        return cleanContent ? `\n\n${headerLevel} ${cleanContent}\n\n` : "";
      },
    );

    return markdown;
  }

  private _convertRemainingHtmlElements(markdown: string): string {
    // Convert paragraphs with Steam's bb_paragraph class
    markdown = markdown.replace(
      /<p(?:\s+class="bb_paragraph")?[^>]*>(.*?)<\/p>/gi,
      (_, content) => {
        const cleanContent = this._stripHtmlTags(content).trim();
        return cleanContent ? `${cleanContent}` : "";
      },
    );

    // Convert unordered lists with Steam's bb_ul class
    markdown = markdown.replace(
      /<ul(?:\s+class="bb_ul")?[^>]*>([\s\S]*?)<\/ul>/gi,
      (_, content) => {
        const listItems = content.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
        const markdownItems = listItems
          .map((item: string) => {
            const cleanItem = item.replace(/<li[^>]*>([\s\S]*?)<\/li>/i, "$1");
            const cleanContent = this._stripHtmlTags(cleanItem).trim();
            return cleanContent ? `- ${cleanContent}` : "";
          })
          .filter(Boolean);
        return markdownItems.length > 0 ? `${markdownItems.join("\n")}\n` : "";
      },
    );

    // Convert ordered lists with Steam's bb_ol class
    markdown = markdown.replace(
      /<ol(?:\s+class="bb_ol")?[^>]*>([\s\S]*?)<\/ol>/gi,
      (_, content) => {
        const listItems = content.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
        const markdownItems = listItems
          .map((item: string, index: number) => {
            const cleanItem = item.replace(/<li[^>]*>([\s\S]*?)<\/li>/i, "$1");
            const cleanContent = this._stripHtmlTags(cleanItem).trim();
            return cleanContent ? `${index + 1}. ${cleanContent}` : "";
          })
          .filter(Boolean);
        return markdownItems.length > 0 ? `${markdownItems.join("\n")}\n` : "";
      },
    );

    // Convert line breaks
    markdown = markdown.replace(/<br\s*\/?>/gi, "\n");

    // Convert italic text with <em> and <i> tags
    markdown = markdown.replace(/<(em|i)[^>]*>(.*?)<\/(em|i)>/gi, "*$2*");

    // Convert underlined text
    markdown = markdown.replace(/<u[^>]*>(.*?)<\/u>/gi, "_$1_");

    // Convert links
    markdown = markdown.replace(
      /<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)<\/a>/gi,
      "[$2]($1)",
    );

    // Convert divs to line breaks (common in Steam descriptions)
    markdown = markdown.replace(/<div[^>]*>(.*?)<\/div>/gi, "$1\n");

    // Handle span tags with bb_img_ctn class (Steam image containers)
    markdown = markdown.replace(
      /<span\s+class="bb_img_ctn"[^>]*>(.*?)<\/span>/gi,
      "$1",
    );

    return markdown;
  }

  private _cleanupBasicFormatting(markdown: string): string {
    // Clean up spaces before newlines
    markdown = markdown.replace(/ +\n/g, "\n");

    // Clean up excessive spacing around punctuation
    markdown = markdown.replace(/\s+([.,!?;:])/g, "$1");

    return markdown;
  }

  private _processImagePlaceholders(
    markdown: string,
    imageReplacements: { placeholder: string; imageId: string }[],
  ): string {
    const lines = markdown.split("\n");
    const processedLines: string[] = [];

    for (const line of lines) {
      const replacedLines = this._replacePlaceholdersInLine(
        line,
        imageReplacements,
      );
      processedLines.push(...replacedLines);
    }

    return processedLines.join("\n");
  }

  private _replacePlaceholdersInLine(
    line: string,
    imageReplacements: { placeholder: string; imageId: string }[],
  ): string[] {
    const currentLine = line;
    const results: string[] = [];

    // Find all placeholders
    const placeholdersInLine = imageReplacements.filter(({ placeholder }) =>
      currentLine.includes(placeholder),
    );

    if (placeholdersInLine.length === 0) {
      return [line];
    }

    // Sort placeholders by their position
    placeholdersInLine.sort(
      (a, b) =>
        currentLine.indexOf(a.placeholder) - currentLine.indexOf(b.placeholder),
    );

    let lastIndex = 0;

    for (const { placeholder, imageId } of placeholdersInLine) {
      const placeholderIndex = currentLine.indexOf(placeholder, lastIndex);

      if (placeholderIndex === -1) continue;

      // Add text before the placeholder (if any)
      const beforeText = currentLine.substring(lastIndex, placeholderIndex);
      if (beforeText.trim()) {
        results.push(beforeText.trim());
        results.push(""); // Empty line before image
      }

      results.push(`![](/api/v1/object/${imageId})`);

      lastIndex = placeholderIndex + placeholder.length;
    }

    // Add any remaining text after the last placeholder
    const afterText = currentLine.substring(lastIndex);
    if (afterText.trim()) {
      results.push(""); // Empty line after image
      results.push(afterText.trim());
    }

    // If we only have images and no text, return just the images
    if (
      results.every(
        (line) => line === "" || line.startsWith("![](/api/v1/object/"),
      )
    ) {
      return results.filter((line) => line !== "");
    }

    return results;
  }

  private _finalCleanup(markdown: string): string {
    // Clean up multiple consecutive newlines
    markdown = markdown.replace(/\n{3,}/g, "\n\n");

    markdown = markdown.trim();

    return markdown;
  }

  private _stripHtmlTags(html: string): string {
    return html
      .replace(/<[^>]*>/g, "")
      .replace(/&nbsp;/g, " ")
      .replace(/&amp;/g, "&")
      .replace(/&lt;/g, "<")
      .replace(/&gt;/g, ">")
      .replace(/&quot;/g, '"')
      .replace(/&#39;/g, "'");
  }
}
