const axios = require('axios');

const db = require('../db/models');

const { Op } = db.Sequelize;

const OSU_PUBLIC_BEATMAPSET_SEARCH_URL = 'https://osu.ppy.sh/beatmapsets/search';
const SUPPORTED_SET_STATUSES = new Set(['ranked', 'loved']);
const DEFAULT_SEED_PAGES = 12;
const DEFAULT_INCREMENTAL_PAGES = 3;
const MAX_PAGES_PER_JOB = 40;
const DEFAULT_START_PAGE = 1;
const REQUEST_TIMEOUT_MS = 30000;
const IMPORT_JOB_TYPES = ['osu_sync_seed', 'osu_sync_incremental'];

function syncError(message, statusCode = 400) {
  const error = new Error(message);
  error.statusCode = statusCode;
  return error;
}

function normalizeInteger(value, fallback = 0) {
  const normalized = Number(value);
  return Number.isFinite(normalized) ? Math.trunc(normalized) : fallback;
}

function normalizeDecimal(value, fallback = 0) {
  const normalized = Number(value);
  return Number.isFinite(normalized) ? normalized : fallback;
}

function normalizeString(value) {
  if (typeof value !== 'string') {
    return '';
  }

  return value.trim();
}

function normalizeSetStatus(status) {
  const normalized = normalizeString(status).toLowerCase();
  return SUPPORTED_SET_STATUSES.has(normalized) ? normalized : null;
}

function getMapperAvatarUrl(osuUserNumeric) {
  if (!osuUserNumeric) {
    return null;
  }

  return `https://a.ppy.sh/${osuUserNumeric}`;
}

function getMapperProfileUrl(osuUserNumeric) {
  if (!osuUserNumeric) {
    return null;
  }

  return `https://osu.ppy.sh/users/${osuUserNumeric}`;
}

function getBestCoverUrl(beatmapSet) {
  return (
    beatmapSet?.covers?.card ||
    beatmapSet?.covers?.['card@2x'] ||
    beatmapSet?.covers?.cover ||
    beatmapSet?.covers?.['cover@2x'] ||
    beatmapSet?.covers?.list ||
    beatmapSet?.covers?.['list@2x'] ||
    null
  );
}

function parseDateOrNull(value) {
  if (!value) {
    return null;
  }

  const parsed = new Date(value);
  return Number.isNaN(parsed.getTime()) ? null : parsed;
}

function parseJobPayload(rawPayload) {
  if (!rawPayload) {
    return {};
  }

  if (typeof rawPayload === 'object' && !Array.isArray(rawPayload)) {
    return rawPayload;
  }

  if (typeof rawPayload !== 'string') {
    throw syncError('job_payload_json must be empty or valid JSON.');
  }

  try {
    const parsed = JSON.parse(rawPayload);

    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
      throw syncError('job_payload_json must decode to a JSON object.');
    }

    return parsed;
  } catch (error) {
    if (error.statusCode) {
      throw error;
    }

    throw syncError('job_payload_json must contain valid JSON.');
  }
}

function getDefaultPageCount(jobType) {
  return jobType === 'osu_sync_incremental'
    ? DEFAULT_INCREMENTAL_PAGES
    : DEFAULT_SEED_PAGES;
}

function getNormalizedPageCount(payload, jobType) {
  return Math.min(
    Math.max(normalizeInteger(payload?.pages, getDefaultPageCount(jobType)), 1),
    MAX_PAGES_PER_JOB,
  );
}

function getNormalizedSort(payload) {
  return normalizeString(payload?.sort || 'plays_desc') || 'plays_desc';
}

function hasExplicitStartPage(payload) {
  const rawStartPage = payload?.startPage ?? payload?.page;

  if (rawStartPage === null || rawStartPage === undefined) {
    return false;
  }

  return `${rawStartPage}`.trim() !== '';
}

function getNormalizedStartPage(payload) {
  return Math.max(
    normalizeInteger(payload?.startPage ?? payload?.page, DEFAULT_START_PAGE),
    DEFAULT_START_PAGE,
  );
}

