import path from 'path';
import { createHash } from 'crypto';

import fs from 'fs-extra';
import fetch from 'node-fetch';
import AdmZip from 'adm-zip';
import * as tar from 'tar';
import Logger from 'electron-log/main';

import {
	MODS,
	DEFAULT_ENABLED_MODS,
	type ModEntry,
	type ModId,
	getMod
} from '~common/mods';
import { type ModState } from '~common/schemas';

import Preferences from './preferences';
import { isTorrentMode } from './aria2';
import Observable from './observable';
import Updater from './updater';
import { addDll, removeDll, listDlls } from './dllsTxt';
import { enumerateDisplays } from './displays';

const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;

/** Files a mod installs on disk. */
const modTargetFiles = (m: ModEntry): string[] => {
	if (m.source.kind === 'directFile') return [m.source.assetName];
	if (m.source.kind === 'archive') return Object.values(m.source.extractMap);
	return [];
};

// client-shipped DLLs that aren't injectable mods; not counted as custom mods
const RESERVED_DLLS = new Set([
	'ace.dll',
	'divxdecoder.dll',
	'discordoverlay.dll',
	'discord_game_sdk.dll',
	'dbghelp.dll',
	'fmod.dll',
	'ijl15.dll',
	'sdl.dll',
	'scan.dll',
	'unicows.dll',
	'zlib1.dll'
]);

// files owned by an active built-in mod; a disabled mod's files are fair game to add by hand
const KNOWN_DLLS = new Set(
	MODS.filter(m => !m.disabled)
		.flatMap(m => [m.registerInDllsTxt, ...modTargetFiles(m)])
		.filter((f): f is string => !!f)
		.map(f => f.toLowerCase())
);

const AV_ERROR =
	'Windows Defender blocked this download. Use "Allow through antivirus" and apply again.';

const looksLikeAvBlock = (msg: string) =>
	/windows defender|virus|potentially unwanted/i.test(msg);

export type ModRowStatus = {
	id: ModId;
	name: string;
	description: string;
	repoUrl: string;
	recommended: boolean;
	requires: ModId[];
	enabled: boolean;
	ignoreUpdates: boolean;
	installedVersion?: string;
	latestVersion: string;
	state: 'idle' | 'downloading' | 'installing' | 'uninstalling' | 'error';
	progress?: number;
	error?: string;
};

export type CustomMod = { name: string; enabled: boolean };

export type ModsStatus = {
	state: 'verifying' | 'idle' | 'busy';
	dirty: boolean;
	mods: ModRowStatus[];
	custom: CustomMod[];
	// enabled mods whose files are missing (AV quarantine or incomplete sync)
	missingFiles: string[];
};

class ModsClass extends Observable<ModsStatus> {
	protected _value: ModsStatus = {
		state: 'verifying',
		dirty: false,
		mods: [],
		custom: [],
		missingFiles: []
	};

	// staged custom-DLL toggles, keyed lower-case; #customApplied mirrors dlls.txt
	#customDesired = new Map<string, boolean>();
	#customApplied = new Map<string, boolean>();
	#customNames = new Map<string, string>();

	get status(): ModsStatus {
		return this._value;
	}

