/*
 * Copyright (C) 2026 Fluxer Contributors
 *
 * This file is part of Fluxer.
 *
 * Fluxer is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Fluxer is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
 */

import validator from 'validator';
import {z} from 'zod';
import {Config} from '~/Config';

const RTL_OVERRIDE_REGEX = /\u202E/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
const FORM_FEED_REGEX = /\u000C/g;
const EMAIL_LOCAL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+$/;
const DISCRIMINATOR_REGEX = /^\d{1,4}$/;
const FILENAME_SAFE_REGEX = /^[\p{L}\p{N}\p{M}_.-]+$/u;
const VANITY_URL_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
const ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/;
const WHITESPACE_REGEX = /\s+/g;
const MULTIPLE_HYPHENS_REGEX = /-{2,}/g;
const NON_FILENAME_CHARS_REGEX = /[^\p{L}\p{N}\p{M}_.-]/gu;

const PROTOCOLS = ['http', 'https'];
const TRUE_VALUES = ['true', 'True', '1'];

const DISALLOWED_CHARS = new Set(' !"#$%&\'()*+,/:;<=>?@[\\]^`{|}~');

const URL_VALIDATOR_OPTIONS = {
	require_protocol: true,
	require_host: true,
	disallow_auth: true,
	allow_trailing_dot: false,
	allow_protocol_relative_urls: false,
	allow_fragments: false,
	validate_length: true,
	protocols: ['http', 'https'] as Array<string>,
} as const;

export const PHONE_E164_REGEX = /^\+[1-9]\d{1,14}$/;
const PHONE_E164_ERROR_MESSAGE = 'Phone number must be in E.164 format (e.g., +1234567890)';

const normalizeString = (value: string): string => {
	return value.replace(RTL_OVERRIDE_REGEX, '').replace(FORM_FEED_REGEX, '').trim();
};

const isValidBase64 = (value: string): boolean => {
	if (value.length % 4 !== 0) {
		return false;
	}

	let padding = 0;
	for (let i = value.length - 1; i >= 0; i--) {
		if (value.charCodeAt(i) !== 61) {
			break;
		}
		padding++;
	}

	if (padding > 2) {
		return false;
	}

	const boundary = value.length - padding;

	for (let i = 0; i < boundary; i++) {
		const code = value.charCodeAt(i);
		const isUpper = code >= 65 && code <= 90;
		const isLower = code >= 97 && code <= 122;
		const isDigit = code >= 48 && code <= 57;
		const isPlus = code === 43;
		const isSlash = code === 47;

		if (!(isUpper || isLower || isDigit || isPlus || isSlash)) {
			return false;
		}
	}

	for (let i = boundary; i < value.length; i++) {
		if (value.charCodeAt(i) !== 61) {
			return false;
		}
	}

	try {
		const decoded = Buffer.from(value, 'base64');
		if (decoded.length === 0) {
			return value === '';
		}

		return decoded.toString('base64') === value;
	} catch {
		return false;
	}
};

const C0_C1_CTRL_REGEX =
	// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
	/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F]/g;

const SURROGATES_REGEX = /[\uD800-\uDFFF]/g;

const JOIN_CONTROLS_REGEX = /(?:\u200C|\u200D)/g;

const WJ_BOM_REGEX = /(?:\u2060|\uFEFF)/g;

const BIDI_CTRL_REGEX = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;

const MISC_INVISIBLES_REGEX = /[\u00AD\u180E\uFFFE\uFFFF]/g;

const TAG_CHARS_REGEX = /[\u{E0000}-\u{E007F}]/gu;

const VARIATION_SELECTORS_BASIC = /[\uFE00-\uFE0F]/g;
const VARIATION_SELECTORS_IDEOGRAPHIC = /[\u{E0100}-\u{E01EF}]/gu;

