import { dirname } from 'node:path';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFile } from 'fs/promises';
import { createHash } from 'crypto';
import { DEFAULT_CONFIG } from './defaultConfig.js';
import fs, { readFileSync } from 'fs';
import logger from './services/logger.js';
import { packageUp } from 'package-up';

const RE_GT = />/g;
const RE_WEBP = /\/format\/webp/gi;
const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i;
const HTTPS_PREFIX = 'https://';
const providersDirectoryPath = `${getDirName()}/provider`;

/**
 * Lazily load all provider modules from the provider directory.
 * Caches the resolved array to avoid re-importing on subsequent calls.
 *
 * @returns {Promise<any[]>} A list of loaded provider modules.
 */
let cachedProvidersPromise = null;

export function getProviders() {
  if (!cachedProvidersPromise) {
    /** @type {string[]} */
    const providerFileNames = fs.readdirSync(providersDirectoryPath).filter((fileName) => fileName.endsWith('.js'));
    cachedProvidersPromise = Promise.all(
      providerFileNames.map((fileName) => import(pathToFileURL(path.join(providersDirectoryPath, fileName)).href)),
    );
  }
  return cachedProvidersPromise;
}

/**
 * Safely stringify a value to JSON for storage.
 * - Returns null when the input is null or undefined.
 * - Uses JSON.stringify directly otherwise.
 *
 * @template T
 * @param {T} v - Any JSON-serializable value.
 * @returns {string|null} JSON string or null.
 */
const toJson = (v) => (v == null ? null : JSON.stringify(v));

/**
 * Safely parse JSON text coming from storage.
 * - Returns the provided fallback when input is null/undefined.
 * - Returns the fallback when parsing fails.
 *
 * @template T
 * @param {string|null|undefined} txt - JSON text from DB/storage.
 * @param {T} fallback - Value to return when txt is null/invalid.
 * @returns {T} Parsed value or fallback.
 */
const fromJson = (txt, fallback) => {
  if (txt == null) return fallback;
  try {
    return JSON.parse(txt);
  } catch {
    return fallback;
  }
};

/**
 * Determine if the current process runs in development mode.
 * Returns true when NODE_ENV is not 'production'.
 * @returns {boolean}
 */
function inDevMode() {
  return process.env.NODE_ENV == null || process.env.NODE_ENV !== 'production';
}

/**
 * Check if a word contains any of the strings in the given array (case-insensitive, substring match).
 * @param {string} word
 * @param {string[]} arr
 * @returns {boolean}
 */
function isOneOf(word, arr) {
  if (!arr || arr.length === 0 || word == null) return false;
  const lowerWord = word.toLowerCase();
  return arr.some((item) => lowerWord.indexOf(item.toLowerCase()) !== -1);
}

/**
 * Check if a value is null or an empty string/array.
 * @param {any} val
 * @returns {boolean}
 */
function nullOrEmpty(val) {
  return val == null || val.length === 0;
}

/**
 * Convert a day time string (HH:mm) to epoch milliseconds for the given reference date.
 * @param {string} timeString - Format HH:mm
 * @param {number} now - Epoch ms used as the date basis
 * @returns {number}
 */
function timeStringToMs(timeString, now) {
  const d = new Date(now);
  const parts = timeString.split(':');
  d.setHours(parts[0]);
  d.setMinutes(parts[1]);
  d.setSeconds(0);
  return d.getTime();
}

/**
 * Determine whether the given timestamp is within the configured working hours, or return true when the window is not set.
 * - If workingHours is missing or either 'from' or 'to' is empty/null, returns true.
 * - Supports windows that cross midnight (e.g., from '23:00' to '06:00').
 *
 * Time parsing is based on the local timezone of the running process.
 *
 * @param {{workingHours?: {from?: string|null, to?: string|null}}} config - Configuration object containing working hours in 'HH:mm' format.
 * @param {number} now - Epoch milliseconds to evaluate.
 * @returns {boolean} True when execution is allowed at 'now'.
 * @example
 * // Same-day window
 * duringWorkingHoursOrNotSet({ workingHours: { from: '08:00', to: '17:00' } }, someTime);
 * @example
 * // Window crossing midnight
 * // For { from: '05:00', to: '00:30' } → 23:00 => true, 01:00 => false, 06:00 => true
 * duringWorkingHoursOrNotSet({ workingHours: { from: '05:00', to: '00:30' } }, Date.now());
 */
