import { getRawgApiKey } from '../config/variables';
import { getDataSource } from '../db/database';
import { GameMatchMissEntity } from '../entities';

export interface MatchedGame {
  id: string;
  name: string;
  iconUrl?: string;
  store?: GameStoreLink;
  processName: string;
}

export interface GameStoreLink {
  id?: string;
  name: string;
  slug?: string;
  domain?: string;
  url: string;
}

interface CacheEntry {
  expiresAt: number;
  game: Omit<MatchedGame, 'processName'> | null;
}

interface RawgSearchResponse {
  results?: RawgGameResult[];
}

interface RawgGameResult {
  id?: number;
  name?: string;
  background_image?: string | null;
  slug?: string;
  stores?: RawgStoreEntry[] | null;
}

interface RawgStoreEntry {
  url?: string | null;
  store?: RawgStore | null;
}

interface RawgStore {
  id?: number;
  name?: string;
  slug?: string;
  domain?: string | null;
}

interface CandidateProcess {
  processName: string;
  score: number;
}

interface GameMatchResult {
  games: MatchedGame[];
  rateLimited?: boolean;
}

interface RawgLookupBudget {
  used: number;
  windowStartedAt: number;
}

const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const PERSISTED_MISS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const RAWG_LOOKUP_WINDOW_MS = 60 * 60 * 1000;
const RAWG_SEARCH_TIMEOUT_MS = 4_000;
const MAX_INCOMING_PROCESSES = 256;
const MAX_CANDIDATE_PROCESSES = 24;
const MAX_UNCACHED_LOOKUPS_PER_REQUEST = 4;
const MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW = 8;
const RAWG_SEARCH_URL = 'https://api.rawg.io/api/games';
const MIN_SEARCH_QUERY_LENGTH = 4;
const IGNORED_PROCESS_NAMES = new Set([
  'agent',
  'bash',
  'baloorunner',
  'chrome',
  'code',
  'conhost',
  'cursor',
  'csrss',
  'dbus-daemon',
  'discord',
  'dwm',
  'electron',
  'explorer',
  'firefox',
  'gameoverlayui',
  'gamemoded',
  'gamescopereaper',
  'gnome-shell',
  'init',
  'kernel_task',
  'metoyou',
  'nvidia-settings',
  'node',
  'npm',
  'obs',
  'powershell',
  'pulseaudio',
  'services',
  'steam',
  'steamwebhelper',
  'system',
  'systemd',
  'taskhostw',
  'wininit',
  'winlogon',
  'xorg'
]);
const IGNORED_PROCESS_PATTERNS = [
  new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
  new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
  new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
  new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
  /^(appimage|at-spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
  /^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
  /(helper|service|daemon|runner|tracker|portal|updater|worker)$/
];
const STORE_SEARCH_URL_BUILDERS: Record<string, (query: string) => string> = {
  steam: (query) => `https://store.steampowered.com/search/?term=${query}`,
  'epic-games': (query) => `https://store.epicgames.com/en-US/browse?q=${query}`,
  gog: (query) => `https://www.gog.com/en/games?query=${query}`,
  itch: (query) => `https://itch.io/search?q=${query}`,
  'xbox-store': (query) => `https://www.xbox.com/search?q=${query}`,
  'playstation-store': (query) => `https://store.playstation.com/search/${query}`,
  nintendo: (query) => `https://www.nintendo.com/search/#q=${query}`,
  'apple-appstore': (query) => `https://apps.apple.com/us/search?term=${query}`,
  'google-play': (query) => `https://play.google.com/store/search?q=${query}&c=apps`
};
const STORE_SEARCH_ALIASES = new Map<string, string>([
  ['steam', 'steam'],
  ['store.steampowered.com', 'steam'],
  ['epic-games', 'epic-games'],
  ['store.epicgames.com', 'epic-games'],
  ['gog', 'gog'],
  ['www.gog.com', 'gog'],
  ['gog.com', 'gog'],
  ['itch', 'itch'],
  ['itch.io', 'itch'],
  ['xbox-store', 'xbox-store'],
  ['www.xbox.com', 'xbox-store'],
  ['xbox.com', 'xbox-store'],
  ['playstation-store', 'playstation-store'],
  ['store.playstation.com', 'playstation-store'],
  ['nintendo', 'nintendo'],
  ['www.nintendo.com', 'nintendo'],
  ['nintendo.com', 'nintendo'],
  ['apple-appstore', 'apple-appstore'],
  ['apps.apple.com', 'apple-appstore'],
  ['google-play', 'google-play'],
  ['play.google.com', 'google-play']
]);
const STORE_PRIORITY = new Map<string, number>([
  ['steam', 0],
  ['gog', 10],
  ['epic-games', 20],
  ['itch', 30],
  ['xbox-store', 80],
  ['playstation-store', 90]
]);
const cache = new Map<string, CacheEntry>();
const rawgLookupBudgets = new Map<string, RawgLookupBudget>();

export async function matchRunningGames(
  processNames: unknown,
  requester: unknown = 'anonymous'
): Promise<GameMatchResult> {
  const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES);
  const matches: MatchedGame[] = [];
  const seenGameIds = new Set<string>();
  const requesterKey = normalizeRequesterKey(requester);
  const persistedMisses = await loadPersistedMissKeys(candidates.map((candidate) => candidate.processName));

  let uncachedLookups = 0;
  let rateLimited = false;

  for (const { processName } of candidates) {
    const cacheKey = normalizeCacheKey(processName);
    const cached = getCachedGame(cacheKey);

    if (cached !== undefined) {
      appendMatch(matches, seenGameIds, processName, cached);
      continue;
    }

    if (persistedMisses.has(cacheKey)) {
      setCachedGame(cacheKey, null);
      continue;
    }

    if (uncachedLookups >= MAX_UNCACHED_LOOKUPS_PER_REQUEST) {
      rateLimited = true;
      continue;
    }

    if (!tryConsumeRawgLookup(requesterKey)) {
      rateLimited = true;
      continue;
    }

    uncachedLookups += 1;

    const game = await resolveRawgGame(processName);

    setCachedGame(cacheKey, game);

    if (!game) {
      await rememberPersistedMiss(cacheKey, processName);
    }

    appendMatch(matches, seenGameIds, processName, game);
  }

  return {
    games: matches,
    rateLimited: rateLimited || undefined
  };
}

