import JSZip from "jszip";
import { Create40kRoster } from "./roster40k";
import { Create40kRoster10th } from "./roster40k-10th";
import { useEffect, useMemo, useRef, useState } from "react";

import Demo0 from "./assets/Demo0.png";
import Demo1 from "./assets/Demo1.png";
import { Roster } from "./9th/Roster";
import { Roster as Roster10th } from "./10th/Roster";
import { useLocalStorage } from "./helpers/useLocalStorage";
import { parseJSON, stringifyJSON } from "./helpers/json";
import { Create40kRoster11th } from "./roster40k-11th";
import { applyTransforms, defaultToggles } from "./transforms";

// The transforms, in the order they are offered in the UI.  These are what makes
// this a fork: they rewrite the roster into the generic datasheets the official
// cards print, rather than a record of one particular list.
const TRANSFORMS = [
	[
		"mergeDuplicateUnits",
		"Merge duplicates",
		"Fold copies of a datasheet - taken only because their wargear is mutually exclusive - into a single card showing every option.",
	],
	[
		"removeLeaderAbilities",
		"Drop Leader/Support",
		"Remove the attachment rules. Once the army is built they say nothing you need mid-game, and they push the rules you do need off the card.",
	],
	[
		"convertChoiceAbilities",
		"Split choice abilities",
		"Print each option of a 'select one each turn' ability as its own titled row, the way the official datasheets do.",
	],
];

const PARSERS = {
	"Warhammer 40,000 9th Edition": [Create40kRoster, 9],
	"Warhammer 40,000 10th Edition": [Create40kRoster10th, 10],
	"Warhammer 40,000 11th Edition": [Create40kRoster11th, 11],
};

const throttle = (func, limit) => {
	let lastFunc;
	let lastRan;
	return (...args) => {
		if (!lastRan) {
			func(...args);
			lastRan = Date.now();
		} else {
			clearTimeout(lastFunc);
			lastFunc = setTimeout(
				() => {
					if (Date.now() - lastRan >= limit) {
						func(...args);
						lastRan = Date.now();
					}
				},
				limit - (Date.now() - lastRan),
			);
		}
	};
};