async function getNextAutomaticStartPage({ currentJobId, sort }) {
  const previousSuccessfulJobs = await db.sync_jobs.findAll({
    attributes: ['id', 'job_type', 'job_payload_json'],
    where: {
      id: {
        [Op.ne]: currentJobId,
      },
      job_type: {
        [Op.in]: IMPORT_JOB_TYPES,
      },
      status: 'succeeded',
    },
    order: [['createdAt', 'ASC']],
  });

  return previousSuccessfulJobs.reduce((nextStartPage, previousJob) => {
    let previousPayload;

    try {
      previousPayload = parseJobPayload(previousJob.job_payload_json);
    } catch (error) {
      return nextStartPage;
    }

    if (getNormalizedSort(previousPayload) !== sort) {
      return nextStartPage;
    }

    const previousStartPage = hasExplicitStartPage(previousPayload)
      ? getNormalizedStartPage(previousPayload)
      : DEFAULT_START_PAGE;
    const previousPages = getNormalizedPageCount(previousPayload, previousJob.job_type);
    const previousNextPage = previousStartPage + previousPages;

    return previousNextPage > nextStartPage ? previousNextPage : nextStartPage;
  }, DEFAULT_START_PAGE);
}

async function getImportOptions(jobRecord) {
  const payload = parseJobPayload(jobRecord?.job_payload_json);
  const pages = getNormalizedPageCount(payload, jobRecord?.job_type);
  const sort = getNormalizedSort(payload);
  const explicitStartPage = hasExplicitStartPage(payload);
  const startPage = explicitStartPage
    ? getNormalizedStartPage(payload)
    : await getNextAutomaticStartPage({
        currentJobId: jobRecord?.id,
        sort,
      });

  return {
    payload,
    pages,
    sort,
    startPage,
    explicitStartPage,
  };
}

function shouldKeepBeatmapSet(beatmapSet) {
  if (!beatmapSet || !normalizeInteger(beatmapSet.id, 0)) {
    return false;
  }

  if (!normalizeSetStatus(beatmapSet.status)) {
    return false;
  }

  if (!normalizeString(beatmapSet.title) || !normalizeString(beatmapSet.artist)) {
    return false;
  }

  return true;
}

function getUsableBeatmaps(beatmapSet) {
  const setStatus = normalizeSetStatus(beatmapSet?.status);

  return (Array.isArray(beatmapSet?.beatmaps) ? beatmapSet.beatmaps : []).filter(
    (beatmap) => {
      if (!beatmap || !normalizeInteger(beatmap.id, 0)) {
        return false;
      }

      const beatmapStatus = normalizeSetStatus(beatmap.status) || setStatus;

      return (
        beatmap.mode === 'osu' &&
        Boolean(beatmap.is_scoreable) &&
        Boolean(beatmapStatus)
      );
    },
  );
}

async function updateJob(jobId, currentUser, patch) {
  await db.sync_jobs.update(
    {
      ...patch,
      updatedById: currentUser?.id || null,
    },
    {
      where: { id: jobId },
    },
  );
}

function toRecordMap(records, getKey) {
  return records.reduce((accumulator, record) => {
    const key = getKey(record);

    if (key !== null && key !== undefined && key !== '') {
      accumulator.set(key, record);
    }

    return accumulator;
  }, new Map());
}

async function fetchBeatmapSearchPage({ page, sort }) {
  let response;

  try {
    response = await axios.get(OSU_PUBLIC_BEATMAPSET_SEARCH_URL, {
      params: {
        m: 0,
        nsfw: false,
        sort,
        page,
      },
      headers: {
        'User-Agent': 'Flatlogic Osu Higher Lower Catalog Sync',
        Accept: 'application/json',
      },
      timeout: REQUEST_TIMEOUT_MS,
    });
  } catch (error) {
    const remoteMessage = error?.response?.data?.error;

    throw syncError(
      remoteMessage || 'Unable to download beatmap catalog data from osu! right now.',
      error?.response?.status || 502,
    );
  }

  const beatmapSets = Array.isArray(response?.data?.beatmapsets)
    ? response.data.beatmapsets
    : [];

  return beatmapSets.filter(shouldKeepBeatmapSet);
}