	#initialRow(m: ModEntry): ModRowStatus {
		const state = Preferences.data?.mods?.[m.id];
		return {
			id: m.id,
			name: m.name,
			description: m.description,
			repoUrl: m.repoUrl,
			recommended: !!m.recommended,
			requires: m.requires ?? [],
			enabled: !!state?.enabled,
			ignoreUpdates: !!state?.ignoreUpdates,
			installedVersion: state?.installedVersion,
			latestVersion: m.version,
			state: 'idle'
		};
	}

	#patchRow(id: ModId, patch: Partial<ModRowStatus>) {
		this._value = {
			...this._value,
			mods: this._value.mods.map(r => (r.id === id ? { ...r, ...patch } : r))
		};
		this._value = { ...this._value, dirty: this.#computeDirty() };
		this._notifyObservers();
	}

	#computeDirty(): boolean {
		if (this.#customDesired.size > 0) return true;
		return this._value.mods.some(r => {
			const wantInstalled = r.enabled;
			const isInstalled = !!r.installedVersion;
			if (wantInstalled !== isInstalled) return true;
			if (
				r.installedVersion &&
				r.installedVersion !== r.latestVersion &&
				!r.ignoreUpdates
			)
				return true;
			return false;
		});
	}

	load() {
		this._value = {
			state: 'verifying',
			dirty: false,
			mods: MODS.filter(m => !m.disabled).map(m => this.#initialRow(m)),
			custom: this._value.custom,
			missingFiles: []
		};
	}

	// DLLs in the client dir we neither ship nor own
	async #detectCustomDlls(clientDir: string): Promise<CustomMod[]> {
		const inDllsTxt = await listDlls(clientDir);
		const enabled = new Set(inDllsTxt.map(n => n.toLowerCase()));
		const found = new Map<string, string>();
		const consider = (name: string) => {
			const lc = name.toLowerCase();
			if (RESERVED_DLLS.has(lc) || KNOWN_DLLS.has(lc) || found.has(lc)) return;
			found.set(lc, name);
		};
		for (const f of await fs.readdir(clientDir).catch(() => [] as string[]))
			if (/\.dll$/i.test(f)) consider(f);
		inDllsTxt.forEach(consider);
		const names = [...found.values()].sort((a, b) => a.localeCompare(b));
		this.#customApplied = new Map(
			names.map(n => [n.toLowerCase(), enabled.has(n.toLowerCase())])
		);
		this.#customNames = new Map(names.map(n => [n.toLowerCase(), n]));
		// drop staged changes for DLLs no longer present
		const present = new Set(names.map(n => n.toLowerCase()));
		for (const lc of [...this.#customDesired.keys()])
			if (!present.has(lc)) this.#customDesired.delete(lc);
		return names.map(name => {
			const lc = name.toLowerCase();
			return {
				name,
				enabled: this.#customDesired.has(lc)
					? !!this.#customDesired.get(lc)
					: !!this.#customApplied.get(lc)
			};
		});
	}

	// flush staged custom-DLL changes to dlls.txt; a failed write stays staged (still pending)
	async #applyCustomDlls(clientDir: string) {
		for (const [lc, enabled] of [...this.#customDesired]) {
			const name = this.#customNames.get(lc) ?? lc;
			try {
				await (enabled ? addDll(clientDir, name) : removeDll(clientDir, name));
				this.#customDesired.delete(lc);
			} catch (e) {
				Logger.warn(`custom dll apply failed for ${name}`, e);
			}
		}
	}

	async #syncPreferredMonitor(clientDir: string) {
		const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
		if (!(await fs.pathExists(vmmfDll))) return;

		const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
		const existsCfg = await fs.pathExists(vmmfCfg);
		const current = existsCfg
			? Number(
					await fs
						.readFile(vmmfCfg, 'utf8')
						.then(s => s.trim())
						.catch(() => '')
			  )
			: NaN;
		const hasCurrent = Number.isInteger(current);
		const ours = Preferences.data?.vmmfWrittenIndex;

		const devices = await enumerateDisplays();
		const usable = devices?.filter(d => d.attached && d.width > 0);
		if (!devices || !usable?.length) {
			Logger.warn('Could not enumerate displays; preferred monitor unchanged');
			return;
		}
		const primary = usable.find(d => d.primary) ?? usable[0];

		if (hasCurrent && ours === undefined) {
			const pinned = devices.find(d => d.index === current);
			const broken = !pinned || !pinned.attached || !pinned.primary;
			if (!broken) {
				Preferences.data = { vmmfWrittenIndex: current };
				Logger.info(`Adopting existing preferred monitor ${current} as chosen`);
				return;
			}
			Logger.warn(
				`Preferred monitor ${current} (${
					pinned ? pinned.deviceName : 'missing'
				}) is ${
					!pinned || !pinned.attached
						? 'not attached'
						: 'not the primary display'
				}; healing to ${primary.index}`
			);
		} else if (hasCurrent && current !== ours) {
			Logger.info(
				`Preferred monitor ${current} was set manually; leaving it alone`
			);
			Preferences.data = { vmmfWrittenIndex: current };
			return;
		} else if (hasCurrent && current === primary.index) {
			return;
		}

		await fs
			.writeFile(vmmfCfg, `${primary.index}\n`, 'utf8')
			.then(() => {
				Preferences.data = { vmmfWrittenIndex: primary.index };
				Logger.info(
					`Preferred monitor set to ${primary.index} (${primary.deviceName} ${primary.width}x${primary.height})`
				);
			})
			.catch(e => Logger.warn('Failed to write preferred monitor', e));
	}

	async verify() {
		this.load();
		this._notifyObservers();

		const clientDir = Preferences.data?.clientDir;

		if (clientDir) {
			await this.#syncPreferredMonitor(clientDir);
		}

		const missing: string[] = [];
		for (const m of MODS) {
			// disabled mods: leave dlls.txt and installed state untouched
			if (m.disabled) continue;

			const state = Preferences.data?.mods?.[m.id];
			let installedVersion = state?.installedVersion;

			// torrent mode: DLLs ship in the client; a missing file goes to `missing`, not dirty
			if (isTorrentMode()) {
				const files = modTargetFiles(m);
				const present =
					!!clientDir &&
					files.length > 0 &&
					(
						await Promise.all(
							files.map(rel => fs.pathExists(path.join(clientDir, rel)))
						)
					).every(Boolean);
				const enabled = state?.enabled ?? DEFAULT_ENABLED_MODS.includes(m.id);
				installedVersion = enabled ? m.version : undefined;
				if (enabled && files.length > 0 && !present) missing.push(m.name);
				// only point dlls.txt at a file actually on disk
				if (clientDir && m.registerInDllsTxt)
					await (present && enabled
						? addDll(clientDir, m.registerInDllsTxt)
						: removeDll(clientDir, m.registerInDllsTxt)
					).catch(e => Logger.warn(`dlls.txt update failed for ${m.id}`, e));
				this.#patchRow(m.id, {
					installedVersion,
					latestVersion: m.version,
					enabled,
					ignoreUpdates: true
				});
				continue;
			}

			if (clientDir && installedVersion) {
				const filesPresent = await Promise.all(
					(state?.installedFiles ?? []).map(rel =>
						fs.pathExists(path.join(clientDir, rel))
					)
				);
				if (state?.installedFiles?.length && !filesPresent.every(Boolean)) {
					installedVersion = undefined;
					await this.#savePref(m.id, {
						enabled: state?.enabled ?? false,
						installedVersion: undefined,
						installedFiles: [],
						ignoreUpdates: state?.ignoreUpdates ?? false
					});
				}
			}

			if (clientDir && m.registerInDllsTxt)
				await (installedVersion
					? addDll(clientDir, m.registerInDllsTxt)
					: removeDll(clientDir, m.registerInDllsTxt)
				).catch(() => {});

			this.#patchRow(m.id, {
				installedVersion,
				latestVersion: m.version,
				enabled: !!state?.enabled,
				ignoreUpdates: !!state?.ignoreUpdates
			});
		}

		this._value = {
			...this._value,
			state: 'idle',
			dirty: this.#computeDirty(),
			custom: clientDir ? await this.#detectCustomDlls(clientDir) : [],
			missingFiles: missing
		};
		this._notifyObservers();
	}

	async toggleCustom(name: string, enabled: boolean) {
		const clientDir = Preferences.data?.clientDir;
		if (!clientDir) return;
		// stage; matching dlls.txt clears the pending change
		const lc = name.toLowerCase();
		if (enabled === !!this.#customApplied.get(lc))
			this.#customDesired.delete(lc);
		else this.#customDesired.set(lc, enabled);
		this._value = {
			...this._value,
			custom: await this.#detectCustomDlls(clientDir)
		};
		this._value = { ...this._value, dirty: this.#computeDirty() };
		this._notifyObservers();
	}

	async addCustomDll(
		srcPath: string
	): Promise<{ ok: boolean; error?: string }> {
		const clientDir = Preferences.data?.clientDir;
		if (!clientDir) return { ok: false, error: 'No game folder is set.' };
		const name = path.basename(srcPath);
		if (!/\.dll$/i.test(name))
			return { ok: false, error: 'Please choose a .dll file.' };
		const lc = name.toLowerCase();
		if (RESERVED_DLLS.has(lc) || KNOWN_DLLS.has(lc))
			return {
				ok: false,
				error: `${name} is a built-in file and can't be added as a custom mod.`
			};
		try {
			const dest = path.join(clientDir, name);
			if (path.resolve(srcPath) !== path.resolve(dest))
				await fs.copy(srcPath, dest, { overwrite: true });
		} catch (e) {
			return { ok: false, error: e instanceof Error ? e.message : String(e) };
		}
		// stage enabled; Apply writes dlls.txt
		this.#customDesired.set(name.toLowerCase(), true);
		this._value = {
			...this._value,
			custom: await this.#detectCustomDlls(clientDir)
		};
		this._value = { ...this._value, dirty: this.#computeDirty() };
		this._notifyObservers();
		return { ok: true };
	}

	async toggle(id: ModId, enabled: boolean) {
		const cur = Preferences.data?.mods?.[id];
		await this.#savePref(id, {
			enabled,
			installedVersion: cur?.installedVersion,
			installedFiles: cur?.installedFiles ?? [],
			ignoreUpdates: cur?.ignoreUpdates ?? false
		});
		this.#patchRow(id, { enabled });
	}

	async setIgnoreUpdates(id: ModId, ignore: boolean) {
		const cur = Preferences.data?.mods?.[id];
		await this.#savePref(id, {
			enabled: cur?.enabled ?? false,
			installedVersion: cur?.installedVersion,
			installedFiles: cur?.installedFiles ?? [],
			ignoreUpdates: ignore
		});
		this.#patchRow(id, { ignoreUpdates: ignore });
	}

	async applyAll(opts: { repairOnly?: boolean } = {}) {
		const clientDir = Preferences.data?.clientDir;
		if (!clientDir) {
			Logger.warn('No clientDir set; cannot apply mods.');
			return;
		}
		// don't commit a mod set with an unmet dependency; dirty stays set. repair is exempt.
		if (!opts.repairOnly) {
			const enabledIds = new Set(
				this._value.mods.filter(r => r.enabled).map(r => r.id)
			);
			const missingDeps = [
				...new Set(
					this._value.mods
						.filter(r => r.enabled)
						.flatMap(r => r.requires.filter(dep => !enabledIds.has(dep)))
				)
			];
			if (missingDeps.length) {
				Logger.warn(
					`Not applying mods: unmet dependencies ${missingDeps.join(', ')}`
				);
				return;
			}
		}
		// commit the player's own DLL toggles first
		await this.#applyCustomDlls(clientDir);
		// torrent mode: mods ship in the client; just reconcile dlls.txt
		if (isTorrentMode()) {
			await this.verify();
			return;
		}
		if (this._value.state === 'busy') {
			Logger.warn('applyAll already running; ignoring re-entrant call.');
			return;
		}
		await this.verify();
		this._value = { ...this._value, state: 'busy' };
		this._notifyObservers();

		const queue = [...this._value.mods];
		queue.sort((a, b) => {
			if (a.id === 'vanillaFixes') return -1;
			if (b.id === 'vanillaFixes') return 1;
			return 0;
		});

		const failures = new Map<ModId, string>();
		for (const row of queue) {
			const m = getMod(row.id);
			if (!m) continue;

			const wantInstalled = row.enabled;
			const isInstalled = !!row.installedVersion;
			const updateAvailable =
				isInstalled &&
				row.installedVersion !== row.latestVersion &&
				!row.ignoreUpdates;

			try {
				if (wantInstalled && !isInstalled) {
					await this.#install(m);
				} else if (!wantInstalled && isInstalled) {
					await this.#uninstall(m);
				} else if (wantInstalled && updateAvailable && !opts.repairOnly) {
					await this.#uninstall(m);
					await this.#install(m);
				}
			} catch (e) {
				Logger.error(`Failed to apply ${m.id}:`, e);
				const msg = e instanceof Error ? e.message : String(e);
				failures.set(m.id, looksLikeAvBlock(msg) ? AV_ERROR : msg);
			}
		}

		this._value = { ...this._value, state: 'idle' };
		await this.verify();
		for (const [id, error] of failures)
			this.#patchRow(id, { state: 'error', error });
		await Updater.verify();
	}

	async #install(m: ModEntry) {
		// In torrent mode the mod binaries ship with the client; nothing is fetched.
		if (isTorrentMode()) return;
		const clientDir = Preferences.data?.clientDir;
		if (!clientDir) throw new Error('No client dir');
		if (m.source.kind === 'managed') return;

		Logger.info(`Installing mod ${m.id}...`);
		this.#patchRow(m.id, {
			state: 'downloading',
			progress: 0,
			error: undefined
		});

		const written: string[] = [];
		const missing: string[] = [];

		if (m.source.kind === 'directFile') {
			const dest = path.join(clientDir, m.source.assetName);
			await this.#downloadTo(m.source.url, dest, m.source.sha256);
			written.push(m.source.assetName);
		} else if (m.source.kind === 'archive') {
			const scratch = path.join(clientDir, '.octolauncher-tmp');
			await fs.ensureDir(scratch);
			const tmp = path.join(
				scratch,
				`${m.id}-${Date.now()}.${m.source.format}`
			);
			await this.#downloadTo(m.source.url, tmp, m.source.sha256);
			this.#patchRow(m.id, { state: 'installing' });

			const map = m.source.extractMap;
			if (m.source.format === 'zip') {
				const zip = new AdmZip(tmp);
				const entries = zip.getEntries();
				for (const [src, dst] of Object.entries(map)) {
					const entry = entries.find(e => e.entryName === src);
					if (!entry) {
						missing.push(src);
						continue;
					}
					const target = path.join(clientDir, dst);
					await fs.ensureDir(path.dirname(target));
					await fs.writeFile(target, entry.getData());
					written.push(dst);
				}
			} else {
				const stagingDir = path.join(scratch, `${m.id}-${Date.now()}-extract`);
				await fs.ensureDir(stagingDir);
				await tar.x({ file: tmp, cwd: stagingDir });
				for (const [src, dst] of Object.entries(map)) {
					const srcPath = path.join(stagingDir, src);
					if (!(await fs.pathExists(srcPath))) {
						missing.push(src);
						continue;
					}
					const target = path.join(clientDir, dst);
					await fs.ensureDir(path.dirname(target));
					await fs.copy(srcPath, target);
					written.push(dst);
				}
				await fs.remove(stagingDir).catch(() => {});
			}
			await fs.remove(tmp).catch(() => {});
		}

		if (missing.length)
			throw new Error(
				`${m.name}: download is missing expected file(s): ${missing.join(', ')}`
			);

		if (m.registerInDllsTxt) {
			await addDll(clientDir, m.registerInDllsTxt);
		}

		await this.#savePref(m.id, {
			enabled: true,
			installedVersion: m.version,
			installedFiles: written,
			ignoreUpdates: Preferences.data?.mods?.[m.id]?.ignoreUpdates ?? false
		});

		this.#patchRow(m.id, {
			state: 'idle',
			installedVersion: m.version,
			progress: 1
		});
	}

	async #uninstall(m: ModEntry) {
		const clientDir = Preferences.data?.clientDir;
		if (!clientDir) throw new Error('No client dir');
		if (m.source.kind === 'managed') return;

		Logger.info(`Uninstalling mod ${m.id}...`);
		this.#patchRow(m.id, { state: 'uninstalling', error: undefined });

		const cur = Preferences.data?.mods?.[m.id];
		const files = cur?.installedFiles ?? [];

		for (const rel of files) {
			const fullPath = path.join(clientDir, rel);
			await fs
				.remove(fullPath)
				.catch(err => Logger.warn(`Couldn't remove ${fullPath}:`, err));
		}

		if (m.registerInDllsTxt) {
			await removeDll(clientDir, m.registerInDllsTxt);
		}

		await this.#savePref(m.id, {
			enabled: cur?.enabled ?? false,
			installedVersion: undefined,
			installedFiles: [],
			ignoreUpdates: cur?.ignoreUpdates ?? false
		});

		this.#patchRow(m.id, { state: 'idle', installedVersion: undefined });
	}

	async #downloadTo(url: string, dest: string, sha256?: string) {
		const res = await fetch(url, {
			headers: { 'User-Agent': 'OctoLauncher' },
			timeout: MOD_DOWNLOAD_TIMEOUT_MS
		});
		if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
		await fs.ensureDir(path.dirname(dest));
		const buf = await res.arrayBuffer();

		if (sha256) {
			const got = createHash('sha256').update(Buffer.from(buf)).digest('hex');
			if (got !== sha256.toLowerCase())
				throw new Error(
					`Checksum mismatch for ${path.basename(
						dest
					)}: expected ${sha256}, got ${got}. Refusing to install.`
				);
		}

		await fs.writeFile(dest, Buffer.from(buf));
		if (!(await fs.pathExists(dest)))
			throw new Error(
				`Downloaded file disappeared after writing: ${path.basename(dest)}. ` +
					'This is often Windows Defender quarantine; if so, use "Allow through antivirus" and apply again.'
			);
	}

	async #savePref(id: ModId, state: ModState) {
		const allMods = { ...(Preferences.data?.mods ?? {}), [id]: state };
		Preferences.data = { mods: allMods };
	}
}

const Mods = new ModsClass();
export default Mods;