const UNICODE_SPACES_REGEX = /[\s\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/g;

const normalizeWhitespace = (s: string): string => s.replace(UNICODE_SPACES_REGEX, ' ').replace(/\s+/g, ' ').trim();

const stripInvisibles = (s: string): string =>
	s
		.replace(C0_C1_CTRL_REGEX, '')
		.replace(JOIN_CONTROLS_REGEX, '')
		.replace(WJ_BOM_REGEX, '')
		.replace(BIDI_CTRL_REGEX, '')
		.replace(MISC_INVISIBLES_REGEX, '')
		.replace(TAG_CHARS_REGEX, '');

const stripVariationSelectors = (s: string): string =>
	s.replace(VARIATION_SELECTORS_BASIC, '').replace(VARIATION_SELECTORS_IDEOGRAPHIC, '');

const sanitizeUsername = (value: string): string => {
	let s = normalizeString(value);
	s = s.replace(SURROGATES_REGEX, '');
	s = stripInvisibles(s);
	s = stripVariationSelectors(s);
	s = normalizeWhitespace(s);
	return s;
};

const sanitizeChannelName = (value: string): string => {
	let s = normalizeString(value);
	s = stripInvisibles(s);
	s = normalizeWhitespace(s);
	return s;
};

export const EmailType = z
	.email('Invalid email format')
	.transform(normalizeString)
	.refine((value) => value.length >= 1 && value.length <= 254, 'Email length must be between 1 and 254 characters')
	.refine((value) => {
		const atIndex = value.indexOf('@');
		if (atIndex === -1) return false;
		const local = value.slice(0, atIndex);
		return EMAIL_LOCAL_REGEX.test(local);
	}, 'Invalid email local part');

export const DiscriminatorType = z
	.string()
	.regex(DISCRIMINATOR_REGEX, 'Discriminator must be 1-4 digits')
	.superRefine((value, ctx) => {
		const num = Number.parseInt(value, 10);
		if (num < 0 || num > 9999) {
			ctx.addIssue({
				code: 'custom',
				message: 'Discriminator must be between 0 and 9999',
			});
			return z.NEVER;
		}
	})
	.transform((value) => Number.parseInt(value, 10));

const FLUXER_TAG_REGEX = /^[a-zA-Z0-9_]+$/;

export const UsernameType = z
	.string()
	.transform((value) => value.trim())
	.refine((value) => value.length >= 1 && value.length <= 32, 'Username length must be between 1 and 32 characters')
	.refine(
		(value) => FLUXER_TAG_REGEX.test(value),
		'Username can only contain Latin letters (a-z, A-Z), numbers (0-9), and underscores (_)',
	)
	.refine((value) => {
		const lowerValue = value.toLowerCase();
		return lowerValue !== 'everyone' && lowerValue !== 'here';
	}, 'Username cannot be "everyone" or "here"')
	.refine((value) => {
		const lowerValue = value.toLowerCase();
		return !lowerValue.includes('fluxer') && !lowerValue.includes('system message');
	}, 'Username cannot contain "fluxer" or "system message"');

export const GlobalNameType = z
	.string()
	.transform(sanitizeUsername)
	.refine((value) => value.length >= 1 && value.length <= 32, 'Global name length must be between 1 and 32 characters')
	.refine((value) => {
		const lowerValue = value.toLowerCase();
		return lowerValue !== 'everyone' && lowerValue !== 'here';
	}, 'Global name cannot be "everyone" or "here"')
	.refine((value) => {
		const lowerValue = value.toLowerCase();
		return !lowerValue.includes('system message');
	}, 'Global name cannot contain "system message"');

export const URLType = z
	.string()
	.transform(normalizeString)
	.refine((value) => value.length >= 1 && value.length <= 2048, 'URL length must be between 1 and 2048 characters')
	.refine((value) => {
		if (!value.startsWith('http://') && !value.startsWith('https://')) {
			return false;
		}
		try {
			const url = new URL(value);
			return PROTOCOLS.includes(url.protocol.slice(0, -1));
		} catch {
			return false;
		}
	}, 'Invalid URL format')
	.refine(
		(value) =>
			validator.isURL(value, {
				...URL_VALIDATOR_OPTIONS,
				require_tld: Config.nodeEnv !== 'development',
			}),
		'Invalid URL format',
	);

export const AttachmentURLType = z
	.string()
	.transform(normalizeString)
	.refine((value) => value.length >= 1 && value.length <= 2048, 'URL length must be between 1 and 2048 characters')
	.refine((value) => {
		if (value.startsWith('attachment://')) {
			const filename = value.slice(13);
			if (filename.length === 0) {
				return false;
			}
			return FILENAME_SAFE_REGEX.test(filename);
		}

		if (!value.startsWith('http://') && !value.startsWith('https://')) {
			return false;
		}
		try {
			const url = new URL(value);
			return PROTOCOLS.includes(url.protocol.slice(0, -1));
		} catch {
			return false;
		}
	}, 'Invalid URL format or attachment reference')
	.refine((value) => {
		if (value.startsWith('attachment://')) {
			return true;
		}
		return validator.isURL(value, {
			...URL_VALIDATOR_OPTIONS,
			require_tld: Config.nodeEnv !== 'development',
		});
	}, 'Invalid URL format');

const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;

function normalizeFilename(value: string): string {
	let normalized = normalizeString(value);

	// biome-ignore lint/suspicious/noControlCharactersInRegex: null byte filtering is intentional for security
	normalized = normalized.replace(/\x00/g, '');

	normalized = normalized.replace(/[/\\]/g, '_');

	normalized = normalized.replace(/\.{2,}/g, '_');

	while (normalized.includes('..')) {
		normalized = normalized.replace(/\.\./g, '_');
	}

	normalized = normalized.replace(/[<>:"|?*]/g, '');

	if (WINDOWS_RESERVED_NAMES.test(normalized)) {
		normalized = `_${normalized}`;
	}

	normalized = normalized.replace(WHITESPACE_REGEX, '_');

	normalized = normalized.replace(NON_FILENAME_CHARS_REGEX, '');

	normalized = normalized.replace(/\.\./g, '_');
	normalized = normalized.replace(/[/\\]/g, '_');

	if (!normalized || /^[._]+$/.test(normalized)) {
		normalized = 'unnamed';
	}

	return normalized;
}

export const FilenameType = z
	.string()
	.refine((value) => value.length >= 1 && value.length <= 255, 'Filename length must be between 1 and 255 characters')
	.transform(normalizeFilename)
	.refine((value) => value.length >= 1, 'Filename cannot be empty after normalization')
	.refine((value) => FILENAME_SAFE_REGEX.test(value), 'Filename contains invalid characters');

export const Int64Type = z.union([z.string(), z.number().int()]).transform((value, ctx) => {
	if (typeof value === 'number' && !Number.isSafeInteger(value)) {
		ctx.addIssue({
			code: 'custom',
			message: 'Invalid integer format',
		});
		return z.NEVER;
	}

	const normalized = typeof value === 'number' ? value.toString() : value;
	const trimmed = normalized.trim();

	try {
		const bigInt = BigInt(trimmed);
		if (bigInt < -9223372036854775808n || bigInt > 9223372036854775807n) {
			ctx.addIssue({
				code: 'custom',
				message: 'Integer out of valid int64 range',
			});
			return z.NEVER;
		}
		return bigInt;
	} catch {
		ctx.addIssue({
			code: 'custom',
			message: 'Invalid integer format',
		});
		return z.NEVER;
	}
});

export const ColorType = z
	.number()
	.int()
	.min(0x000000, 'Color value must be at least 0x000000')
	.max(0xffffff, 'Color value must not exceed 0xffffff');

export const Int32Type = z.number().int().min(0).max(2147483647);

const INTEGER_STRING_REGEX = /^[+-]?\d+$/;

const coerceNumericStringToNumber = (value: unknown): unknown => {
	if (typeof value !== 'string') {
		return value;
	}

	const trimmed = value.trim();
	if (trimmed.length === 0 || !INTEGER_STRING_REGEX.test(trimmed)) {
		return value;
	}

	const parsed = Number(trimmed);
	return Number.isNaN(parsed) ? value : parsed;
};

export const coerceNumberFromString = <T extends z.ZodNumber>(schema: T) =>
	z.preprocess((value) => coerceNumericStringToNumber(value), schema);

export const QueryBooleanType = z
	.string()
	.trim()
	.optional()
	.default('false')
	.transform((value) => TRUE_VALUES.includes(value));

export const createQueryIntegerType = ({defaultValue = 0, minValue = 0, maxValue = 2147483647} = {}) =>
	z
		.string()
		.trim()
		.optional()
		.default(defaultValue.toString())
		.superRefine((value, ctx) => {
			const num = Number.parseInt(value, 10);
			if (!Number.isInteger(num) || num < minValue || num > maxValue) {
				ctx.addIssue({
					code: 'custom',
					message: `Invalid integer: must be between ${minValue} and ${maxValue}`,
				});
				return z.NEVER;
			}
		})
		.transform((value) => Number.parseInt(value, 10));

export const DateTimeType = z.union([
	z
		.string()
		.regex(ISO_TIMESTAMP_REGEX, 'Must be a valid ISO timestamp')
		.superRefine((value, ctx) => {
			const date = new Date(value);
			if (Number.isNaN(date.getTime())) {
				ctx.addIssue({
					code: 'custom',
					message: 'Invalid date',
				});
				return z.NEVER;
			}
		})
		.transform((value) => new Date(value)),
	z
		.number()
		.int()
		.min(0)
		.max(8640000000000000)
		.superRefine((value, ctx) => {
			const date = new Date(value);
			if (Number.isNaN(date.getTime())) {
				ctx.addIssue({
					code: 'custom',
					message: 'Invalid date',
				});
				return z.NEVER;
			}
		})
		.transform((value) => new Date(value)),
]);

export const WebhookNameType = z
	.string()
	.transform(normalizeString)
	.refine(
		(value) => value.length >= 1 && value.length <= 80,
		'Webhook name length must be between 1 and 80 characters',
	);

export const PasswordType = z
	.string()
	.transform(normalizeString)
	.refine((value) => value.length >= 8 && value.length <= 256, 'Password length must be between 8 and 256 characters');

export const PhoneNumberType = z
	.string()
	.transform(normalizeString)
	.refine((value) => PHONE_E164_REGEX.test(value), PHONE_E164_ERROR_MESSAGE);

export const createStringType = (minLength = 1, maxLength = 256) =>
	z
		.string()
		.transform(normalizeString)
		.refine(
			(value: string) => value.length >= minLength && value.length <= maxLength,
			minLength === maxLength
				? `String must be exactly ${minLength} characters`
				: `String length must be between ${minLength} and ${maxLength} characters`,
		);

export const createUnboundedStringType = () => z.string().transform(normalizeString);

export const createBase64StringType = (minLength = 1, maxLength = 256) =>
	z
		.string()
		.superRefine((value, ctx) => {
			const normalized = normalizeString(value);
			const commaIndex = normalized.indexOf(',');
			const base64 = commaIndex !== -1 ? normalized.slice(commaIndex + 1) : normalized;

			if (base64.length < minLength || base64.length > maxLength) {
				ctx.addIssue({
					code: 'custom',
					message: `Base64 string length must be between ${minLength} and ${maxLength} characters`,
				});
				return z.NEVER;
			}

			if (base64.length < 1 || !isValidBase64(base64)) {
				ctx.addIssue({
					code: 'custom',
					message: 'Value must be a valid base64-encoded string',
				});
				return z.NEVER;
			}
		})
		.transform((value) => {
			const normalized = normalizeString(value);
			const commaIndex = normalized.indexOf(',');
			return commaIndex !== -1 ? normalized.slice(commaIndex + 1) : normalized;
		});

export const ChannelNameType = z
	.string()
	.superRefine((value, ctx) => {
		const normalized = normalizeString(value);
		const processed =
			normalized
				.toLowerCase()
				.replace(WHITESPACE_REGEX, '-')
				.split('')
				.filter((char) => !DISALLOWED_CHARS.has(char))
				.join('') || '-';
		if (processed.length < 1) {
			ctx.addIssue({
				code: 'custom',
				message: 'Channel name cannot be empty after normalization',
			});
			return z.NEVER;
		}
	})
	.transform((value) => {
		const normalized = normalizeString(value);
		return (
			normalized
				.toLowerCase()
				.replace(WHITESPACE_REGEX, '-')
				.split('')
				.filter((char) => !DISALLOWED_CHARS.has(char))
				.join('') || '-'
		);
	})
	.refine(
		(value) => value.length >= 1 && value.length <= 100,
		'Channel name length must be between 1 and 100 characters',
	);

export const GeneralChannelNameType = z
	.string()
	.transform((value) => {
		let sanitized = sanitizeChannelName(value);
		sanitized = sanitized.replace(WHITESPACE_REGEX, ' ');
		return sanitized;
	})
	.pipe(
		z
			.string()
			.refine((v) => v.trim().length > 0, 'Name cannot be empty after normalization')
			.min(1, 'Name length must be between 1 and 100 characters')
			.max(100, 'Name length must be between 1 and 100 characters'),
	);

export const VanityURLCodeType = z
	.string()
	.superRefine((value, ctx) => {
		const normalized = normalizeString(value);
		const processed = normalized.toLowerCase().replace(WHITESPACE_REGEX, '-').replace(MULTIPLE_HYPHENS_REGEX, '-');
		if (!VANITY_URL_REGEX.test(processed)) {
			ctx.addIssue({
				code: 'custom',
				message: 'Vanity URL can only contain lowercase letters (a-z), digits (0-9), and hyphens (-)',
			});
			return z.NEVER;
		}
	})
	.transform((value) => {
		const normalized = normalizeString(value);
		return normalized.toLowerCase().replace(WHITESPACE_REGEX, '-').replace(MULTIPLE_HYPHENS_REGEX, '-');
	})
	.refine(
		(value) => value.length >= 2 && value.length <= 32,
		'Vanity URL code length must be between 2 and 32 characters',
	);

const AUDIT_LOG_REASON_MAX_LENGTH = 512;

export const AuditLogReasonType = z
	.string()
	.nullable()
	.optional()
	.transform((value) => {
		if (!value || value.trim().length === 0) {
			return null;
		}
		const normalized = normalizeString(value);
		if (normalized.length < 1 || normalized.length > AUDIT_LOG_REASON_MAX_LENGTH) {
			return null;
		}
		return normalized;
	});

export const SudoVerificationSchema = z.object({
	password: PasswordType.optional(),
	mfa_method: z.enum(['totp', 'sms', 'webauthn']).optional(),
	mfa_code: createStringType(1, 32).optional(),
	webauthn_response: z.any().optional(),
	webauthn_challenge: createStringType().optional(),
});

export {z};
