'use strict';

const _ = require('lodash');
const createStylelint = require('./createStylelint');
const createStylelintResult = require('./createStylelintResult');
const debug = require('debug')('stylelint:standalone');
const fastGlob = require('fast-glob');
const FileCache = require('./utils/FileCache');
const filterFilePaths = require('./utils/filterFilePaths');
const formatters = require('./formatters');
const fs = require('fs');
const getFormatterOptionsText = require('./utils/getFormatterOptionsText');
const globby = require('globby');
const hash = require('./utils/hash');
const NoFilesFoundError = require('./utils/noFilesFoundError');
const path = require('path');
const pkg = require('../package.json');
const prepareReturnValue = require('./prepareReturnValue');
const { default: ignore } = require('ignore');
const DEFAULT_IGNORE_FILENAME = '.stylelintignore';
const FILE_NOT_FOUND_ERROR_CODE = 'ENOENT';
const ALWAYS_IGNORED_GLOBS = ['**/node_modules/**'];
const writeFileAtomic = require('write-file-atomic');

/** @typedef {import('stylelint').StylelintStandaloneOptions} StylelintStandaloneOptions */
/** @typedef {import('stylelint').StylelintStandaloneReturnValue} StylelintStandaloneReturnValue */
/** @typedef {import('stylelint').StylelintResult} StylelintResult */
/** @typedef {import('stylelint').Formatter} Formatter */
/** @typedef {import('stylelint').FormatterIdentifier} FormatterIdentifier */

/**
 * @param {StylelintStandaloneOptions} options
 * @returns {Promise<StylelintStandaloneReturnValue>}
 */