function normalizeProcessList(value: unknown): CandidateProcess[] {
  if (!Array.isArray(value)) {
    return [];
  }

  const processes = new Map<string, CandidateProcess>();

  for (const entry of value.slice(0, MAX_INCOMING_PROCESSES)) {
    const processName = normalizeProcessName(entry);

    if (processName) {
      const cacheKey = normalizeCacheKey(processName);

      if (!processes.has(cacheKey)) {
        processes.set(cacheKey, {
          processName,
          score: scoreCandidateProcess(String(entry), processName)
        });
      }
    }
  }

  return Array.from(processes.values())
    .sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
}

function normalizeProcessName(value: unknown): string {
  if (typeof value !== 'string') {
    return '';
  }

  const normalized = value
    .trim()
    .replace(/\.exe$/i, '')
    .replace(/[_-]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  const cacheKey = normalizeCacheKey(normalized);

  if (normalized.length < 3 || normalized.length > 96 || shouldIgnoreProcessName(cacheKey)) {
    return '';
  }

  return normalized;
}

function shouldIgnoreProcessName(cacheKey: string): boolean {
  return IGNORED_PROCESS_NAMES.has(cacheKey)
    || IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
}

function normalizeRequesterKey(value: unknown): string {
  if (typeof value !== 'string') {
    return 'anonymous';
  }

  const normalized = value.trim().toLowerCase();

  return normalized || 'anonymous';
}

function tryConsumeRawgLookup(requesterKey: string): boolean {
  const now = Date.now();
  const existing = rawgLookupBudgets.get(requesterKey);

  if (!existing || existing.windowStartedAt + RAWG_LOOKUP_WINDOW_MS <= now) {
    rawgLookupBudgets.set(requesterKey, {
      used: 1,
      windowStartedAt: now
    });

    return true;
  }

  if (existing.used >= MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW) {
    return false;
  }

  existing.used += 1;

  return true;
}

function scoreCandidateProcess(rawValue: string, processName: string): number {
  let score = 0;

  if (/\.exe$/i.test(rawValue.trim())) {
    score += 12;
  }

  if (/[A-Z]/.test(processName) && /[a-z]/.test(processName)) {
    score += 4;
  }

  if (/\d/.test(processName)) {
    score += 1;
  }

  if (processName.length >= 5 && processName.length <= 32) {
    score += 2;
  }

  if (processName.includes(' ')) {
    score -= 2;
  }

  return score;
}

function normalizeCacheKey(value: string): string {
  return value.trim()
    .toLowerCase()
    .replace(/\s+/g, ' ');
}

function getCachedGame(cacheKey: string): Omit<MatchedGame, 'processName'> | null | undefined {
  const cached = cache.get(cacheKey);

  if (!cached) {
    return undefined;
  }

  if (cached.expiresAt <= Date.now()) {
    cache.delete(cacheKey);
    return undefined;
  }

  return cached.game;
}

function setCachedGame(cacheKey: string, game: Omit<MatchedGame, 'processName'> | null): void {
  cache.set(cacheKey, {
    expiresAt: Date.now() + CACHE_TTL_MS,
    game
  });
}

async function loadPersistedMissKeys(processNames: string[]): Promise<Set<string>> {
  const cacheKeys = Array.from(new Set(processNames.map((name) => normalizeCacheKey(name))));

  if (cacheKeys.length === 0) {
    return new Set();
  }

  try {
    const repository = getDataSource().getRepository(GameMatchMissEntity);
    const now = Date.now();

    await repository.createQueryBuilder()
      .delete()
      .where('expiresAt <= :now', { now })
      .execute();

    const rows = await repository.createQueryBuilder('miss')
      .select('miss.processKey')
      .where('miss.processKey IN (:...cacheKeys)', { cacheKeys })
      .andWhere('miss.expiresAt > :now', { now })
      .getMany();

    return new Set(rows.map((row) => row.processKey));
  } catch {
    return new Set();
  }
}

async function rememberPersistedMiss(cacheKey: string, processName: string): Promise<void> {
  try {
    const now = Date.now();

    await getDataSource().getRepository(GameMatchMissEntity)
      .save({
        processKey: cacheKey,
        processName,
        missedAt: now,
        expiresAt: now + PERSISTED_MISS_TTL_MS
      });
  } catch {
    return;
  }
}

async function resolveRawgGame(processName: string): Promise<Omit<MatchedGame, 'processName'> | null> {
  const apiKey = getRawgApiKey();

  if (!apiKey) {
    return null;
  }

  const query = buildSearchQuery(processName);

  if (!query) {
    return null;
  }

  const url = new URL(RAWG_SEARCH_URL);

  url.searchParams.set('key', apiKey);
  url.searchParams.set('search', query);
  url.searchParams.set('search_precise', 'true');
  url.searchParams.set('exclude_additions', 'true');
  url.searchParams.set('page_size', '1');

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), RAWG_SEARCH_TIMEOUT_MS);

  try {
    const response = await fetch(url, { signal: controller.signal });

    if (!response.ok) {
      return null;
    }

    const body = await response.json() as RawgSearchResponse;
    const result = body.results?.[0];

    if (!isAcceptableRawgMatch(query, result)) {
      return null;
    }

    return {
      id: String(result.id),
      name: result.name.trim(),
      iconUrl: result.background_image || undefined,
      store: selectPreferredStore(result, result.name.trim())
    };
  } catch {
    return null;
  } finally {
    clearTimeout(timeout);
  }
}