function duringWorkingHoursOrNotSet(config, now) {
  const { workingHours } = config;
  if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
    return true;
  }
  const toDate = timeStringToMs(workingHours.to, now);
  const fromDate = timeStringToMs(workingHours.from, now);

  // If parsing fails (e.g., malformed time), be lenient and allow.
  if (isNaN(toDate) || isNaN(fromDate)) {
    return true;
  }

  if (toDate >= fromDate) {
    // Same-day window (e.g., 08:00 - 17:00)
    return now >= fromDate && now <= toDate;
  }

  // Window crosses midnight (e.g., 05:00 -> 00:30 next day)
  // Accept if we are after 'from' today OR before 'to' today (which represents next day's cutoff).
  return now >= fromDate || now <= toDate;
}

/**
 * Return the directory name of the current module (ESM equivalent of __dirname).
 * @returns {string}
 */
function getDirName() {
  return dirname(fileURLToPath(import.meta.url));
}

/**
 * Build a sha256 hash string from the provided inputs (ignores null/empty strings).
 * Returns null if there are no valid inputs.
 * @param {...(string|null|undefined)} inputs
 * @returns {string|null}
 */
function buildHash(...inputs) {
  if (inputs == null) {
    return null;
  }
  const cleaned = inputs.filter((i) => i != null && i.length > 0);
  if (cleaned.length === 0) {
    return null;
  }
  return createHash('sha256').update(cleaned.join(',')).digest('hex');
}

/**
 * The in-memory configuration object. Call refreshConfig() to populate/update.
 * @type {any}
 */
let config = {};

/**
 * Read config JSON from disk (conf/config.json) and parse it.
 * @returns {Promise<any>} Parsed configuration object.
 */
export async function readConfigFromStorage() {
  return JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
}

/**
 * Refresh the in-memory config, ensuring the file exists and setting backward-compatible defaults.
 * Populates defaults for analyticsEnabled, demoMode, sqlitepath when missing.
 * @returns {Promise<void>}
 */
export async function refreshConfig() {
  checkIfConfigExistsAndWriteIfNot();

  try {
    config = await readConfigFromStorage();
    //backwards compatibility...
    config.analyticsEnabled ??= null;
    config.demoMode ??= false;
    // default sqlitepath when missing in older configs
    config.sqlitepath ??= '/db';
  } catch (error) {
    config = { ...DEFAULT_CONFIG };
    logger.info('Error reading config file.', error);
  }
}

/**
 * If the config file does not exist, create it with DEFAULT_CONFIG.
 * @returns {void}
 */
const checkIfConfigExistsAndWriteIfNot = () => {
  if (!fs.existsSync(`${getDirName()}/../conf/config.json`)) {
    logger.info('Could not find config file. Will create one with default values now');
    fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...DEFAULT_CONFIG }));
  }
};

/**
 * Normalize image URLs:
 * - Trim, remove stray '>' characters.
 * - Convert '/format/webp' segments to '/format/jpg'.
 * - Enforce HTTPS and ensure a valid image extension (jpg/png/gif). If URL contains '.jpg' without query, cut trailing parts.
 * - Return null for invalid inputs.
 * @param {string} url
 * @returns {string|null}
 */
const normalizeImageUrl = (url) => {
  if (typeof url !== 'string' || url.length === 0) return null;

  let u = url.trim().replace(RE_GT, '');
  if (RE_WEBP.test(u)) u = u.replace(RE_WEBP, '/format/jpg');
  if (!u.startsWith(HTTPS_PREFIX)) return null;
  if (!RE_EXT.test(u)) {
    const jpgIdx = u.toLowerCase().lastIndexOf('.jpg');
    if (jpgIdx > -1) u = u.slice(0, jpgIdx + 4);
  }
  return u;
};

/**
 * returns Fredy's version
 * @returns {Promise<*|string>}
 */
async function getPackageVersion() {
  try {
    const packagePath = await packageUp();
    const packageJson = readFileSync(packagePath, 'utf8');
    const json = JSON.parse(packageJson);
    return json.version;
  } catch (error) {
    logger.error('Error reading version from package.json', error);
  }
  return 'N/A';
}

/**
 * Sleep helper
 * @param {number} ms milliseconds to wait
 * @returns {Promise<void>}
 */
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Return a random integer between min and max (inclusive).
 * @param {number} min - Minimum integer value.
 * @param {number} max - Maximum integer value.
 * @returns {number} A random integer N where min <= N <= max.
 */
function randomBetween(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

// Call refreshConfig() from the application entrypoint during startup to populate config.
await refreshConfig();

export {
  isOneOf,
  normalizeImageUrl,
  inDevMode,
  nullOrEmpty,
  duringWorkingHoursOrNotSet,
  getDirName,
  sleep,
  randomBetween,
  config,
  buildHash,
  getPackageVersion,
  toJson,
  fromJson,
};