module.exports = function (options) {
	const cacheLocation = options.cacheLocation;
	const code = options.code;
	const codeFilename = options.codeFilename;
	const config = options.config;
	const configBasedir = options.configBasedir;
	const configFile = options.configFile;
	const configOverrides = options.configOverrides;
	const customSyntax = options.customSyntax;
	const globbyOptions = options.globbyOptions;
	const files = options.files;
	const fix = options.fix;
	const formatter = options.formatter;
	const ignoreDisables = options.ignoreDisables;
	const reportNeedlessDisables = options.reportNeedlessDisables;
	const reportInvalidScopeDisables = options.reportInvalidScopeDisables;
	const reportDescriptionlessDisables = options.reportDescriptionlessDisables;
	const syntax = options.syntax;
	const allowEmptyInput = options.allowEmptyInput || false;
	const useCache = options.cache || false;
	/** @type {FileCache} */
	let fileCache;
	const startTime = Date.now();

	// The ignorer will be used to filter file paths after the glob is checked,
	// before any files are actually read
	const ignoreFilePath = options.ignorePath || DEFAULT_IGNORE_FILENAME;
	const absoluteIgnoreFilePath = path.isAbsolute(ignoreFilePath)
		? ignoreFilePath
		: path.resolve(process.cwd(), ignoreFilePath);
	let ignoreText = '';

	try {
		ignoreText = fs.readFileSync(absoluteIgnoreFilePath, 'utf8');
	} catch (readError) {
		if (readError.code !== FILE_NOT_FOUND_ERROR_CODE) throw readError;
	}

	const ignorePattern = options.ignorePattern || [];
	const ignorer = ignore().add(ignoreText).add(ignorePattern);

	const isValidCode = typeof code === 'string';

	if ((!files && !isValidCode) || (files && (code || isValidCode))) {
		throw new Error('You must pass stylelint a `files` glob or a `code` string, though not both');
	}

	/** @type {Formatter} */
	let formatterFunction;

	try {
		formatterFunction = getFormatterFunction(formatter);
	} catch (error) {
		return Promise.reject(error);
	}

	const stylelint = createStylelint({
		config,
		configFile,
		configBasedir,
		configOverrides,
		ignoreDisables,
		ignorePath: ignoreFilePath,
		reportNeedlessDisables,
		reportInvalidScopeDisables,
		reportDescriptionlessDisables,
		syntax,
		customSyntax,
		fix,
	});

	if (!files) {
		const absoluteCodeFilename =
			codeFilename !== undefined && !path.isAbsolute(codeFilename)
				? path.join(process.cwd(), codeFilename)
				: codeFilename;

		// if file is ignored, return nothing
		if (
			absoluteCodeFilename &&
			!filterFilePaths(ignorer, [path.relative(process.cwd(), absoluteCodeFilename)]).length
		) {
			return Promise.resolve(prepareReturnValue([], options, formatterFunction));
		}

		return stylelint
			._lintSource({
				code,
				codeFilename: absoluteCodeFilename,
			})
			.then((postcssResult) => {
				// Check for file existence
				return new Promise((resolve, reject) => {
					if (!absoluteCodeFilename) {
						reject();

						return;
					}

					fs.stat(absoluteCodeFilename, (err) => {
						if (err) {
							reject();
						} else {
							resolve();
						}
					});
				})
					.then(() => {
						return stylelint._createStylelintResult(postcssResult, absoluteCodeFilename);
					})
					.catch(() => {
						return stylelint._createStylelintResult(postcssResult);
					});
			})
			.catch(_.partial(handleError, stylelint))
			.then((stylelintResult) => {
				const postcssResult = stylelintResult._postcssResult;
				const returnValue = prepareReturnValue([stylelintResult], options, formatterFunction);

				if (options.fix && postcssResult && !postcssResult.stylelint.ignored) {
					if (!postcssResult.stylelint.disableWritingFix) {
						// If we're fixing, the output should be the fixed code
						returnValue.output = postcssResult.root.toString(postcssResult.opts.syntax);
					} else {
						// If the writing of the fix is disabled, the input code is returned as-is
						returnValue.output = code;
					}
				}

				return returnValue;
			});
	}

	let fileList = files;

	if (typeof fileList === 'string') {
		fileList = [fileList];
	}

	fileList = fileList.map((entry) => {
		if (globby.hasMagic(entry)) {
			const cwd = _.get(globbyOptions, 'cwd', process.cwd());
			const absolutePath = !path.isAbsolute(entry) ? path.join(cwd, entry) : path.normalize(entry);

			if (fs.existsSync(absolutePath)) {
				// This glob-like path points to a file. Return an escaped path to avoid globbing
				return fastGlob.escapePath(entry);
			}
		}

		return entry;
	});

	if (!options.disableDefaultIgnores) {
		fileList = fileList.concat(ALWAYS_IGNORED_GLOBS.map((glob) => `!${glob}`));
	}

	if (useCache) {
		const stylelintVersion = pkg.version;
		const hashOfConfig = hash(`${stylelintVersion}_${JSON.stringify(config || {})}`);

		fileCache = new FileCache(cacheLocation, hashOfConfig);
	} else {
		// No need to calculate hash here, we just want to delete cache file.
		fileCache = new FileCache(cacheLocation);
		// Remove cache file if cache option is disabled
		fileCache.destroy();
	}

	return globby(fileList, globbyOptions)
		.then((filePaths) => {
			// The ignorer filter needs to check paths relative to cwd
			filePaths = filterFilePaths(
				ignorer,
				filePaths.map((p) => path.relative(process.cwd(), p)),
			);

			if (!filePaths.length) {
				if (!allowEmptyInput) {
					throw new NoFilesFoundError(fileList);
				}

				return Promise.all([]);
			}

			const cwd = _.get(globbyOptions, 'cwd', process.cwd());
			let absoluteFilePaths = filePaths.map((filePath) => {
				const absoluteFilepath = !path.isAbsolute(filePath)
					? path.join(cwd, filePath)
					: path.normalize(filePath);

				return absoluteFilepath;
			});

			if (useCache) {
				absoluteFilePaths = absoluteFilePaths.filter(fileCache.hasFileChanged.bind(fileCache));
			}

			const getStylelintResults = absoluteFilePaths.map((absoluteFilepath) => {
				debug(`Processing ${absoluteFilepath}`);

				return stylelint
					._lintSource({
						filePath: absoluteFilepath,
					})
					.then((postcssResult) => {
						if (postcssResult.stylelint.stylelintError && useCache) {
							debug(`${absoluteFilepath} contains linting errors and will not be cached.`);
							fileCache.removeEntry(absoluteFilepath);
						}

						/**
						 * If we're fixing, save the file with changed code
						 * @type {Promise<Error | void>}
						 */
						let fixFile = Promise.resolve();

						if (
							postcssResult.root &&
							postcssResult.opts &&
							!postcssResult.stylelint.ignored &&
							options.fix &&
							!postcssResult.stylelint.disableWritingFix
						) {
							// @ts-ignore TODO TYPES toString accepts 0 arguments
							const fixedCss = postcssResult.root.toString(postcssResult.opts.syntax);

							if (
								postcssResult.root &&
								postcssResult.root.source &&
								// @ts-ignore TODO TYPES css is unknown property
								postcssResult.root.source.input.css !== fixedCss
							) {
								fixFile = writeFileAtomic(absoluteFilepath, fixedCss);
							}
						}

						return fixFile.then(() =>
							stylelint._createStylelintResult(postcssResult, absoluteFilepath),
						);
					})
					.catch((error) => {
						// On any error, we should not cache the lint result
						fileCache.removeEntry(absoluteFilepath);

						return handleError(stylelint, error, absoluteFilepath);
					});
			});

			return Promise.all(getStylelintResults);
		})
		.then((stylelintResults) => {
			if (useCache) {
				fileCache.reconcile();
			}

			const rtn = prepareReturnValue(stylelintResults, options, formatterFunction);

			debug(`Linting complete in ${Date.now() - startTime}ms`);

			return rtn;
		});
};

/**
 * @param {FormatterIdentifier | undefined} selected
 * @returns {Formatter}
 */
function getFormatterFunction(selected) {
	/** @type {Formatter} */
	let formatterFunction;

	if (typeof selected === 'string') {
		formatterFunction = formatters[selected];

		if (formatterFunction === undefined) {
			throw new Error(
				`You must use a valid formatter option: ${getFormatterOptionsText()} or a function`,
			);
		}
	} else if (typeof selected === 'function') {
		formatterFunction = selected;
	} else {
		formatterFunction = formatters.json;
	}

	return formatterFunction;
}

/**
 * @param {import('stylelint').StylelintInternalApi} stylelint
 * @param {any} error
 * @param {string} [filePath]
 * @return {Promise<StylelintResult>}
 */
function handleError(stylelint, error, filePath = undefined) {
	if (error.name === 'CssSyntaxError') {
		return createStylelintResult(stylelint, undefined, filePath, error);
	}

	throw error;
}