function App() {
	// Saved rosters hold the *raw* roster XML rather than the parsed roster, so
	// that flipping a transform toggle can rebuild them without a re-upload.
	// The keys are BrevyScribe's own, so nothing upstream FancyScribe left in
	// localStorage is read back in the wrong shape.
	const [rosters, setRosters] = useLocalStorage("brevyscribe.rosters", "[]");
	const rostersJSON = parseJSON(rosters ?? "[]");
	const [savedToggles, setSavedToggles] = useLocalStorage(
		"brevyscribe.transforms",
		stringifyJSON(defaultToggles),
	);
	// Memoised so the rebuild effect can depend on the toggles themselves rather
	// than their serialised form; a fresh object every render would re-run it.
	const toggles = useMemo(
		() => ({ ...defaultToggles, ...parseJSON(savedToggles ?? "{}") }),
		[savedToggles],
	);
	// { xml, save } - the roster to show, and whether it belongs in the list.
	const [source, setSource] = useState();
	const [error, setError] = useState();
	const [roster, setRoster] = useState();
	const [edition, setEdition] = useState(10); // [9, 10, 11]
	const [onePerPage, setOnePerPage] = useState(false);
	const [showLore, setShowLore] = useState(true);
	const [primaryColor, setPrimaryColor] = useState("#536766");
	const [colorUserChoice, setColorUserChoice] = useState(false);
	const uploadRef = useRef();

	const throttledSetPrimaryColor = useRef(
		throttle((color) => setPrimaryColor(color), 50),
	).current;

	async function handleFileSelect(event) {
		const files = event?.target?.files;

		if (files) {
			const reader = new FileReader();
			reader.onerror = () => {
				reader.abort();
				setError("Failed to read roster file.");
			};
			reader.onloadend = async () => {
				const content = reader.result;
				setSource({ xml: await unzip(content), save: true });
			};
			reader.readAsBinaryString(files[0]);
		} else {
			let example;
			switch (event) {
				case "ultras":
					example = await fetch("Ultramarines Example.rosz");
					break;
				case "chaos daemons":
					example = await fetch("Chaos Demons Mix.rosz");
					break;
				case "death guard":
					example = await fetch("Death Guard Example.rosz");
					break;
				case "thousand sons":
					example = await fetch("Thousand Sons Example.rosz");
					break;
				default:
					break;
			}
			if (!example) return;
			const arrayBuffer = await example.arrayBuffer();
			// Create a new Blob object from the zip file contents
			const zipBlob = new Blob([arrayBuffer], { type: "application/zip" });
			setSource({ xml: await unzip(zipBlob), save: false });
		}
	}

	// Already in the list, so there is nothing to save; the stored XML goes
	// through the same rebuild as a fresh upload.
	const loadFromLocalStorage = (saved) =>
		setSource({ xml: saved.xml, save: false });

	const unzip = async (file) => {
		if (file?.charAt && file.charAt(0) !== "P") {
			console.log(file.replace(/\u00a0/g, " "));
			return file
				.replaceAll("Â", " ")
				.replaceAll("â", "'")
				.replaceAll("â", "■"); // Â is a common issue with UTF-8 encoding in XML files
		} else {
			const jszip = new JSZip();
			const zip = await jszip.loadAsync(file);
			return zip.file(/[^/]+\.ros/)[0].async("string"); // Get roster files that are in the root
		}
	};

	// Rebuild whenever a new roster arrives or a transform is toggled.  The
	// transforms run here rather than over the uploaded file, so flipping a
	// toggle rebuilds from the original XML and nothing has to be re-uploaded.
	//
	// biome-ignore lint/correctness/useExhaustiveDependencies: the roster list is
	// read to append to it, so depending on it would re-run this on its own write.
	useEffect(() => {
		if (!source?.xml) return;

		const doc = new DOMParser().parseFromString(source.xml, "text/xml");
		const info = doc?.querySelector("roster");
		if (!info) return;

		// Determine roster type (game system).
		const gameType = info.getAttribute("gameSystemName");
		if (!gameType) return;
		const parser = PARSERS[gameType];
		if (!parser) {
			setError(`No support for game type '${gameType}'.`);
			return;
		}

		const report = applyTransforms(doc, toggles);
		const [createRoster, rosterEdition] = parser;
		const built = createRoster(doc, gameType);
		if (!built || built.forces.length === 0) return;

		setRoster(built);
		setEdition(rosterEdition);
		setError("");

		const name = info.getAttribute("name");
		if (name) document.title = `BrevyScribe ${name}`;
		console.log(built, report);

		if (source.save) {
			// Keyed on the XML, so re-saving the same roster moves it to the front
			// of the list rather than adding a second copy.
			setRosters(
				stringifyJSON([
					{ name: name || "Roster", xml: source.xml },
					...rostersJSON.filter((entry) => entry.xml !== source.xml),
				]),
			);
		}
	}, [source, toggles, setRosters]);

	useEffect(() => {
		// Check if the browser is Safari, and if so, remove the accept attribute
		// from the file input element. This is because Safari doesn't support
		// extensions on the accept attribute for input type=file
		// (https://caniuse.com/input-file-accept). If set, they will not allow any
		// file to be selected.
		if (
			navigator.userAgent.match(/AppleWebKit.*Safari/) &&
			!navigator.userAgent.includes("Chrome")
		) {
			uploadRef.current?.removeAttribute("accept");
		}
	}, []);
	useEffect(() => {
		if (roster) {
			js_colorPicker.value = getPrimaryColor(roster.forces[0].catalog);
			setPrimaryColor(getPrimaryColor(roster.forces[0].catalog));
			setColorUserChoice(false);
		}
	}, [roster]);

	const handleDrag = (e) => {
		e.preventDefault();
		e.stopPropagation();
	};

	useEffect(() => {
		if (edition === 9) {
			document.body.style.minWidth = "600px";
		} else {
			document.body.style.minWidth = "";
		}
	}, [edition]);

	return (
		<div
			id="js_app"
			className="App"
			style={{
				"--primary-color": primaryColor,
				"--primary-color-transparent": primaryColor + "60",
			}}
		>
			<div className="header print-display-none">
				<a
					href="/"
					style={{
						color: "#fff",
						fontWeight: 800,
						display: "flex",
						flexDirection: "column",
					}}
				>
					BrevyScribe{" "}
					<span
						style={{
							fontSize: "0.8rem",
							fontWeight: 400,
						}}
					>
						Generic datasheets, not list-specific ones
					</span>
				</a>
				<div className="subheader">
					A fancy way to print your Warhammer 40k datasheets
				</div>
			</div>

			<div className="body">
				<div
					className="print-display-none max-w-[95vw]"
					style={{
						display: "flex",
						flexDirection: roster ? "row" : "column",
						alignItems: "center",
						gap: 8,
					}}
				>
					<div
						className="print-display-none"
						style={{
							fontSize: roster ? "1rem" : "1.7rem",
							fontWeight: roster ? 600 : "",
							display: rostersJSON.length > 0 ? "" : "none",
						}}
					>
						Your rosters
					</div>
					<div
						className="print-display-none"
						style={{
							gap: 8,
							flexWrap: "wrap",
							justifyContent: "center",
							alignItems: "center",
							display: "flex",
						}}
					>
						{rostersJSON.map((r, index) => (
							<div key={index} style={{ position: "relative" }}>
								<button
									className="print-display-none"
									style={{ fontSize: roster ? "" : "1.2rem" }}
									onClick={() => loadFromLocalStorage(r)}
								>
									{r.name}
								</button>
								<button
									onClick={() =>
										setRosters(
											stringifyJSON(rostersJSON.filter((_, i) => i !== index)),
										)
									}
									className="print-display-none absolute right-[-4px] top-[-4px] rounded-full border-0 bg-red-600 fill-white p-[2px] text-sm transition duration-150 ease-in-out hover:bg-red-800 focus:outline-none"
								>
									<svg height="16" viewBox="0 0 16 16" width="16">
										<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
									</svg>
								</button>
							</div>
						))}
					</div>
				</div>
				<div className="print-display-none" />
				<div
					className="print-display-none max-w-[95vw]"
					style={{ display: "flex", width: "100%", gap: 8 }}
				>
					<input
						type="file"
						ref={uploadRef}
						accept=".ros,.rosz"
						name="rosterUpload"
						id="rosterUpload"
						onChange={handleFileSelect}
						style={{ display: "none" }}
					/>
					<label
						htmlFor="rosterUpload"
						className={"rosterUpload " + (roster ? "rosterUploaded" : "")}
						id="rosterUploadContainer"
						onDrop={(e) => {
							e.preventDefault();
							e.stopPropagation();
							if (e.dataTransfer.files && e.dataTransfer.files[0]) {
								e.target.files = e.dataTransfer.files;
								handleFileSelect(e);
							}
						}}
						onDragEnter={handleDrag}
						onDragLeave={handleDrag}
						onDragOver={handleDrag}
					>
						<div id="preloadContainer">
							<span>Upload roster file (.ros, .rosz)</span>
						</div>
					</label>

					<button
						style={{ display: roster ? "" : "none" }}
						onClick={() => window.print()}
					>
						Print roster
					</button>
				</div>

				<div
					className="print-display-none max-w-[95vw]"
					style={{
						display: roster ? "flex" : "none",
						width: "100%",
						gap: 16,
						flexWrap: "wrap",
					}}
				>
					<span style={{ fontWeight: 600, minHeight: 26 }}>Datasheets:</span>
					{TRANSFORMS.map(([key, label, description]) => (
						<label
							key={key}
							title={description}
							style={{
								display: "flex",
								alignItems: "center",
								gap: 4,
								minHeight: 26,
							}}
						>
							<input
								type="checkbox"
								checked={toggles[key]}
								onChange={(e) =>
									setSavedToggles(
										stringifyJSON({ ...toggles, [key]: e.target.checked }),
									)
								}
							/>
							<span className="select-none">{label}</span>
						</label>
					))}
				</div>

				<div
					className="print-display-none max-w-[95vw]"
					style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }}
				>
					<label
						style={{
							display: "flex",
							alignItems: "center",
							gap: 4,
							minHeight: 26,
						}}
					>
						<input
							type="checkbox"
							onChange={(e) => setOnePerPage(e.target.checked)}
						/>
						<span className="select-none">
							One Datacard per Page when Printing
						</span>
					</label>
					<label
						style={{
							display: "flex",
							alignItems: "center",
							gap: 4,
							minHeight: 26,
						}}
						title="Print the flavour text from public/Lore.csv beside the model image, the way the official datasheets do."
					>
						<input
							type="checkbox"
							checked={showLore}
							onChange={(e) => setShowLore(e.target.checked)}
						/>
						<span className="select-none">Show Lore Text</span>
					</label>
					<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
						<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
							<input
								id="js_colorPicker"
								type="color"
								className="rounded-sm border border-gray-400"
								style={{ height: 24, width: 32, padding: "0 2px" }}
								onChange={(e) => {
									setColorUserChoice(true);
									throttledSetPrimaryColor(e.target.value);
								}}
							/>
							<span> Custom Color</span>
						</label>

						{roster && colorUserChoice && (
							<button
								onClick={() => {
									setPrimaryColor(getPrimaryColor(roster.forces[0].catalog));
									setColorUserChoice(false);
									js_colorPicker.value = getPrimaryColor(
										roster.forces[0].catalog,
									);
								}}
								style={{
									padding: "2px 4px",
									borderRadius: 4,
									borderWidth: 1,
								}}
							>
								Reset color
							</button>
						)}
					</div>
				</div>
				<div className="print-display-none" style={{ color: "red" }}>
					{error}
				</div>
				{edition === 9 && <Roster roster={roster} onePerPage={onePerPage} />}
				{(edition === 10 || edition === 11) && (
					<Roster10th
						roster={roster}
						onePerPage={onePerPage}
						colorUserChoice={colorUserChoice}
						primaryColor={primaryColor}
						showLore={showLore}
					/>
				)}

				<div
					className="max-w-[95vw]"
					style={{
						paddingTop: 8,
						fontSize: "1.7rem",
						display: roster ? "none" : "flex",
					}}
				>
					Examples
				</div>
				<div
					className="max-w-[95vw]"
					style={{
						gap: 8,
						flexWrap: "wrap",
						justifyContent: "center",
						display: roster ? "none" : "flex",
					}}
				>
					<button
						className="print-display-none"
						style={{ fontSize: "1.2rem" }}
						onClick={() => handleFileSelect("thousand sons")}
					>
						Thousand Sons (10th)
					</button>
					<button
						className="print-display-none"
						style={{ fontSize: "1.2rem" }}
						onClick={() => handleFileSelect("death guard")}
					>
						Death Guard (10th)
					</button>
					<button
						className="print-display-none"
						style={{ fontSize: "1.2rem" }}
						onClick={() => handleFileSelect("ultras")}
					>
						Ultramarines (9th)
					</button>
					<button
						className="print-display-none"
						style={{ fontSize: "1.2rem" }}
						onClick={() => handleFileSelect("chaos daemons")}
					>
						Chaos Daemons (9th)
					</button>
				</div>
				<div
					className="print-display-none max-w-[95vw]"
					style={{ display: roster ? "none" : "" }}
				>
					<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>About</div>
					<div style={{ fontSize: "1.2rem" }}>
						BrevyScribe renders{" "}
						<a
							href="https://www.battlescribe.net/"
							target="_blank"
							rel="noreferrer"
						>
							BattleScribe
						</a>{" "}
						or <a href="https://www.newrecruit.eu/">New Recruit</a> roster files
						in an opinionated format inspired by the 10th edition datacards.
					</div>
					<div style={{ fontSize: "1.2rem" }}>
						It differs from its upstream in what it prints: rather than a record
						of one particular list, it rewrites the roster into the generic
						datasheets the official cards show. Duplicate copies of a unit are
						folded into one card carrying every option, the Leader and Support
						attachment rules are dropped, and a &quot;select one each
						turn&quot; ability is split into one titled row per option. Use the{" "}
						<b>Datasheets</b> toggles above to turn any of that off.
					</div>
					<div style={{ fontSize: "1.2rem" }}>
						BrevyScribe is a fork of{" "}
						<a
							href="https://github.com/NilsUeter/fancyscribe"
							target="_blank"
							rel="noreferrer"
						>
							FancyScribe
						</a>{" "}
						by Nils Ueter, which does all the heavy lifting here. Additional
						inspiration and large parts of the parsing logic come from the{" "}
						<a
							href="https://rweyrauch.github.io/PrettyScribe"
							target="_blank"
							rel="noreferrer"
						>
							PrettyScribe
						</a>{" "}
						website.
					</div>
					<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>
						Output Examples
					</div>
					<img src={Demo0} className="pb-4" style={{ width: "100%" }} />
					<img src={Demo1} style={{ width: "100%" }} />
				</div>
			</div>
		</div>
	);
}