async function upsertBeatmapSetsPage({ beatmapSets, currentUser }) {
  const transaction = await db.sequelize.transaction();
  const now = new Date();
  const counts = {
    processed: 0,
    inserted: 0,
    updated: 0,
    skipped: 0,
  };

  try {
    const osuBeatmapSetNumerics = beatmapSets
      .map((beatmapSet) => normalizeInteger(beatmapSet.id, 0))
      .filter(Boolean);
    const creatorUserNumerics = beatmapSets
      .map((beatmapSet) => normalizeInteger(beatmapSet.user_id, 0))
      .filter(Boolean);
    const artistNames = beatmapSets
      .map((beatmapSet) => normalizeString(beatmapSet.artist))
      .filter(Boolean);
    const osuBeatmapNumerics = beatmapSets.flatMap((beatmapSet) =>
      getUsableBeatmaps(beatmapSet)
        .map((beatmap) => normalizeInteger(beatmap.id, 0))
        .filter(Boolean),
    );

    const [existingSets, existingMappers, existingArtists, existingBeatmaps] =
      await Promise.all([
        db.beatmap_sets.findAll({
          where: {
            osu_beatmapset_numeric: {
              [Op.in]: osuBeatmapSetNumerics,
            },
          },
          transaction,
        }),
        creatorUserNumerics.length
          ? db.mappers.findAll({
              where: {
                osu_user_numeric: {
                  [Op.in]: creatorUserNumerics,
                },
              },
              transaction,
            })
          : [],
        artistNames.length
          ? db.artists.findAll({
              where: {
                name: {
                  [Op.in]: artistNames,
                },
              },
              transaction,
            })
          : [],
        osuBeatmapNumerics.length
          ? db.beatmaps.findAll({
              where: {
                osu_beatmap_numeric: {
                  [Op.in]: osuBeatmapNumerics,
                },
              },
              transaction,
            })
          : [],
      ]);

    const beatmapSetMap = toRecordMap(
      existingSets,
      (record) => normalizeInteger(record.osu_beatmapset_numeric, 0),
    );
    const mapperMap = toRecordMap(
      existingMappers,
      (record) => normalizeInteger(record.osu_user_numeric, 0),
    );
    const artistMap = toRecordMap(existingArtists, (record) => normalizeString(record.name));
    const beatmapMap = toRecordMap(
      existingBeatmaps,
      (record) => normalizeInteger(record.osu_beatmap_numeric, 0),
    );

    for (const beatmapSet of beatmapSets) {
      const osuBeatmapSetNumeric = normalizeInteger(beatmapSet.id, 0);
      const creatorUserNumeric = normalizeInteger(beatmapSet.user_id, 0);
      const artistName = normalizeString(beatmapSet.artist);
      const setStatus = normalizeSetStatus(beatmapSet.status);
      const usableBeatmaps = getUsableBeatmaps(beatmapSet);
      const coverUrl = getBestCoverUrl(beatmapSet);

      if (
        !osuBeatmapSetNumeric ||
        !creatorUserNumeric ||
        !artistName ||
        !setStatus ||
        !usableBeatmaps.length
      ) {
        counts.skipped += 1;
        continue;
      }

      counts.processed += 1;

      const beatmapSetPayload = {
        osu_beatmapset_numeric: osuBeatmapSetNumeric,
        title: normalizeString(beatmapSet.title) || 'Unknown beatmap set',
        artist_name: artistName,
        creator_username: normalizeString(beatmapSet.creator) || 'Unknown mapper',
        creator_user_numeric: creatorUserNumeric,
        status: setStatus,
        total_playcount: normalizeInteger(beatmapSet.play_count, 0),
        background_image_url: coverUrl,
        cover_image_url: coverUrl,
        ranked_at: setStatus === 'ranked' ? parseDateOrNull(beatmapSet.ranked_date) : null,
        loved_at: setStatus === 'loved' ? parseDateOrNull(beatmapSet.ranked_date) : null,
        last_synced_at: now,
        updatedById: currentUser.id,
      };

      let beatmapSetRecord = beatmapSetMap.get(osuBeatmapSetNumeric);

      if (beatmapSetRecord) {
        await beatmapSetRecord.update(beatmapSetPayload, { transaction });
        counts.updated += 1;
      } else {
        beatmapSetRecord = await db.beatmap_sets.create(
          {
            ...beatmapSetPayload,
            createdById: currentUser.id,
          },
          { transaction },
        );
        beatmapSetMap.set(osuBeatmapSetNumeric, beatmapSetRecord);
        counts.inserted += 1;
      }

      const mapperPayload = {
        osu_user_numeric: creatorUserNumeric,
        osu_username: normalizeString(beatmapSet.creator) || 'Unknown mapper',
        avatar_url: getMapperAvatarUrl(creatorUserNumeric),
        profile_url: getMapperProfileUrl(creatorUserNumeric),
        banner_url: coverUrl,
        status: 'active',
        last_synced_at: now,
        updatedById: currentUser.id,
      };

      let mapperRecord = mapperMap.get(creatorUserNumeric);

      if (mapperRecord) {
        await mapperRecord.update(mapperPayload, { transaction });
        counts.updated += 1;
      } else {
        mapperRecord = await db.mappers.create(
          {
            ...mapperPayload,
            createdById: currentUser.id,
          },
          { transaction },
        );
        mapperMap.set(creatorUserNumeric, mapperRecord);
        counts.inserted += 1;
      }

      const artistPayload = {
        name: artistName,
        image_source: coverUrl ? 'beatmap_background_fallback' : 'none',
        image_url: coverUrl,
        external_url: null,
        status: 'active',
        last_synced_at: now,
        updatedById: currentUser.id,
      };

      let artistRecord = artistMap.get(artistName);

      if (artistRecord) {
        await artistRecord.update(artistPayload, { transaction });
        counts.updated += 1;
      } else {
        artistRecord = await db.artists.create(
          {
            ...artistPayload,
            createdById: currentUser.id,
          },
          { transaction },
        );
        artistMap.set(artistName, artistRecord);
        counts.inserted += 1;
      }

      for (const beatmap of usableBeatmaps) {
        const osuBeatmapNumeric = normalizeInteger(beatmap.id, 0);
        const beatmapStatus = normalizeSetStatus(beatmap.status) || setStatus;

        if (!osuBeatmapNumeric || !beatmapStatus) {
          counts.skipped += 1;
          continue;
        }

        const beatmapPayload = {
          osu_beatmap_numeric: osuBeatmapNumeric,
          difficulty_name: normalizeString(beatmap.version) || 'Unknown difficulty',
          mode: 'osu',
          status: beatmapStatus,
          playcount: normalizeInteger(beatmap.playcount, 0),
          passcount: normalizeInteger(beatmap.passcount, 0),
          star_rating: normalizeDecimal(beatmap.difficulty_rating, 0),
          bpm: normalizeDecimal(beatmap.bpm || beatmapSet.bpm, 0),
          length_seconds: normalizeInteger(
            beatmap.total_length || beatmap.hit_length,
            0,
          ),
          cs: normalizeDecimal(beatmap.cs, 0),
          ar: normalizeDecimal(beatmap.ar, 0),
          od: normalizeDecimal(beatmap.accuracy, 0),
          hp: normalizeDecimal(beatmap.drain, 0),
          last_synced_at: now,
          beatmap_setId: beatmapSetRecord.id,
          mapperId: mapperRecord.id,
          artistId: artistRecord.id,
          updatedById: currentUser.id,
        };

        let beatmapRecord = beatmapMap.get(osuBeatmapNumeric);

        if (beatmapRecord) {
          await beatmapRecord.update(beatmapPayload, { transaction });
          counts.updated += 1;
        } else {
          beatmapRecord = await db.beatmaps.create(
            {
              ...beatmapPayload,
              createdById: currentUser.id,
            },
            { transaction },
          );
          beatmapMap.set(osuBeatmapNumeric, beatmapRecord);
          counts.inserted += 1;
        }
      }
    }

    await transaction.commit();
    return counts;
  } catch (error) {
    await transaction.rollback();
    throw error;
  }
}

