import { relative, sep } from 'node:path';
import process from 'node:process';

import picocolors from 'picocolors';
import stringWidth from 'string-width';
import table from 'table';

import { assertNumber } from '../utils/validateTypes.mjs';
import calcSeverityCounts from './calcSeverityCounts.mjs';
import isUnicodeSupported from '../utils/isUnicodeSupported.mjs';
import pluralize from '../utils/pluralize.mjs';
import preprocessWarnings from './preprocessWarnings.mjs';
import terminalLink from './terminalLink.mjs';

const { yellow, dim, underline, blue, red, green } = picocolors;

const NON_ASCII_PATTERN = /\P{ASCII}/u;
const MARGIN_WIDTHS = 9;

/**
 * @param {string} s
 * @returns {string}
 */
function identity(s) {
	return s;
}

const levelColors = {
	info: blue,
	warning: yellow,
	error: red,
	success: identity,
};

const supportsUnicode = isUnicodeSupported();

const symbols = {
	info: blue(supportsUnicode ? 'ℹ' : 'i'),
	warning: yellow(supportsUnicode ? '⚠' : '‼'),
	error: red(supportsUnicode ? '✖' : '×'),
	success: green(supportsUnicode ? '✔' : '√'),
};

/**
 * @param {import('stylelint').LintResult[]} results
 * @returns {string}
 */
function deprecationsFormatter(results) {
	const allDeprecationWarnings = results.flatMap((result) => result.deprecations || []);

	if (allDeprecationWarnings.length === 0) {
		return '';
	}

	const seenText = new Set();
	const lines = [];

	for (const { text, reference } of allDeprecationWarnings) {
		if (seenText.has(text)) continue;

		seenText.add(text);

		let line = ` ${dim('-')} ${text}`;

		if (reference) {
			line += dim(` See: ${underline(reference)}`);
		}

		lines.push(line);
	}

	return ['', yellow('Deprecation warnings:'), ...lines, ''].join('\n');
}

/**
 * @param {import('stylelint').LintResult[]} results
 * @returns {string}
 */
function invalidOptionsFormatter(results) {
	const allInvalidOptionWarnings = results.flatMap((result) =>
		(result.invalidOptionWarnings || []).map((warning) => warning.text),
	);
	const uniqueInvalidOptionWarnings = [...new Set(allInvalidOptionWarnings)];

	return uniqueInvalidOptionWarnings.reduce((output, warning) => {
		output += red('Invalid Option: ');
		output += warning;

		return `${output}\n`;
	}, '\n');
}

/**
 * @param {string} fromValue
 * @param {string} cwd
 * @returns {string}
 */
function logFrom(fromValue, cwd) {
	if (fromValue.startsWith('<')) {
		return underline(fromValue);
	}

	const filePath = relative(cwd, fromValue).split(sep).join('/');

	return terminalLink(filePath, `file://${fromValue}`);
}

/**
 * @param {{[k: number]: number}} columnWidths
 * @returns {number}
 */
function getMessageWidth(columnWidths) {
	const width = columnWidths[3];

	assertNumber(width);

	if (!process.stdout.isTTY) {
		return width;
	}

	const availableWidth = process.stdout.columns < 80 ? 80 : process.stdout.columns;
	const fullWidth = Object.values(columnWidths).reduce((a, b) => a + b);

	// If there is no reason to wrap the text, we won't align the last column to the right
	if (availableWidth > fullWidth + MARGIN_WIDTHS) {
		return width;
	}

	return availableWidth - (fullWidth - width + MARGIN_WIDTHS);
}

/**
 * @param {import('stylelint').Warning[]} messages
 * @param {string} source
 * @param {string} cwd
 * @returns {string}
 */