const getPrimaryColor = (catalog) => {
	switch (catalog.replace("Xenos - ", "")) {
		case "Imperium - Adeptus Astartes":
			return "#536766";
		case "Imperium - Adeptus Astartes - Blood Angels":
			return "#761119";
		case "Imperium - Adeptus Astartes - Space Wolves":
			return "#3e646f";
		case "Imperium - Adeptus Astartes - Imperial Fists":
			return "#b87d00";
		case "Imperium - Adeptus Astartes - Raven Guard":
			return "#2b2b2b";
		case "Imperium - Adeptus Astartes - Salamanders":
			return "#1b623f";
		case "Imperium - Adeptus Astartes - White Scars":
			return "#783028";
		case "Imperium - Adeptus Astartes - Dark Angels":
			return "#014419";
		case "Imperium - Adeptus Astartes - Black Templars":
			return "#002f42";
		case "Imperium - Adeptus Astartes - Deathwatch":
			return "#44494d";
		case "Imperium - Adeptus Custodes":
			return "#765c41";
		case "Imperium - Adeptus Mechanicus":
			return "#a03332";
		case "Imperium - Adepta Sororitas":
			return "#5e0a00";
		case "Imperium - Astra Militarum":
			return "#375441";
		case "Imperium - Grey Knights":
			return "#4a6672";
		case "Imperium - Imperial Knights":
			return "#03495e";
		case "Chaos - Daemons":
			return "#383c46";
		case "Chaos - Chaos Space Marines":
			return "#1d3138";
		case "Chaos - World Eaters":
			return "#883531";
		case "Chaos - Death Guard":
			return "#576011";
		case "Chaos - Chaos Knights":
			return "#405c58";
		case "Chaos - Thousand Sons":
			return "#015d68";
		case "Necrons":
			return "#005c2f";
		case "Orks":
			return "#4b6621";
		case "Tyranids":
		case "Tyranids - Genestealer Cults":
			return "#44264C";
		case "Aeldari - Craftworlds":
			return "#1f787f";
		case "Aeldari - Harlequins":
			return "#6f322f";
		case "Leagues of Votann":
			return "#7d4c08";
		case "T'au Empire":
			return "#206173";
		default:
			console.log(catalog);
			return "#536766";
	}
};

export default App;