async function recomputeEntityStats({
  entity = 'mapper',
  currentUser,
}) {
  const foreignKey = entity === 'artist' ? 'artistId' : 'mapperId';
  const StatsModel = entity === 'artist' ? db.artist_stats : db.mapper_stats;
  const transaction = await db.sequelize.transaction();
  const now = new Date();

  try {
    const aggregates = await db.beatmaps.findAll({
      attributes: [
        foreignKey,
        [db.sequelize.fn('SUM', db.sequelize.col('playcount')), 'total_plays'],
        [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'ranked_loved_maps_count'],
      ],
      where: {
        [foreignKey]: {
          [Op.ne]: null,
        },
        mode: 'osu',
        status: {
          [Op.in]: ['ranked', 'loved'],
        },
      },
      group: [foreignKey],
      raw: true,
      transaction,
    });

    const entityIds = aggregates
      .map((row) => row[foreignKey])
      .filter(Boolean);

    const existingStats = entityIds.length
      ? await StatsModel.findAll({
          where: {
            [foreignKey]: {
              [Op.in]: entityIds,
            },
          },
          order: [['createdAt', 'ASC'], ['id', 'ASC']],
          transaction,
        })
      : [];

    const existingStatsMap = existingStats.reduce((accumulator, statRecord) => {
      const relatedId = statRecord[foreignKey];

      if (!accumulator.has(relatedId)) {
        accumulator.set(relatedId, []);
      }

      accumulator.get(relatedId).push(statRecord);
      return accumulator;
    }, new Map());

    let inserted = 0;
    let updated = 0;
    let removedDuplicates = 0;

    for (const aggregate of aggregates) {
      const relatedId = aggregate[foreignKey];
      const relatedStats = existingStatsMap.get(relatedId) || [];
      const [primaryRecord, ...duplicateRecords] = relatedStats;
      const payload = {
        total_plays: normalizeInteger(aggregate.total_plays, 0),
        ranked_loved_maps_count: normalizeInteger(
          aggregate.ranked_loved_maps_count,
          0,
        ),
        computed_at: now,
        updatedById: currentUser.id,
      };

      if (primaryRecord) {
        await primaryRecord.update(payload, { transaction });
        updated += 1;
      } else {
        await StatsModel.create(
          {
            ...payload,
            [foreignKey]: relatedId,
            createdById: currentUser.id,
          },
          { transaction },
        );
        inserted += 1;
      }

      for (const duplicateRecord of duplicateRecords) {
        await duplicateRecord.destroy({ transaction });
        removedDuplicates += 1;
      }
    }

    await transaction.commit();

    return {
      inserted,
      updated,
      removedDuplicates,
      processed: aggregates.length,
    };
  } catch (error) {
    await transaction.rollback();
    throw error;
  }
}