function formatter(messages, source, cwd) {
	if (messages.length === 0) return '';

	/**
	 * Create a list of column widths, needed to calculate
	 * the size of the message column and if needed wrap it.
	 * @type {{[k: string]: number}}
	 */
	const columnWidths = { 0: 1, 1: 1, 2: 1, 3: 1, 4: 1 };

	/**
	 * @param {[string, string, string, string, string]} columns
	 * @returns {[string, string, string, string, string]}
	 */
	function calculateWidths(columns) {
		for (const [key, value] of Object.entries(columns)) {
			const normalisedValue = value ? value.toString() : value;
			const width = columnWidths[key];

			assertNumber(width);
			columnWidths[key] = Math.max(width, stringWidth(normalisedValue));
		}

		return columns;
	}

	let output = '\n';

	if (source) {
		output += `${logFrom(source, cwd)}\n`;
	}

	/**
	 * @param {import('stylelint').Warning} message
	 * @returns {string}
	 */
	function formatMessageText(message) {
		let result = message.text;

		result = result
			// Remove all control characters (newline, tab and etc)
			.replace(/[\u0001-\u001A]+/g, ' ') // eslint-disable-line no-control-regex
			.replace(/\.$/, '');

		const ruleString = ` (${message.rule})`;

		if (result.endsWith(ruleString)) {
			result = result.slice(0, result.lastIndexOf(ruleString));
		}

		return result;
	}

	const cleanedMessages = messages.map((message) => {
		const { line, column, severity } = message;
		/**
		 * @type {[string, string, string, string, string]}
		 */
		const row = [
			line ? line.toString() : '',
			column ? column.toString() : '',
			symbols[severity] ? levelColors[severity](symbols[severity]) : severity,
			formatMessageText(message),
			dim(message.rule || ''),
		];

		calculateWidths(row);

		return row;
	});

	const messageWidth = getMessageWidth(columnWidths);
	const hasNonAsciiChar = messages.some((msg) => NON_ASCII_PATTERN.test(msg.text));

	output += table
		.table(cleanedMessages, {
			border: table.getBorderCharacters('void'),
			columns: {
				0: { alignment: 'right', width: columnWidths[0], paddingRight: 0, paddingLeft: 2 },
				1: { alignment: 'left', width: columnWidths[1] },
				2: { alignment: 'center', width: columnWidths[2] },
				3: {
					alignment: 'left',
					width: messageWidth,
					wrapWord: messageWidth > 1 && !hasNonAsciiChar,
				},
				4: { alignment: 'left', width: columnWidths[4], paddingRight: 0 },
			},
			drawHorizontalLine: () => false,
		})
		.split('\n')
		.map((el) => el.replace(/(\d+)\s+(\d+)/, (_m, p1, p2) => dim(`${p1}:${p2}`)).trimEnd())
		.join('\n');

	return output;
}

/**
 * @type {import('stylelint').Formatter}
 */
export default function stringFormatter(results, returnValue) {
	let output = invalidOptionsFormatter(results);

	output += deprecationsFormatter(results);

	const resultCounts = { error: 0, warning: 0 };
	const fixableCounts = { error: 0, warning: 0 };

	output = results.reduce((accum, result) => {
		preprocessWarnings(result);

		accum += formatter(
			result.warnings,
			result.source || '',
			(returnValue && returnValue.cwd) || process.cwd(),
		);

		for (const warning of result.warnings) {
			calcSeverityCounts(warning.severity, resultCounts);
			const fixable = returnValue.ruleMetadata?.[warning.rule]?.fixable;

			if (fixable === true) {
				calcSeverityCounts(warning.severity, fixableCounts);
			}
		}

		return accum;
	}, output);

	// Ensure consistent padding
	output = output.trim();

	if (output !== '') {
		output = `\n${output}\n`;

		const errorCount = resultCounts.error;
		const warningCount = resultCounts.warning;
		const total = errorCount + warningCount;

		if (total > 0) {
			const error = red(`${errorCount} ${pluralize('error', errorCount)}`);
			const warning = yellow(`${warningCount} ${pluralize('warning', warningCount)}`);
			const symbol = errorCount > 0 ? symbols.error : symbols.warning;

			output += `\n${symbol} ${total} ${pluralize('problem', total)} (${error}, ${warning})`;
		}

		const fixErrorCount = fixableCounts.error;
		const fixWarningCount = fixableCounts.warning;

		if (fixErrorCount > 0 || fixWarningCount > 0) {
			let fixErrorText;
			let fixWarningText;

			if (fixErrorCount > 0) {
				fixErrorText = `${fixErrorCount} ${pluralize('error', fixErrorCount)}`;
			}

			if (fixWarningCount > 0) {
				fixWarningText = `${fixWarningCount} ${pluralize('warning', fixWarningCount)}`;
			}

			const countText = [fixErrorText, fixWarningText].filter(Boolean).join(' and ');

			output += `\n  ${countText} potentially fixable with the "--fix" option.`;
		}

		output += '\n\n';
	}

	return output;
}