function selectPreferredStore(result: RawgGameResult, gameName: string): GameStoreLink | undefined {
  const stores = Array.isArray(result.stores) ? result.stores : [];
  const usableStores = stores
    .map((entry) => buildStoreLink(entry, gameName))
    .filter((store): store is GameStoreLink => !!store);

  return usableStores.sort((left, right) => getStorePriority(left) - getStorePriority(right))[0];
}

function getStorePriority(store: GameStoreLink): number {
  const storeKey = STORE_SEARCH_ALIASES.get(store.slug ?? '')
    ?? STORE_SEARCH_ALIASES.get(store.domain ?? '')
    ?? store.name.trim().toLowerCase();

  return STORE_PRIORITY.get(storeKey) ?? 50;
}

function buildStoreLink(entry: RawgStoreEntry, gameName: string): GameStoreLink | undefined {
  const store = entry.store;

  if (!store || typeof store.name !== 'string' || !store.name.trim()) {
    return undefined;
  }

  const slug = typeof store.slug === 'string' && store.slug.trim()
    ? store.slug.trim().toLowerCase()
    : undefined;
  const domain = typeof store.domain === 'string' && store.domain.trim()
    ? store.domain.trim()
      .replace(/^https?:\/\//i, '')
      .replace(/\/$/, '')
    : undefined;
  const url = normalizeExternalUrl(entry.url) ?? buildStoreSearchUrl(slug, domain, gameName);

  if (!url) {
    return undefined;
  }

  return {
    id: typeof store.id === 'number' ? String(store.id) : undefined,
    name: store.name.trim(),
    slug,
    domain,
    url
  };
}

function normalizeExternalUrl(value: unknown): string | undefined {
  if (typeof value !== 'string' || !value.trim()) {
    return undefined;
  }

  const trimmed = value.trim();

  return trimmed.startsWith('http://') || trimmed.startsWith('https://')
    ? trimmed
    : undefined;
}

function buildStoreSearchUrl(slug: string | undefined, domain: string | undefined, gameName: string): string | undefined {
  const query = encodeURIComponent(gameName);
  const storeKey = STORE_SEARCH_ALIASES.get(slug ?? '') ?? STORE_SEARCH_ALIASES.get(domain ?? '');
  const buildUrl = storeKey ? STORE_SEARCH_URL_BUILDERS[storeKey] : undefined;

  return buildUrl?.(query) ?? (domain ? `https://${domain}` : undefined);
}

function buildSearchQuery(processName: string): string {
  const query = processName
    .replace(/\.exe$/i, '')
    .replace(/\b(x64|x86|win64|win32|linux|shipping|client|launcher|game)\b/gi, ' ')
    .replace(/\s+/g, ' ')
    .trim();

  return query.length >= MIN_SEARCH_QUERY_LENGTH ? query : '';
}

function isAcceptableRawgMatch(
  query: string,
  result: RawgGameResult | undefined
): result is Required<Pick<RawgGameResult, 'id' | 'name'>> & RawgGameResult {
  if (!result || typeof result.id !== 'number' || typeof result.name !== 'string' || !result.name.trim()) {
    return false;
  }

  const queryKey = normalizeComparableText(query);
  const nameKey = normalizeComparableText(result.name);
  const slugKey = normalizeComparableText(result.slug ?? '');
  const queryTokens = tokenizeComparableText(queryKey);
  const nameTokens = tokenizeComparableText(nameKey);
  const slugTokens = tokenizeComparableText(slugKey);

  if (queryKey.length < MIN_SEARCH_QUERY_LENGTH || queryTokens.length === 0) {
    return false;
  }

  if (queryKey === nameKey || queryKey === slugKey) {
    return true;
  }

  if (queryTokens.length === 1) {
    const [queryToken] = queryTokens;

    return queryToken.length >= 5
      && (nameTokens.includes(queryToken) || slugTokens.includes(queryToken));
  }

  return queryTokens.every((token) => nameTokens.includes(token) || slugTokens.includes(token));
}

function normalizeComparableText(value: string): string {
  return value.toLowerCase()
    .replace(/[^a-z0-9]+/g, ' ')
    .trim();
}

function tokenizeComparableText(value: string): string[] {
  return value.split(' ')
    .filter((token) => token.length >= 2);
}

function appendMatch(
  matches: MatchedGame[],
  seenGameIds: Set<string>,
  processName: string,
  game: Omit<MatchedGame, 'processName'> | null
): void {
  if (!game || seenGameIds.has(game.id)) {
    return;
  }

  seenGameIds.add(game.id);
  matches.push({
    ...game,
    processName
  });
}