module.exports = class OsuCatalogSyncService {
  static isRunnableJobType(jobType) {
    return [
      'osu_sync_seed',
      'osu_sync_incremental',
      'aggregate_mapper_totals',
      'aggregate_artist_totals',
    ].includes(jobType);
  }

  static async run(jobRecord, currentUser) {
    if (!jobRecord?.id) {
      throw syncError('Sync job record is required.');
    }

    if (jobRecord.job_type === 'aggregate_mapper_totals') {
      const mapperTotals = await recomputeEntityStats({
        entity: 'mapper',
        currentUser,
      });

      return {
        processedCount: mapperTotals.processed,
        insertedCount: mapperTotals.inserted,
        updatedCount: mapperTotals.updated,
        skippedCount: 0,
        summary: `Recomputed mapper totals for ${mapperTotals.processed} mappers.`,
      };
    }

    if (jobRecord.job_type === 'aggregate_artist_totals') {
      const artistTotals = await recomputeEntityStats({
        entity: 'artist',
        currentUser,
      });

      return {
        processedCount: artistTotals.processed,
        insertedCount: artistTotals.inserted,
        updatedCount: artistTotals.updated,
        skippedCount: 0,
        summary: `Recomputed artist totals for ${artistTotals.processed} artists.`,
      };
    }

    const importOptions = await getImportOptions(jobRecord);
    const normalizedPayload = {
      ...importOptions.payload,
      pages: importOptions.pages,
      sort: importOptions.sort,
      startPage: importOptions.startPage,
    };

    delete normalizedPayload.page;

    await updateJob(jobRecord.id, currentUser, {
      job_payload_json: JSON.stringify(normalizedPayload),
    });

    const totals = {
      processedCount: 0,
      insertedCount: 0,
      updatedCount: 0,
      skippedCount: 0,
      errorCount: 0,
    };

    for (let pageOffset = 0; pageOffset < importOptions.pages; pageOffset += 1) {
      const page = importOptions.startPage + pageOffset;
      const beatmapSets = await fetchBeatmapSearchPage({
        page,
        sort: importOptions.sort,
      });
      const pageCounts = await upsertBeatmapSetsPage({
        beatmapSets,
        currentUser,
      });

      totals.processedCount += pageCounts.processed;
      totals.insertedCount += pageCounts.inserted;
      totals.updatedCount += pageCounts.updated;
      totals.skippedCount += pageCounts.skipped;

      await updateJob(jobRecord.id, currentUser, {
        processed_count: totals.processedCount,
        inserted_count: totals.insertedCount,
        updated_count: totals.updatedCount,
        skipped_count: totals.skippedCount,
        error_count: totals.errorCount,
      });
    }

    const [mapperTotals, artistTotals] = await Promise.all([
      recomputeEntityStats({ entity: 'mapper', currentUser }),
      recomputeEntityStats({ entity: 'artist', currentUser }),
    ]);

    totals.insertedCount += mapperTotals.inserted + artistTotals.inserted;
    totals.updatedCount += mapperTotals.updated + artistTotals.updated;

    const endPage = importOptions.startPage + importOptions.pages - 1;
    const pageRangeLabel =
      importOptions.pages === 1
        ? `page ${importOptions.startPage}`
        : `pages ${importOptions.startPage}-${endPage}`;
    const nextSyncHint = importOptions.explicitStartPage
      ? ''
      : ` Next automatic sync will continue from page ${endPage + 1}.`;

    return {
      ...totals,
      summary: `Imported ${totals.processedCount} beatmap sets across ${pageRangeLabel} (${importOptions.sort}) and refreshed mapper/artist totals.${nextSyncHint}`,
    };
  }
};
