/**
 * Teambuilder team editor, extracted from the rest of the Preact
 * client so that it can be used in isolation.
 *
 * @author Guangcong Luo <guangcongluo@gmail.com>
 * @license AGPLv3
 */

import preact from "../js/lib/preact";
import { type Team, Config, PS } from "./client-main";
import { Dex, type ModdedDex, toID, type ID, PSUtils } from "./battle-dex";
import { Teams } from './battle-teams';
import { DexSearch, type SearchRow, type SearchType } from "./battle-dex-search";
import { PSSearchResults } from "./battle-searchresults";
import { BattleNatures, BattleStatNames, type StatName } from "./battle-dex-data";
import { BattleStatGuesser, BattleStatOptimizer, BattleTooltips } from "./battle-tooltips";
import { PSModel } from "./client-core";
import { Net } from "./client-connection";
import { PSIcon } from "./panels";

type SelectionType = 'pokemon' | 'ability' | 'item' | 'move' | 'stats' | 'details';

type SampleSets = {
	[speciesName: string]: {
		[setName: string]: Dex.PokemonSet,
	},
};
type SampleSetsTable = { dex?: SampleSets, stats?: SampleSets };

export class TeamEditorState extends PSModel {
	static clipboard: {
		teams: {
			[teamKey: string]: {
				team: Team,
				sets: { [index: number]: Dex.PokemonSet },
				/** was the team added from the team list rather than the team editor's set list?
				  * (if yes, delete the team itself when moving it) */
				entire: boolean,
			},
		} | null,
		otherSets: Dex.PokemonSet[] | null,
		readonly: boolean,
	} | null = null;
	team: Team;
	sets: Dex.PokemonSet[] = [];
	lastPackedTeam = '';
	gen = Dex.gen;
	dex: ModdedDex = Dex;
	deletedSet: {
		set: Dex.PokemonSet,
		index: number,
	} | null = null;
	search = new DexSearch();
	format: ID = `gen${this.gen}` as ID;
	searchIndex = 0;
	originalSpecies: string | null = null;
	narrow = false;
	selectionTypeOrder: readonly SelectionType[] = [
		'pokemon', 'ability', 'item', 'move', 'stats', 'details',
	];
	innerFocus: {
		setIndex: number,
		type: SelectionType,
		typeIndex?: number,
	} | null = null;
	isLetsGo = false;
	isNatDex = false;
	isBDSP = false;
	formeLegality: 'normal' | 'hackmons' | 'custom' = 'normal';
	abilityLegality: 'normal' | 'hackmons' = 'normal';
	defaultLevel = 100;
	readonly = false;
	fetching = false;
	private userSetsCache: Record<ID, { [species: string]: { [setName: string]: Dex.PokemonSet } }> = {};
	constructor(team: Team) {
		super();
		this.team = team;
		this.updateTeam(false);
		this.setFormat(team.format);
		window.search = this.search;
	}
	updateTeam(readonly: boolean) {
		if (this.lastPackedTeam !== this.team.packedTeam) {
			this.sets = Teams.unpack(this.team.packedTeam);
			this.lastPackedTeam = this.team.packedTeam;
		}
		this.readonly = readonly;
	}
	setFormat(format: string) {
		const team = this.team;
		const formatid = toID(format);
		this.format = formatid;
		team.format = formatid;
		this.dex = Dex.forFormat(formatid);
		this.gen = this.dex.gen;

		format = toID(format).slice(4);
		this.isLetsGo = formatid.includes('letsgo');
		this.isNatDex = formatid.includes('nationaldex') || formatid.includes('natdex');
		this.isBDSP = formatid.includes('bdsp');
		if (formatid.includes('almostanyability') || formatid.includes('aaa')) {
			this.abilityLegality = 'hackmons';
		} else {
			this.abilityLegality = 'normal';
		}
		if (formatid.includes('hackmons') || formatid.includes('bh')) {
			this.formeLegality = 'hackmons';
			this.abilityLegality = 'hackmons';
		} else if (formatid.includes('metronome') || formatid.includes('customgame')) {
			this.formeLegality = 'custom';
			this.abilityLegality = 'hackmons';
		} else {
			this.formeLegality = 'normal';
		}

		this.defaultLevel = 100;
		if (
			formatid.includes('vgc') || formatid.includes('bss') || formatid.includes('ultrasinnohclassic') ||
			formatid.includes('battlespot') || formatid.includes('battlestadium') || formatid.includes('battlefestival')
		) {
			this.defaultLevel = 50;
		}
		if (formatid.includes('lc')) {
			this.defaultLevel = 5;
		}
	}
	setSearchType(type: SearchType, i: number, value?: string) {
		const set = this.sets[i];
		this.search.setType(type, this.format, set);
		this.originalSpecies = null;
		this.search.prependResults = null;
		if (type === 'move') {
			this.search.prependResults = this.getSearchMoves(set);
			if (value && this.search.prependResults.some(row => row[1].split('_')[2] === toID(value))) {
				value = '';
			}
		} else if (value) {
			switch (type) {
			case 'pokemon':
				if (this.dex.species.get(value).exists) {
					this.originalSpecies = value;
					this.search.prependResults = [['pokemon', toID(value)]];
					value = '';
				}
				break;
			case 'item':
				if (toID(value) === 'noitem') value = '';
				if (this.dex.items.get(value).exists) {
					this.search.prependResults = [['item', toID(value)]];
					value = '';
				}
				break;
			case 'ability':
				if (toID(value) === 'selectability') value = '';
				if (toID(value) === 'noability') value = '';
				if (this.dex.abilities.get(value).exists) {
					this.search.prependResults = [['ability', toID(value)]];
					value = '';
				}
				break;
			}
		}

		if (type === 'item') (this.search.prependResults ||= []).push(['item', '' as ID]);
		this.search.find(value || '');
		this.searchIndex = this.search.results?.[0]?.[0] === 'header' ? 1 : 0;
	}
	updateSearchMoves(set: Dex.PokemonSet) {
		let oldResultsLength = this.search.prependResults?.length || 0;
		this.search.prependResults = this.getSearchMoves(set);
		this.searchIndex += this.search.prependResults.length - oldResultsLength;
		if (this.searchIndex < 0) this.searchIndex = 0;
		this.search.results = null;
		if (this.search.query) {
			this.setSearchValue('');
		} else {
			this.search.find('');
		}
	}
	getSearchMoves(set: Dex.PokemonSet) {
		const out: SearchRow[] = [];
		for (let i = 0; i < Math.max(set.moves.length, 4); i++) {
			out.push(['move', `_${i + 1}_${toID(set.moves[i] || '')}` as ID]);
		}
		return out;
	}
	setSearchValue(value: string) {
		this.search.find(value);
		this.searchIndex = this.search.results?.[0]?.[0] === 'header' ? 1 : 0;
	}
	selectSearchValue(): string | null {
		let result = this.search.results?.[this.searchIndex];
		if (result?.[0] === 'header') {
			this.searchIndex++;
			result = this.search.results?.[this.searchIndex];
		}
		if (!result) return null;
		if (this.search.addFilter(result)) {
			this.searchIndex = 0;
			return null;
		}
		return this.getResultValue(result);
	}
	changeSpecies(set: Dex.PokemonSet, speciesName: string) {
		const species = this.dex.species.get(speciesName);
		if (set.item === this.getDefaultItem(set.species)) set.item = undefined;
		if (set.name === set.species.split('-')[0]) delete set.name;
		set.species = species.name;
		set.ability = this.getDefaultAbility(set);
		set.item = this.getDefaultItem(species.name) ?? set.item;

		if (toID(speciesName) === 'Cathy') {
			set.name = "Cathy";
			set.species = 'Trevenant';
			set.level = undefined;
			set.gender = 'F';
			set.item = 'Starf Berry';
			set.ability = 'Harvest';
			set.moves = ['Substitute', 'Horn Leech', 'Earthquake', 'Phantom Force'];
			set.evs = { hp: 36, atk: 252, def: 0, spa: 0, spd: 0, spe: 220 };
			set.ivs = undefined;
			set.nature = 'Jolly';
		}
	}
	deleteSet(index: number) {
		if (this.sets.length <= index) return;
		this.deletedSet = {
			set: this.sets[index],
			index,
		};
		this.sets.splice(index, 1);
	}
	undeleteSet() {
		if (!this.deletedSet) return;
		this.sets.splice(this.deletedSet.index, 0, this.deletedSet.set);
		this.deletedSet = null;
	}
	copySet(index: number) {
		if (this.sets.length <= index) return;

		TeamEditorState.clipboard ||= {
			teams: {},
			otherSets: null,
			readonly: false,
		};
		TeamEditorState.clipboard.teams ||= {};
		TeamEditorState.clipboard.teams[this.team.key] ||= {
			team: this.team, sets: {}, entire: false,
		};
		if (this.readonly) TeamEditorState.clipboard.readonly = true;

		if (TeamEditorState.clipboard.teams[this.team.key].sets[index] === this.sets[index]) {
			// remove
			TeamEditorState.clipboard.teams[this.team.key].entire = false;
			delete TeamEditorState.clipboard.teams[this.team.key].sets[index];
			if (!Object.keys(TeamEditorState.clipboard.teams[this.team.key].sets).length) {
				delete TeamEditorState.clipboard.teams[this.team.key];
			}
			if (!Object.keys(TeamEditorState.clipboard.teams).length) {
				TeamEditorState.clipboard.teams = null;
				if (!TeamEditorState.clipboard.otherSets) {
					TeamEditorState.clipboard = null;
				}
			}
			return;
		}
		TeamEditorState.clipboard.teams[this.team.key].sets[index] = this.sets[index];
	}
	static copyTeam(team: Team) {
		TeamEditorState.clipboard ||= {
			teams: {},
			otherSets: null,
			readonly: false,
		};
		TeamEditorState.clipboard.teams ||= {};

		if (TeamEditorState.clipboard.teams[team.key]) {
			// remove
			delete TeamEditorState.clipboard.teams[team.key];
			if (!Object.keys(TeamEditorState.clipboard.teams).length) {
				TeamEditorState.clipboard.teams = null;
				if (!TeamEditorState.clipboard.otherSets) {
					TeamEditorState.clipboard = null;
				}
			}
			return;
		}
		TeamEditorState.clipboard.teams[team.key] ||= {
			team, sets: {}, entire: true,
		};
		const sets = Teams.unpack(team.packedTeam);
		for (let i = 0; i < sets.length; i++) {
			TeamEditorState.clipboard.teams[team.key].sets[i] = sets[i];
		}
	}
	pasteSet(index: number, isMove?: boolean) {
		if (!TeamEditorState.clipboard) return;
		if (this.readonly) return;

		if (isMove) {
			if (TeamEditorState.clipboard.readonly) return;

			for (const key in TeamEditorState.clipboard.teams) {
				const clipboardTeam = TeamEditorState.clipboard.teams[key];
				const sources = Object.keys(clipboardTeam.sets).map(Number);
				// descending order, so splices won't affect future indices
				sources.sort((a, b) => -(a - b));
				for (const source of sources) {
					if (key === this.team.key) {
						this.sets.splice(source, 1);
						if (source < index) index--;
					} else {
						const team = clipboardTeam.team;
						const sets = Teams.unpack(team.packedTeam);
						sets.splice(source, 1);
						team.packedTeam = Teams.pack(sets);
						team.iconCache = null;
					}
				}
			}
		}

		const sets: Dex.PokemonSet[] = [];
		for (const key in TeamEditorState.clipboard.teams) {
			const clipboardTeam = TeamEditorState.clipboard.teams[key];
			for (const set of Object.values(clipboardTeam.sets)) {
				sets.push(set);
			}
		}
		sets.push(...TeamEditorState.clipboard.otherSets || []);

		for (const set of sets) {
			// not the most efficient way to deepclone but we don't need efficiency here
			const newSet = JSON.parse(JSON.stringify(set)) as Dex.PokemonSet;
			this.sets.splice(index, 0, newSet);
			index++;
		}
		TeamEditorState.clipboard = null;
		this.save();
	}
	static pasteTeam(index: number, isMove?: boolean, folder = '') {
		if (!TeamEditorState.clipboard) return;

		if (isMove) {
			if (TeamEditorState.clipboard.readonly) return;

			const indexesToRemove: number[] = [];
			for (const key in TeamEditorState.clipboard.teams) {
				if (TeamEditorState.clipboard.teams[key].entire) {
					const team = TeamEditorState.clipboard.teams[key].team;
					const i = PS.teams.list.indexOf(team);
					if (i >= 0) indexesToRemove.push(i);
				}
			}
			// descending order, so splices won't affect future indices
			indexesToRemove.sort((a, b) => -(a - b));
			for (const i of indexesToRemove) {
				PS.teams.list.splice(i, 1);
				if (i < index) index--;
			}
		}

		const teams: Team[] = [];

		const sets: Teams.PokemonSet[] = [];
		for (const key in TeamEditorState.clipboard.teams) {
			const clipboardTeam = TeamEditorState.clipboard.teams[key];
			if (clipboardTeam.entire) {
				if (isMove) {
					teams.push(clipboardTeam.team);
					clipboardTeam.team.folder = folder;
				} else {
					const team: Team = {
						name: `${clipboardTeam.team.name} (copy)`,
						format: clipboardTeam.team.format,
						folder,
						packedTeam: clipboardTeam.team.packedTeam,
						isBox: clipboardTeam.team.isBox,
						iconCache: null,
						key: '',
					};
					teams.push(team);
				}
			} else {
				for (const set of Object.values(clipboardTeam.sets)) {
					sets.push(set);
				}
			}
		}
		sets.push(...TeamEditorState.clipboard.otherSets || []);
		if (sets.length) {
			const team: Team = {
				name: `Pasted Team`,
				format: Dex.modid,
				folder,
				packedTeam: Teams.pack(sets),
				isBox: false,
				iconCache: null,
				key: '',
			};
			teams.push(team);
		}

		PS.teams.spliceIn(index, teams);

		TeamEditorState.clipboard = null;
	}
	ignoreRows = ['header', 'sortpokemon', 'sortmove', 'html'];
	downSearchValue() {
		if (!this.search.results || this.searchIndex >= this.search.results.length - 1) return;

		this.searchIndex++;
		if (this.ignoreRows.includes(this.search.results[this.searchIndex]?.[0])) {
			if (this.searchIndex >= this.search.results.length - 1) return;
			this.searchIndex++;
		}
		if (this.ignoreRows.includes(this.search.results[this.searchIndex]?.[0])) {
			if (this.searchIndex >= this.search.results.length - 1) return;
			this.searchIndex++;
		}
	}
	upSearchValue() {
		if (!this.search.results || this.searchIndex <= 0) return;

		if (this.searchIndex <= 1 && this.ignoreRows.includes(this.search.results[0]?.[0])) return;
		this.searchIndex--;
		if (this.ignoreRows.includes(this.search.results[this.searchIndex]?.[0])) {
			if (this.searchIndex <= 0) return;
			this.searchIndex--;
		}
		if (this.ignoreRows.includes(this.search.results[this.searchIndex]?.[0])) {
			if (this.searchIndex <= 0) return;
			this.searchIndex--;
		}
	}
	getResultValue(result: SearchRow): string {
		switch (result[0]) {
		case 'pokemon':
			return this.dex.species.get(result[1]).name;
		case 'item':
			return this.dex.items.get(result[1]).name;
		case 'ability':
			return this.dex.abilities.get(result[1]).name;
		case 'move':
			if (result[1].startsWith('_')) {
				const [slot, moveid] = result[1].slice(1).split('_');
				return this.dex.moves.get(moveid).name + '|' + slot;
			}
			return this.dex.moves.get(result[1]).name;
		case 'html':
		case 'header':
			return '';
		default:
			return result[1];
		}
	}
	canAdd(): boolean {
		return this.sets.length < 6 || this.team.isBox;
	}
	getHPType(set: Dex.PokemonSet): Dex.TypeName {
		if (set.hpType) return set.hpType as Dex.TypeName;
		const hpMove = set.ivs ? null : this.getHPMove(set);
		if (hpMove) return hpMove;

		const hpTypes = [
			'Fighting', 'Flying', 'Poison', 'Ground', 'Rock', 'Bug', 'Ghost', 'Steel', 'Fire', 'Water', 'Grass', 'Electric', 'Psychic', 'Ice', 'Dragon', 'Dark',
		] as const;
		if (this.gen <= 2) {
			if (!set.ivs) return 'Dark';
			// const hpDV = Math.floor(set.ivs.hp / 2);
			const atkDV = Math.floor(set.ivs.atk / 2);
			const defDV = Math.floor(set.ivs.def / 2);
			// const speDV = Math.floor(set.ivs.spe / 2);
			// const spcDV = Math.floor(set.ivs.spa / 2);
			// const expectedHpDV = (atkDV % 2) * 8 + (defDV % 2) * 4 + (speDV % 2) * 2 + (spcDV % 2);
			// if (expectedHpDV !== hpDV) {
			// 	set.ivs.hp = expectedHpDV * 2;
			// 	if (set.ivs.hp === 30) set.ivs.hp = 31;
			// }
			return hpTypes[4 * (atkDV % 4) + (defDV % 4)];
		} else {
			const ivs = set.ivs || this.defaultIVs(set);
			let hpTypeX = 0;
			let i = 1;
			// n.b. this is not our usual order (Spe and SpD are flipped)
			const statOrder = ['hp', 'atk', 'def', 'spe', 'spa', 'spd'] as const;
			for (const s of statOrder) {
				if (ivs[s] === undefined) ivs[s] = 31;
				hpTypeX += i * (ivs[s] % 2);
				i *= 2;
			}
			return hpTypes[Math.floor(hpTypeX * 15 / 63)];
		}
	};
	hpTypeMatters(set: Dex.PokemonSet): boolean {
		if (this.gen < 2) return false;
		if (this.gen > 7) return false;
		for (const move of set.moves) {
			const moveid = toID(move);
			if (moveid.startsWith('hiddenpower')) return true;
			if (moveid === 'transform') return true;
		}
		if (toID(set.ability) === 'imposter') return true;
		return false;
	}
	getHPMove(set: Dex.PokemonSet): Dex.TypeName | null {
		if (set.moves) {
			for (const move of set.moves) {
				const moveid = toID(move);
				if (moveid.startsWith('hiddenpower')) {
					return moveid.charAt(11).toUpperCase() + moveid.slice(12) as Dex.TypeName;
				}
			}
		}
		return null;
	}
	getIVs(set: Dex.PokemonSet) {
		const ivs = this.defaultIVs(set);
		if (set.ivs) Object.assign(ivs, set.ivs);
		return ivs;
	}
	defaultIVs(set: Dex.PokemonSet, noGuess = !!set.ivs): Record<Dex.StatName, number> {
		const useIVs = this.gen > 2;
		const defaultIVs = { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
		if (!useIVs) {
			for (const stat of Dex.statNames) defaultIVs[stat] = 15;
		}
		if (noGuess) return defaultIVs;

		const hpType = this.getHPMove(set);
		const hpModulo = (useIVs ? 2 : 4);
		const { minAtk, minSpe } = this.prefersMinStats(set);
		if (minAtk) defaultIVs['atk'] = 0;
		if (minSpe) defaultIVs['spe'] = 0;

		if (!useIVs) {
			const hpDVs = hpType ? this.dex.types.get(hpType).HPdvs : null;
			if (hpDVs) {
				for (const stat in hpDVs) defaultIVs[stat as Dex.StatName] = hpDVs[stat as Dex.StatName]!;
			}
		} else {
			const hpIVs = hpType ? this.dex.types.get(hpType).HPivs : null;
			if (hpIVs) {
				if (this.canHyperTrain(set)) {
					if (minSpe) defaultIVs['spe'] = hpIVs['spe'] ?? 31;
					if (minAtk) defaultIVs['atk'] = hpIVs['atk'] ?? 31;
				} else {
					for (const stat in hpIVs) defaultIVs[stat as Dex.StatName] = hpIVs[stat as Dex.StatName]!;
				}
			}
		}

		if (hpType) {
			if (minSpe) defaultIVs['spe'] %= hpModulo;
			if (minAtk) defaultIVs['atk'] %= hpModulo;
		}
		if (minAtk && useIVs) {
			// min Atk
			if (['Gouging Fire', 'Iron Boulder', 'Iron Crown', 'Raging Bolt'].includes(set.species)) {
				// only available with 20 Atk IVs
				defaultIVs['atk'] = 20;
			} else if (set.species.startsWith('Terapagos')) {
				// only available with 15 Atk IVs
				defaultIVs['atk'] = 15;
			}
		}
		return defaultIVs;
	}
	defaultHappiness(set: Dex.PokemonSet) {
		if (set.moves.includes('Return')) return 255;
		if (set.moves.includes('Frustration')) return 0;
		return undefined;
	}
	prefersMinStats(set: Dex.PokemonSet) {
		let minSpe = !set.evs?.spe && set.moves.includes('Gyro Ball');
		let minAtk = !set.evs?.atk;

		// only available through an event with 31 Spe IVs
		if (set.species.startsWith('Terapagos')) minSpe = false;

		const preferMaxAtkFormats = ['1v1', 'categoryswap', 'partnersincrime', 'typesplit'];
		if (preferMaxAtkFormats.some(f => this.format.includes(f))) {
			minAtk = false;
			return { minAtk, minSpe };
		}
		if (this.format === 'gen7hiddentype') return { minAtk, minSpe };

		// only available through an event with 31 Atk IVs
		if (set.ability === 'Battle Bond' || ['Koraidon', 'Miraidon', 'Gimmighoul-Roaming'].includes(set.species)) {
			minAtk = false;
			return { minAtk, minSpe };
		}
		if (!set.moves.length) minAtk = false;
		for (const moveName of set.moves) {
			if (!moveName) continue;
			const move = this.dex.moves.get(moveName);
			if (move.id === 'transform') {
				const hasMoveBesidesTransform = set.moves.length > 1;
				if (!hasMoveBesidesTransform) minAtk = false;
			} else if (
				move.category === 'Physical' && !move.damage && !move.ohko &&
				!['foulplay', 'endeavor', 'counter', 'bodypress', 'seismictoss', 'bide', 'metalburst', 'superfang'].includes(move.id) &&
				!(this.gen < 8 && move.id === 'rapidspin')
			) {
				minAtk = false;
			} else if (
				['metronome', 'assist', 'copycat', 'mefirst', 'photongeyser', 'shellsidearm', 'terablast'].includes(move.id) ||
				(this.gen === 5 && move.id === 'naturepower')
			) {
				minAtk = false;
			}
		}

		return { minAtk, minSpe };
	}
	getNickname(set: Dex.PokemonSet) {
		return set.name || this.dex.species.get(set.species).baseSpecies || '';
	}
	canHyperTrain(set: Dex.PokemonSet) {
		let format: string = this.format;
		if (this.gen < 7 || format === 'gen7hiddentype') return false;
		if ((set.level || this.defaultLevel) === 100) return true;
		if ((set.level || this.defaultLevel) >= 50 && this.defaultLevel === 50) return true;
		return false;
	}
	getHPIVs(hpType: Dex.TypeName | null) {
		switch (hpType) {
		case 'Dark':
			return ['111111'];
		case 'Dragon':
			return ['011111', '101111', '110111'];
		case 'Ice':
			return ['010111', '100111', '111110'];
		case 'Psychic':
			return ['011110', '101110', '110110'];
		case 'Electric':
			return ['010110', '100110', '111011'];
		case 'Grass':
			return ['011011', '101011', '110011'];
		case 'Water':
			return ['100011', '111010'];
		case 'Fire':
			return ['101010', '110010'];
		case 'Steel':
			return ['100010', '111101'];
		case 'Ghost':
			return ['101101', '110101'];
		case 'Bug':
			return ['100101', '111100', '101100'];
		case 'Rock':
			return ['001100', '110100', '100100'];
		case 'Ground':
			return ['000100', '111001', '101001'];
		case 'Poison':
			return ['001001', '110001', '100001'];
		case 'Flying':
			return ['000001', '111000', '101000'];
		case 'Fighting':
			return ['001000', '110000', '100000'];
		default:
			return null;
		}
	}
	getStat(stat: StatName, set: Dex.PokemonSet, ivOverride: number, evOverride?: number, natureOverride?: number) {
		const supportsEVs = !this.isLetsGo;
		const supportsAVs = !supportsEVs;

		// do this after setting set.evs because it's assumed to exist
		// after getStat is run
		const species = this.dex.species.get(set.species);
		if (!species.exists) return 0;

		const level = set.level || this.defaultLevel;

		const baseStat = species.baseStats[stat];
		const iv = ivOverride;
		const ev = evOverride ?? set.evs?.[stat] ?? (this.gen > 2 ? 0 : 252);

		if (stat === 'hp') {
			if (baseStat === 1) return 1;
			if (!supportsEVs) return Math.trunc(Math.trunc(2 * baseStat + iv + 100) * level / 100 + 10) + (supportsAVs ? ev : 0);
			return Math.trunc(Math.trunc(2 * baseStat + iv + Math.trunc(ev / 4) + 100) * level / 100 + 10);
		}
		let val = Math.trunc(Math.trunc(2 * baseStat + iv + Math.trunc(ev / 4)) * level / 100 + 5);
		if (!supportsEVs) {
			val = Math.trunc(Math.trunc(2 * baseStat + iv) * level / 100 + 5);
		}
		if (natureOverride) {
			val *= natureOverride;
		} else if (BattleNatures[set.nature!]?.plus === stat) {
			val *= 1.1;
		} else if (BattleNatures[set.nature!]?.minus === stat) {
			val *= 0.9;
		}
		if (!supportsEVs) {
			const friendshipValue = Math.trunc((70 / 255 / 10 + 1) * 100);
			val = Math.trunc(val) * friendshipValue / 100 + (supportsAVs ? ev : 0);
		}
		return Math.trunc(val);
	}
	export(compat?: boolean) {
		return Teams.export(this.sets, this.dex, !compat);
	}
	import(value: string) {
		this.sets = Teams.import(value);
		this.save();
	}
	getTypeWeakness(type: Dex.TypeName, attackType: Dex.TypeName): 0 | 0.5 | 1 | 2 {
		const weaknessType = this.dex.types.get(type).damageTaken?.[attackType];
		if (weaknessType === Dex.IMMUNE) return 0;
		if (weaknessType === Dex.RESIST) return 0.5;
		if (weaknessType === Dex.WEAK) return 2;
		return 1;
	}
	getWeakness(types: readonly Dex.TypeName[], abilityid: ID, attackType: Dex.TypeName): number {
		const abilityFactor = BattleTooltips.getTypeAbilityWeakness(attackType, abilityid, this.dex);
		if (abilityFactor === 0) return 0;

		if (abilityid === 'wonderguard') {
			for (const type of types) {
				if (this.getTypeWeakness(type, attackType) <= 1) return 0;
			}
		}

		let factor = abilityFactor;
		for (const type of types) {
			factor *= this.getTypeWeakness(type, attackType);
		}
		return factor;
	}
	pokemonDefensiveCoverage(set: Dex.PokemonSet) {
		const coverage: Record<string, number> = {};
		const species = this.dex.species.get(set.species);
		const abilityid = toID(set.ability);
		for (const type of this.dex.types.names()) {
			coverage[type] = this.getWeakness(species.types, abilityid, type);
		}
		return coverage as Record<Dex.TypeName, number>;
	}
	teamDefensiveCoverage() {
		type Counter = { type: Dex.TypeName, resists: number, neutrals: number, weaknesses: number };
		const counters: Record<Dex.TypeName, Counter> = {} as any;
		for (const type of this.dex.types.names()) {
			counters[type] = {
				type,
				resists: 0,
				neutrals: 0,
				weaknesses: 0,
			};
		}
		for (const set of this.sets) {
			const coverage = this.pokemonDefensiveCoverage(set);
			for (const [type, value] of Object.entries(coverage) as [Dex.TypeName, number][]) {
				if (value < 1) {
					counters[type].resists++;
				} else if (value === 1) {
					counters[type].neutrals++;
				} else {
					counters[type].weaknesses++;
				}
			}
		}
		return counters;
	}
	getDefaultAbility(set: Dex.PokemonSet) {
		if (this.gen < 3 || this.isLetsGo || this.formeLegality === 'custom') return set.ability;
		const species = this.dex.species.get(set.species);
		if (this.formeLegality === 'hackmons') {
			// TODO: support gen 9 hackmons forme legality more completely than this
			if (this.gen < 9 || species.baseSpecies !== 'Xerneas') return set.ability;
			// falls through to final return statement
		} else if (this.abilityLegality === 'hackmons') {
			if (!species.battleOnly) return set.ability;
			if (species.requiredItems.length || species.baseSpecies === 'Meloetta') return set.ability;
			// battle only species only ever have one ability
			// if they don't have a required item and aren't Meloetta, they change formes with that ability
			// so it's forced, even in AAA
			return species.abilities[0];
		}
		const abilities = Object.values(species.abilities);
		if (abilities.length === 1) return abilities[0];
		if (set.ability && abilities.includes(set.ability)) return set.ability;
		return undefined;
	}
	getDefaultItem(speciesName: string) {
		const species = this.dex.species.get(speciesName);
		let items = species.requiredItems;
		if (this.gen !== 7 && !this.isNatDex) {
			// Require plates on Arceus when Z crystals don't exist
			items = items.filter(i => !i.endsWith('ium Z'));
		}
		if (items.length === 1) {
			if (this.formeLegality === 'normal' ||
				this.formeLegality === 'hackmons' && this.gen === 9 && species.battleOnly &&
				!species.isMega && !species.isPrimal && species.name !== 'Necrozma-Ultra') {
				return items[0];
			}
		}
		return undefined;
	}
	save() {
		this.team.packedTeam = Teams.pack(this.sets);
		this.lastPackedTeam = this.team.packedTeam;
		this.team.iconCache = null;
	}

	/** undefined: loading, null: unavailable */
	static sampleSets: { [formatid: string]: SampleSetsTable | null } = {};
	// not static for complicated reasons. either way leads to an obscure
	// race condition if fetchSampleSets is called simultaneously from
	// different TeamEditorState instances, but this way just means two
	// network requests rather than the UI getting out of sync.
	_sampleSetPromises: Record<string, Promise<void>> = {};
	fetchSampleSets(formatid: ID) {
		if (formatid in TeamEditorState.sampleSets) return;
		if (formatid.length <= 4) {
			TeamEditorState.sampleSets[formatid] = null;
			return;
		}
		if (!(formatid in this._sampleSetPromises)) {
			this._sampleSetPromises[formatid] = Net(
				`https://${Config.routes.client}/data/sets/${formatid}.json`
			).get().then(json => {
				const data = JSON.parse(json);
				TeamEditorState.sampleSets[formatid] = data;
				this.update();
			}).catch(() => {
				TeamEditorState.sampleSets[formatid] = null;
			});
		}
	}
	/** returns null if sample sets aren't done loading */
	getSampleSets(set: Dex.PokemonSet): string[] | null {
		const d = TeamEditorState.sampleSets[this.format];
		if (d === undefined) {
			this.fetchSampleSets(this.format);
			return null;
		}
		if (!d?.dex) return [];
		const speciesid = toID(set.species);
		const all = {
			...d.dex[set.species],
			...d.dex[speciesid],
			...d.stats?.[set.species],
			...d.stats?.[speciesid],
		};
		return Object.keys(all);
	}
	/** returns null if no boxes exist, empty array if no sets for this species */
	getUserSets(set: Dex.PokemonSet): { [setName: string]: Dex.PokemonSet } | null {
		if (!this.userSetsCache[this.format]) {
			const userSets: { [species: string]: { [setName: string]: Dex.PokemonSet } } = {};

			for (const team of window.PS?.teams.list || []) {
				if (team.format !== this.format || !team.isBox) continue;

				const setList = Teams.unpack(team.packedTeam);
				const duplicateNameIndices: Record<string, number> = {};

				for (const boxSet of setList) {
					let name = boxSet.name || boxSet.species;
					if (duplicateNameIndices[name]) {
						name += ` ${duplicateNameIndices[name]}`;
					}
					duplicateNameIndices[name] = (duplicateNameIndices[name] || 0) + 1;

					userSets[boxSet.species] ??= {};
					userSets[boxSet.species][name] = boxSet;
				}
			}

			this.userSetsCache[this.format] = userSets;
		}

		const cachedSets = this.userSetsCache[this.format];
		if (Object.keys(cachedSets).length === 0) return null;
		return cachedSets[set.species] || {};
	}

	static renderClipboard(cancelClipboard: () => void) {
		if (!TeamEditorState.clipboard) return null;

		const renderSet = (set: Dex.PokemonSet) => <div class="set">
			<small>
				<PSIcon pokemon={set} /> {set.name || set.species}
				{set.ability && ` [${set.ability}]`}{set.item && ` @ ${set.item}`}
				{} - {set.moves.join(' / ') || '(No moves)'}
			</small>
		</div>;
		const renderTeam = (team: Team, sets: Dex.PokemonSet[]) => <div class="set"><small>
			<strong>{team.name}</strong><br />
			{sets.map(set => <PSIcon pokemon={set} />)}
		</small></div>;

		return <div class="infobox">
			Clipboard
			{Object.values(TeamEditorState.clipboard.teams || {})?.map(clipboardTeam => (
				clipboardTeam.entire ? (
					renderTeam(clipboardTeam.team, Object.values(clipboardTeam.sets))
				) : (
					Object.values(clipboardTeam.sets).map(set => renderSet(set))
				)
			))}
			{TeamEditorState.clipboard.otherSets?.map(set => renderSet(set))}
			<button class="button" onClick={cancelClipboard}>
				<i class="fa fa-times" aria-hidden></i> Cancel
			</button>
		</div>;
	}
}

export class TeamEditor extends preact.Component<{
	team: Team, narrow?: boolean, onChange?: () => void, readOnly?: boolean,
	children?: preact.ComponentChildren, resources?: preact.ComponentChildren,
}> {
	wizard = true;
	editor!: TeamEditorState;
	setTab = (ev: Event) => {
		const target = ev.currentTarget as HTMLButtonElement;
		const wizard = target.value === 'wizard';
		this.wizard = wizard;
		this.forceUpdate();
	};
	static probablyMobile() {
		return document.body.offsetWidth < 500;
	}
	renderDefensiveCoverage() {
		const { editor } = this;
		if (editor.team.isBox) return null;
		if (!editor.sets.length) return null;

		const counters = Object.values(editor.teamDefensiveCoverage());
		PSUtils.sortBy(counters, counter => [counter.resists, -counter.weaknesses]);
		const good = [], medium = [], bad = [];
		const renderTypeDefensive = (counter: typeof counters[number]) => (
			<tr>
				<th>{counter.type}</th>
				<td>{counter.resists} <small class="gray">resist</small></td>
				<td>{counter.weaknesses} <small class="gray">weak</small></td>
			</tr>
		);
		for (const counter of counters) {
			if (counter.resists > 0) {
				good.push(renderTypeDefensive(counter));
			} else if (counter.weaknesses <= 0) {
				medium.push(renderTypeDefensive(counter));
			} else {
				bad.push(renderTypeDefensive(counter));
			}
		}
		return <details class="details">
			<summary>
				<strong>Defensive coverage</strong>
				<table class="details-preview table">
					{bad}
					<tr><td colSpan={3}><span class="details-preview ilink"><small>See all</small></span></td></tr>
				</table>
			</summary>
			<table class="table">{bad}{medium}{good}</table>
		</details>;
	}
	cancelClipboard = () => {
		TeamEditorState.clipboard = null;
		this.forceUpdate();
	};
	update = () => {
		this.forceUpdate();
	};
	override render() {
		if (!this.editor) {
			this.editor = new TeamEditorState(this.props.team);
			this.editor.subscribe(() => {
				this.forceUpdate();
			});
		}
		const editor = this.editor;
		window.editor = editor; // debug
		editor.updateTeam(!!this.props.readOnly);
		editor.narrow = this.props.narrow ?? document.body.offsetWidth < 500;
		if (this.props.team.format !== editor.format) {
			editor.setFormat(this.props.team.format);
		}

		return <div class="teameditor">
			<ul class="tabbar">
				<li><button onClick={this.setTab} value="wizard" class={`button${this.wizard ? ' cur' : ''}`}>
					Wizard
				</button></li>
				<li><button onClick={this.setTab} value="import" class={`button${!this.wizard ? ' cur' : ''}`}>
					Import/Export
				</button></li>
			</ul>
			{TeamEditorState.renderClipboard(this.cancelClipboard)}
			{this.wizard ? (
				<TeamWizard editor={editor} onChange={this.props.onChange} onUpdate={this.update} />
			) : (
				<TeamTextbox editor={editor} onChange={this.props.onChange} onUpdate={this.update} />
			)}
			{!this.editor.innerFocus && <>
				{this.props.children}
				<div class="team-resources">
					<br /><hr /><br />
					{this.renderDefensiveCoverage()}
					{this.props.resources}
				</div>
			</>}
		</div>;
	}
}

class TeamTextbox extends preact.Component<{
	editor: TeamEditorState,
	onChange?: () => void, onUpdate?: () => void,
}> {
	static EMPTY_PROMISE = Promise.resolve(null);
	editor!: TeamEditorState;
	setInfo: {
		species: string,
		bottomY: number,
		index: number,
	}[] = [];
	textbox: HTMLTextAreaElement = null!;
	heightTester: HTMLTextAreaElement = null!;
	compat = false;
	/** we changed the set but are delaying updates until the selection form is closed */
	setDirty = false;
	windowing = true;
	selection: {
		setIndex: number,
		type: SelectionType | null,
		typeIndex: number,
		lineRange: [number, number] | null,
	} | null = null;
	innerFocus: {
		offsetY: number | null,
		setIndex: number,
		type: SelectionType,
		/** i.e. which move is this */
		typeIndex: number,
		range: [number, number],
		/** if you edit, you'll change the range end, so it needs to be updated with this in mind */
		rangeEndChar: string,
	} | null = null;
	getYAt(index: number, fullLine?: boolean) {
		if (index < 0) return 10;
		if (index === 0) return 31;
		const newValue = this.textbox.value.slice(0, index);
		this.heightTester.value = fullLine && !newValue.endsWith('\n') ? newValue + '\n' : newValue;
		return this.heightTester.scrollHeight;
	}
	input = () => {
		this.updateText();
		this.save();
	};
	keyUp = () => this.updateText(true);
	contextMenu = (ev: MouseEvent) => {
		if (!ev.shiftKey) {
			const hadInnerFocus = this.innerFocus?.range[1];
			this.openInnerFocus();
			if (hadInnerFocus !== this.innerFocus?.range[1]) {
				ev.preventDefault();
				ev.stopImmediatePropagation();
			}
		}
	};
	openInnerFocus() {
		const oldRange = this.selection?.lineRange;
		this.updateText(true, true);
		if (this.selection) {
			// this shouldn't actually update anything, so the reference comparison is enough
			if (this.selection.lineRange === oldRange) return !!this.innerFocus;
			if (this.textbox.selectionStart === this.textbox.selectionEnd) {
				const range = this.getSelectionTypeRange();
				if (range) this.textbox.setSelectionRange(range[0], range[1]);
			}
		}
		return !!this.innerFocus;
	}
	keyDown = (ev: KeyboardEvent) => {
		const editor = this.editor;
		switch (ev.keyCode) {
		case 27: // escape
		case 8: // backspace
			if (this.innerFocus) {
				const atStart = (this.innerFocus.range[0] === this.textbox.selectionStart &&
					this.innerFocus.range[0] === this.textbox.selectionEnd);
				if (ev.keyCode === 27 || atStart) {
					if (editor.search.removeFilter()) {
						editor.setSearchValue(this.getInnerFocusValue());
						this.resetScroll();
						this.forceUpdate();
						ev.stopImmediatePropagation();
						ev.preventDefault();
					} else if (this.closeMenu()) {
						ev.stopImmediatePropagation();
						ev.preventDefault();
					}
				}
			}
			break;
		case 38: // up
			if (this.innerFocus) {
				editor.upSearchValue();
				const resultsUp = this.base!.querySelector('.searchresults');
				if (resultsUp) {
					resultsUp.scrollTop = Math.max(0, editor.searchIndex * 33 - Math.trunc((window.innerHeight - 100) * 0.4));
				}
				this.forceUpdate();
				ev.preventDefault();
			}
			break;
		case 40: // down
			if (this.innerFocus) {
				editor.downSearchValue();
				const resultsDown = this.base!.querySelector('.searchresults');
				if (resultsDown) {
					resultsDown.scrollTop = Math.max(0, editor.searchIndex * 33 - Math.trunc((window.innerHeight - 100) * 0.4));
				}
				this.forceUpdate();
				ev.preventDefault();
			}
			break;
		case 9: // tab
		case 13: // enter
			if (ev.keyCode === 13 && ev.shiftKey) return;
			if (ev.altKey || ev.metaKey) return;
			if (!this.innerFocus) {
				if (this.maybeReplaceLine()) {
					// do nothing else
				} else if (
					this.textbox.selectionStart === this.textbox.value.length &&
					(this.textbox.value.endsWith('\n\n') || !this.textbox.value)
				) {
					this.addPokemon();
				} else if (!this.openInnerFocus()) {
					break;
				}
				ev.stopImmediatePropagation();
				ev.preventDefault();
			} else {
				const result = this.editor.selectSearchValue();
				if (result !== null) {
					const [name, moveSlot] = result.split('|');
					this.selectResult(this.innerFocus.type, name, moveSlot);
				} else {
					this.replaceNoFocus('', this.innerFocus.range[0], this.innerFocus.range[1]);
					this.editor.setSearchValue('');
					this.forceUpdate();
				}
				this.resetScroll();
				ev.stopImmediatePropagation();
				ev.preventDefault();
			}
			break;
		case 80: // p
			if (ev.metaKey) {
				window.PS?.alert(editor.export(this.compat));
				ev.stopImmediatePropagation();
				ev.preventDefault();
				break;
			}
		}
	};
	maybeReplaceLine = () => {
		if (this.textbox.selectionStart !== this.textbox.selectionEnd) return;
		const current = this.textbox.selectionEnd;
		const lineStart = this.textbox.value.lastIndexOf('\n', current) + 1;
		const value = this.textbox.value.slice(lineStart, current);

		const pokepaste = /^https?:\/\/pokepast.es\/([a-z0-9]+)(?:\/.*)?$/.exec(value)?.[1];
		if (pokepaste) {
			this.editor.fetching = true;
			Net(`https://pokepast.es/${pokepaste}/json`).get().then(json => {
				const paste = JSON.parse(json);
				const pasteTxt = paste.paste.replace(/\r\n/g, '\n');
				if (this.textbox) {
					// make sure it's still there:
					const valueIndex = this.textbox.value.indexOf(value);
					this.replace(paste.paste.replace(/\r\n/g, '\n'), valueIndex, valueIndex + value.length);
				} else {
					this.editor.import(pasteTxt);
					this.props.onChange?.();
				}
				const notes = paste["notes"] as string;
				if (notes.startsWith("Format: ")) {
					const formatid = toID(notes.slice(8));
					this.editor.setFormat(formatid);
				}
				const title = paste["title"] as string;
				if (title && !title.startsWith('Untitled')) {
					this.editor.team.name = title.replace(/[|\\/]/g, '');
				}
				this.editor.fetching = false;
				this.props.onUpdate?.();
			});
			return true;
		}
		return false;
	};
	getInnerFocusValue() {
		if (!this.innerFocus) return '';
		return this.textbox.value.slice(this.innerFocus.range[0], this.innerFocus.range[1]);
	}
	clearInnerFocus() {
		if (this.innerFocus) {
			if (this.innerFocus.type === 'pokemon') {
				const value = this.getInnerFocusValue();
				if (!toID(value)) {
					this.replaceNoFocus(this.editor.originalSpecies || '', this.innerFocus.range[0], this.innerFocus.range[1]);
				}
			}
			this.innerFocus = null;
		}
	}
	closeMenu = () => {
		if (this.innerFocus) {
			this.clearInnerFocus();
			if (this.setDirty) {
				this.updateText();
				this.save();
			} else {
				this.forceUpdate();
			}
			this.textbox.focus();
			return true;
		}
		return false;
	};
	updateText = (noTextChange?: boolean, autoSelect?: boolean | SelectionType) => {
		const textbox = this.textbox;
		let value = textbox.value;
		let selectionStart = textbox.selectionStart || 0;
		let selectionEnd = textbox.selectionEnd || 0;

		if (this.innerFocus) {
			if (!noTextChange) {
				let lineEnd = this.textbox.value.indexOf('\n', this.innerFocus.range[0]);
				if (lineEnd < 0) lineEnd = this.textbox.value.length;
				const line = this.textbox.value.slice(this.innerFocus.range[0], lineEnd);
				if (this.innerFocus.rangeEndChar) {
					const index = line.indexOf(this.innerFocus.rangeEndChar);
					if (index >= 0) lineEnd = this.innerFocus.range[0] + index;
				}
				this.innerFocus.range[1] = lineEnd;
			}
			const [start, end] = this.innerFocus.range;
			if (selectionStart >= start && selectionStart <= end && selectionEnd >= start && selectionEnd <= end) {
				if (!noTextChange) {
					this.updateSearch();
					this.setDirty = true;
				}
				return;
			}
			this.clearInnerFocus();
			value = textbox.value;
			selectionStart = textbox.selectionStart || 0;
			selectionEnd = textbox.selectionEnd || 0;
		}

		if (this.setDirty) {
			this.setDirty = false;
			noTextChange = false;
		}

		this.heightTester.style.width = `${textbox.offsetWidth}px`;
		/** index of `value` that we've parsed to */
		let index = 0;
		/** for the set we're currently parsing */
		let setIndex: number | null = null;
		let nextSetIndex = 0;
		if (!noTextChange) this.setInfo = [];
		this.selection = null;

		while (index < value.length) {
			let nlIndex = value.indexOf('\n', index);
			if (nlIndex < 0) nlIndex = value.length;
			const line = value.slice(index, nlIndex);

			if (!line.trim()) {
				setIndex = null;
				index = nlIndex + 1;
				continue;
			}

			if (setIndex === null && index && !noTextChange && this.setInfo.length) {
				this.setInfo[this.setInfo.length - 1].bottomY = this.getYAt(index - 1);
			}

			if (setIndex === null) {
				if (!noTextChange) {
					const atIndex = line.indexOf('@');
					let species = atIndex >= 0 ? line.slice(0, atIndex).trim() : line.trim();
					if (species.endsWith(' (M)') || species.endsWith(' (F)')) {
						species = species.slice(0, -4);
					}
					if (species.endsWith(')')) {
						const parenIndex = species.lastIndexOf(' (');
						if (parenIndex >= 0) {
							species = species.slice(parenIndex + 2, -1);
						}
					}
					this.setInfo.push({
						species,
						bottomY: -1,
						index,
					});
				}
				setIndex = nextSetIndex;
				nextSetIndex++;
			}

			const selectionEndCutoff = (selectionStart === selectionEnd ? nlIndex : nlIndex + 1);
			let start = index, end = index + line.length;
			if (index <= selectionStart && selectionEnd <= selectionEndCutoff) {
				// both ends within range
				let type: SelectionType | null = null;
				const lcLine = line.toLowerCase().trim();

				if (lcLine.startsWith('ability:')) {
					type = 'ability';
				} else if (lcLine.startsWith('-')) {
					type = 'move';
				} else if (
					!lcLine || lcLine.startsWith('level:') || lcLine.startsWith('gender:') ||
					(lcLine + ':').startsWith('shiny:') || (lcLine + ':').startsWith('gigantamax:') ||
					lcLine.startsWith('tera type:') || lcLine.startsWith('dynamax level:')
				) {
					type = 'details';
				} else if (
					lcLine.startsWith('ivs:') || lcLine.startsWith('evs:') ||
					lcLine.endsWith(' nature')
				) {
					type = 'stats';
				} else {
					type = 'pokemon';
					const atIndex = line.indexOf('@');
					if (atIndex >= 0) {
						if (selectionStart > index + atIndex) {
							type = 'item';
							start = index + atIndex + 1;
						} else {
							end = index + atIndex;
							if (line.charAt(atIndex - 1) === ']' || line.charAt(atIndex - 2) === ']') {
								type = 'ability';
							}
						}
					}
				}

				if (typeof autoSelect === 'string') autoSelect = autoSelect === type;
				this.selection = {
					setIndex, type, lineRange: [start, end], typeIndex: 0,
				};
				if (autoSelect) this.engageFocus();
			}

			index = nlIndex + 1;
		}
		if (!noTextChange) {
			const end = value.endsWith('\n\n') ? value.length - 1 : value.length;
			const bottomY = this.getYAt(end, true);
			if (this.setInfo.length) {
				this.setInfo[this.setInfo.length - 1].bottomY = bottomY;
			}

			textbox.style.height = `${bottomY + 100}px`;
		}
		this.forceUpdate();
	};
	engageFocus(focus?: this['innerFocus']) {
		if (this.innerFocus && !focus) return;
		const editor = this.editor;
		if (editor.readonly) return;

		if (!focus) {
			if (!this.selection?.type) return;

			const range = this.getSelectionTypeRange();
			if (!range) return;
			const { type, setIndex } = this.selection;

			let rangeEndChar = this.textbox.value.charAt(range[1]);
			if (rangeEndChar === ' ') rangeEndChar += this.textbox.value.charAt(range[1] + 1);
			focus = {
				offsetY: this.getYAt(range[0]),
				setIndex,
				type,
				typeIndex: this.selection.typeIndex,
				range,
				rangeEndChar,
			};
		}
		this.innerFocus = focus;

		if (focus.type === 'details' || focus.type === 'stats') {
			this.forceUpdate();
			return;
		}

		const value = this.textbox.value.slice(focus.range[0], focus.range[1]);
		editor.setSearchType(focus.type, focus.setIndex, value);
		this.resetScroll();
		this.textbox.setSelectionRange(focus.range[0], focus.range[1]);
		this.forceUpdate();
	}
	updateSearch() {
		if (!this.innerFocus) return;
		const { range } = this.innerFocus;
		const editor = this.editor;
		const value = this.textbox.value.slice(range[0], range[1]);

		editor.setSearchValue(value);
		this.resetScroll();
		this.forceUpdate();
	}
	selectResult = (type: string | null, name: string, moveSlot?: string) => {
		if (type === null) {
			this.resetScroll();
			this.forceUpdate();
		} else if (!type) {
			this.changeSet(this.innerFocus!.type, '');
		} else {
			this.changeSet(type as SelectionType, name, moveSlot);
		}
	};
	getSelectionTypeRange(): [number, number] | null {
		const selection = this.selection;
		if (!selection?.lineRange) return null;

		let [start, end] = selection.lineRange;
		let lcLine = this.textbox.value.slice(start, end).toLowerCase();
		if (lcLine.endsWith('  ')) {
			end -= 2;
			lcLine = lcLine.slice(0, -2);
		}

		switch (selection.type) {
		case 'pokemon': {
			// let atIndex = lcLine.lastIndexOf('@');
			// if (atIndex >= 0) {
			// 	if (lcLine.charAt(atIndex - 1) === ' ') atIndex--;
			// 	lcLine = lcLine.slice(0, atIndex);
			// 	end = start + atIndex;
			// }

			if (lcLine.endsWith(' ')) {
				lcLine = lcLine.slice(0, -1);
				end--;
			}

			if (lcLine.endsWith(' (m)') || lcLine.endsWith(' (f)')) {
				lcLine = lcLine.slice(0, -4);
				end -= 4;
			}

			if (lcLine.endsWith(')')) {
				const parenIndex = lcLine.lastIndexOf(' (');
				if (parenIndex >= 0) {
					start = start + parenIndex + 2;
					end--;
				}
			}

			return [start, end];
		}
		case 'item': {
			// let atIndex = lcLine.lastIndexOf('@');
			// if (atIndex < 0) return null;

			// if (lcLine.charAt(atIndex + 1) === ' ') atIndex++;
			// return { start: start + atIndex + 1, end };
			if (lcLine.startsWith(' ')) start++;
			return [start, end];
		}
		case 'ability': {
			if (lcLine.startsWith('[')) {
				start++;
				if (lcLine.endsWith(' ')) {
					end--;
					lcLine = lcLine.slice(0, -1);
				}
				if (lcLine.endsWith(']')) {
					end--;
				}
				return [start, end];
			}
			if (!lcLine.startsWith('ability:')) return null;
			start += lcLine.startsWith('ability: ') ? 9 : 8;
			return [start, end];
		}
		case 'move': {
			if (!lcLine.startsWith('-')) return null;
			start += lcLine.startsWith('- ') ? 2 : 1;
			return [start, end];
		}
		}
		return [start, end];
	}
	changeSet(type: SelectionType, name: string, moveSlot?: string) {
		const focus = this.innerFocus;
		if (!focus) return;

		if (type === focus.type && type !== 'pokemon') {
			this.replace(name, focus.range[0], focus.range[1]);
			this.updateText(false, true);
			return;
		}

		switch (type) {
		case 'pokemon': {
			const set = this.editor.sets[focus.setIndex] ||= {
				species: '',
				moves: [],
			};
			this.editor.changeSpecies(set, name);
			this.replaceSet(focus.setIndex);
			this.updateText(false, true);
			break;
		}
		case 'ability': {
			this.editor.sets[focus.setIndex].ability = name;
			this.replaceSet(focus.setIndex);
			this.updateText(false, true);
			break;
		}
		}
	}
	getSetRange(index: number) {
		if (!this.setInfo[index]) {
			if (this.innerFocus?.setIndex === index) {
				return this.innerFocus.range;
			}
			return [this.textbox.value.length, this.textbox.value.length];
		}
		const start = this.setInfo[index].index;
		const end = this.setInfo[index + 1].index;
		return [start, end];
	}
	changeCompat = (ev: Event) => {
		const checkbox = ev.currentTarget as HTMLInputElement;
		this.compat = checkbox.checked;
		this.editor.import(this.textbox.value);
		this.textbox.value = this.editor.export(this.compat);
		// this.textbox.select();
		// document.execCommand('insertText', false, this.editor.export(this.compat));
		this.updateText();
	};
	replaceSet(index: number) {
		const editor = this.editor;
		const { team } = editor;
		if (!team) return;

		let newText = Teams.exportSet(editor.sets[index], editor.dex, !this.compat);
		const [start, end] = this.getSetRange(index);
		if (start && start === this.textbox.value.length && !this.textbox.value.endsWith('\n\n')) {
			newText = (this.textbox.value.endsWith('\n') ? '\n' : '\n\n') + newText;
		}
		this.replaceNoFocus(newText, start, end, start + newText.length);
		// we won't do a full update but we do need to update where the end is,
		// for future updates
		if (!this.setInfo[index]) {
			this.updateText();
			this.save();
		} else {
			if (this.setInfo[index + 1]) {
				this.setInfo[index + 1].index = start + newText.length;
			}
			// others don't need to be updated;
			// we'll do a full update next time we focus the textbox
			this.setDirty = true;
		}
	}
	replace(text: string, start: number, end: number, selectionStart = start, selectionEnd = start + text.length) {
		const textbox = this.textbox;
		// const value = textbox.value;
		// textbox.value = value.slice(0, start) + text + value.slice(end);
		textbox.focus();
		textbox.setSelectionRange(start, end);
		document.execCommand('insertText', false, text);
		// textbox.setSelectionRange(selectionStart, selectionEnd);
		this.save();
	}
	replaceNoFocus(text: string, start: number, end: number, selectionStart = start, selectionEnd = start + text.length) {
		const textbox = this.textbox;
		const value = textbox.value;
		textbox.value = value.slice(0, start) + text + value.slice(end);
		textbox.setSelectionRange(selectionStart, selectionEnd);
		this.save();
	}
	save() {
		this.editor.import(this.textbox.value);
		this.props.onChange?.();
	}
	override componentDidMount() {
		this.textbox = this.base!.getElementsByClassName('teamtextbox')[0] as HTMLTextAreaElement;
		this.heightTester = this.base!.getElementsByClassName('heighttester')[0] as HTMLTextAreaElement;

		this.editor = this.props.editor;
		const exportedTeam = this.editor.export(this.compat);
		this.textbox.value = exportedTeam;
		this.updateText();
		setTimeout(() => this.updateText());
	}
	override componentWillUnmount() {
		this.textbox = null!;
		this.heightTester = null!;
	}
	clickDetails = (ev: Event) => {
		const target = ev.currentTarget as HTMLButtonElement;
		const i = parseInt(target.value || '0');
		if (this.innerFocus?.type === target.name) {
			this.innerFocus = null;
			this.forceUpdate();
			return;
		}
		this.engageFocus({
			offsetY: null,
			setIndex: i,
			type: target.name as SelectionType,
			typeIndex: 0,
			range: [0, 0],
			rangeEndChar: '',
		});
	};
	addPokemon = () => {
		if (this.textbox.value && !this.textbox.value.endsWith('\n\n')) {
			this.textbox.value += this.textbox.value.endsWith('\n') ? '\n' : '\n\n';
		}
		const end = this.textbox.value === '\n\n' ? 0 : this.textbox.value.length;
		this.textbox.setSelectionRange(end, end);
		this.textbox.focus();
		this.engageFocus({
			offsetY: this.getYAt(end, true),
			setIndex: this.setInfo.length,
			type: 'pokemon',
			typeIndex: 0,
			range: [end, end],
			rangeEndChar: '@',
		});
	};
	scrollResults = (ev: Event) => {
		if (!(ev.currentTarget as HTMLElement).scrollTop) return;
		this.windowing = false;
		if (document.documentElement.clientWidth === document.documentElement.scrollWidth) {
			(ev.currentTarget as any).scrollIntoViewIfNeeded?.();
		}
		this.forceUpdate();
	};
	resetScroll() {
		this.windowing = true;
		const searchResults = this.base!.querySelector('.searchresults');
		if (searchResults) searchResults.scrollTop = 0;
	}
	windowResults() {
		if (this.windowing) {
			return Math.ceil(window.innerHeight / 33);
		}
		return null;
	}

	renderDetails(set: Dex.PokemonSet, i: number) {
		const editor = this.editor;
		const species = editor.dex.species.get(set.species);

		const GenderChart = {
			'M': 'Male',
			'F': 'Female',
			'N': '\u2014', // em dash
		};
		const gender = GenderChart[(set.gender || species.gender || 'N') as 'N'];

		return <button class="textbox setdetails" name="details" value={i} onClick={this.clickDetails}>
			<span class="detailcell">
				<label>Level</label>{set.level || editor.defaultLevel}
			</span>
			<span class="detailcell">
				<label>Shiny</label>{set.shiny ? 'Yes' : '\u2014'}
			</span>
			{editor.gen === 9 ? (
				<span class="detailcell">
					<label>Tera</label><PSIcon type={set.teraType || species.requiredTeraType || species.types[0]} />
				</span>
			) : editor.hpTypeMatters(set) ? (
				<span class="detailcell">
					<label>H. Power</label><PSIcon type={editor.getHPType(set)} />
				</span>
			) : (
				<span class="detailcell">
					<label>Gender</label>{gender}
				</span>
			)}
		</button>;
	}

	renderStats(set: Dex.PokemonSet, i: number) {
		const editor = this.editor;

		// stat cell
		return <button class="textbox setstats" name="stats" value={i} onClick={this.clickDetails}>
			{StatForm.renderStatGraph(set, editor)}
		</button>;
	}
	handleSetChange = () => {
		if (this.selection) {
			this.replaceSet(this.selection.setIndex);
			this.forceUpdate();
		}
	};
	bottomY() {
		return this.setInfo[this.setInfo.length - 1]?.bottomY ?? 8;
	}
	copyAll = (ev: Event) => {
		this.textbox.select();
		document.execCommand('copy');
		const button = ev?.currentTarget as HTMLButtonElement;
		if (button) {
			button.innerHTML = '<i class="fa fa-check" aria-hidden="true"></i> Copied';
			button.className += ' cur';
		}
	};
	render() {
		const editor = this.props.editor;
		const statsDetailsOffset = editor.gen >= 3 ? 18 : -1;
		return <div>
			<p>
				<button class="button" onClick={this.copyAll}>
					<i class="fa fa-copy" aria-hidden></i> Copy
				</button> {}
				<label class="checkbox inline">
					<input type="checkbox" name="compat" onChange={this.changeCompat} /> Old export format
				</label>
			</p>
			<div class="teameditor-text">
				<textarea
					class="textbox teamtextbox" style={`padding-left:${editor.narrow ? '50px' : '100px'}`}
					onInput={this.input} onContextMenu={this.contextMenu} onKeyUp={this.keyUp} onKeyDown={this.keyDown}
					onClick={this.keyUp} onChange={this.maybeReplaceLine}
					placeholder=" Paste exported teams, pokepaste URLs, or JSON here" readOnly={editor.readonly}
				/>
				<textarea
					class="textbox teamtextbox heighttester" tabIndex={-1} aria-hidden
					style={`padding-left:${editor.narrow ? '50px' : '100px'};visibility:hidden;left:-15px`}
				/>
				<div class="teamoverlays">
					{this.setInfo.slice(0, -1).map(info =>
						<hr style={`top:${info.bottomY - 18}px;pointer-events:none`} />
					)}
					{editor.canAdd() && !!this.setInfo.length && <hr style={`top:${this.bottomY() - 18}px`} />}
					{this.setInfo.map((info, i) => {
						if (!info.species) return null;
						const set = editor.sets[i];
						if (!set) return null;
						const prevOffset = i === 0 ? 8 : this.setInfo[i - 1].bottomY;
						const species = editor.dex.species.get(info.species);
						const num = Dex.getPokemonIconNum(species.id);
						if (!num) return null;

						if (editor.narrow) {
							return <div style={`top:${prevOffset + 1}px;left:5px;position:absolute;text-align:center;pointer-events:none`}>
								<div><PSIcon pokemon={species.id} /></div>
								{species.types.map(type => <div><PSIcon type={type} /></div>)}
								<div><PSIcon item={set.item || null} /></div>
							</div>;
						}
						return [<div
							style={
								`top:${prevOffset - 7}px;left:0;position:absolute;text-align:right;` +
								`width:94px;padding:103px 5px 0 0;min-height:24px;pointer-events:none;` +
								Dex.getTeambuilderSprite(set, editor.dex)
							}
						>
							<div>{species.types.map(type => <PSIcon type={type} />)}<PSIcon item={set.item || null} /></div>
						</div>, <div style={`top:${prevOffset + statsDetailsOffset}px;right:9px;position:absolute`}>
							{this.renderStats(set, i)}
						</div>, <div style={`top:${prevOffset + statsDetailsOffset}px;right:145px;position:absolute`}>
							{this.renderDetails(set, i)}
						</div>];
					})}
					{editor.canAdd() && !(this.innerFocus && this.innerFocus.setIndex >= this.setInfo.length) && (
						<div style={`top:${this.bottomY() - 3}px;left:${editor.narrow ? 55 : 105}px;position:absolute`}>
							<button class="button" onClick={this.addPokemon}>
								<i class="fa fa-plus" aria-hidden></i> Add Pok&eacute;mon
							</button>
						</div>
					)}
					{this.innerFocus?.offsetY != null && (
						<div
							class={`teaminnertextbox teaminnertextbox-${this.innerFocus.type}`}
							style={`top:${this.innerFocus.offsetY - 21}px;left:${editor.narrow ? 46 : 96}px;`}
						></div>
					)}
				</div>
				{this.innerFocus && (
					<div
						class="searchresults"
						style={`top:${(this.setInfo[this.innerFocus.setIndex]?.bottomY ?? this.bottomY() + 50) - 12}px`}
						onScroll={this.scrollResults}
					>
						<button class="button closesearch" onClick={this.closeMenu}>
							{!editor.narrow && <kbd>Esc</kbd>} <i class="fa fa-times" aria-hidden></i> Close
						</button>
						{this.innerFocus.type === 'stats' ? (
							<StatForm editor={editor} set={this.editor.sets[this.innerFocus.setIndex]} onChange={this.handleSetChange} />
						) : this.innerFocus.type === 'details' ? (
							<DetailsForm editor={editor} set={this.editor.sets[this.innerFocus.setIndex]} onChange={this.handleSetChange} />
						) : (
							<PSSearchResults
								search={editor.search} resultIndex={editor.searchIndex}
								windowing={this.windowResults()} onSelect={this.selectResult}
							/>
						)}
					</div>
				)}
			</div>
		</div>;
	}
}

class TeamWizard extends preact.Component<{
	editor: TeamEditorState, onChange?: () => void, onUpdate: () => void,
}> {
	setSearchBox: string | null = null;
	windowing = true;
	setFocus = (ev: Event) => {
		const { editor } = this.props;
		if (editor.readonly) return;
		const target = ev.currentTarget as HTMLButtonElement;
		const [rawType, i] = (target.value || '').split('|');
		const setIndex = parseInt(i);
		const type = rawType as SelectionType;
		if (!target.value || editor.innerFocus && editor.innerFocus.setIndex === setIndex && editor.innerFocus.type === type) {
			this.changeFocus(null);
			return;
		}
		this.changeFocus({
			setIndex,
			type,
		});
	};
	deleteSet = (ev: Event) => {
		const target = ev.currentTarget as HTMLButtonElement;
		const i = parseInt(target.value);
		const { editor } = this.props;
		editor.deleteSet(i);
		if (editor.innerFocus) {
			this.changeFocus({
				setIndex: editor.sets.length,
				type: 'pokemon',
			});
		}
		this.handleSetChange();
		ev.preventDefault();
	};
	copySet = (ev: Event) => {
		const target = ev.currentTarget as HTMLButtonElement;
		const i = parseInt(target.value);
		const { editor } = this.props;
		editor.copySet(i);
		editor.innerFocus = null;
		this.props.onUpdate();
		window.PS?.update();
		ev.preventDefault();
	};
	undeleteSet = (ev: Event) => {
		const { editor } = this.props;
		const setIndex = editor.deletedSet?.index;
		editor.undeleteSet();
		if (editor.innerFocus && setIndex !== undefined) {
			this.changeFocus({
				setIndex,
				type: 'pokemon',
			});
		}
		this.handleSetChange();
		ev.preventDefault();
	};
	pasteSet = (ev: Event) => {
		const target = ev.currentTarget as HTMLButtonElement;
		const i = parseInt(target.value);
		const { editor } = this.props;
		editor.pasteSet(i);
		this.handleSetChange();
		window.PS?.update();
		ev.preventDefault();
	};
	moveSet = (ev: Event) => {
		const target = ev.currentTarget as HTMLButtonElement;
		const i = parseInt(target.value);
		const { editor } = this.props;
		editor.pasteSet(i, true);
		this.handleSetChange();
		ev.preventDefault();
	};
	changeFocus(focus: TeamEditorState['innerFocus']) {
		const { editor } = this.props;
		editor.innerFocus = focus;
		if (!focus) {
			this.props.onUpdate();
			return;
		}

		const set = editor.sets[focus.setIndex];
		if (focus.type === 'details') {
			this.setSearchBox = set.name || '';
		} else if (focus.type !== 'stats') {
			let value;
			if (focus.type === 'pokemon') value = set?.species || '';
			else if (focus.type === 'item') value = set.item;
			else if (focus.type === 'ability') value = set.ability;
			editor.setSearchType(focus.type, focus.setIndex, value);
			this.resetScroll();
			this.setSearchBox = value || '';
		}
		this.props.onUpdate();
	}
	renderSet(set: Dex.PokemonSet | undefined, i: number) {
		const { editor } = this.props;
		const sprite = Dex.getTeambuilderSprite(set, editor.dex);
		if (!set) {
			return <div class="set-button">
				<div style="text-align:right">
					{editor.deletedSet ? (
						<button onClick={this.undeleteSet} class="option"><i class="fa fa-undo" aria-hidden></i> Undo delete</button>
					) : (
						<button class="option" style="visibility:hidden"><i class="fa fa-trash" aria-hidden></i> Delete</button>
					)}
				</div>
				<table>
					<tr>
						<td rowSpan={2} class="set-pokemon"><div class="border-collapse">
							<button class="button button-first cur" onClick={this.setFocus} value={`pokemon|${i}`}>
								<span class="sprite" style={sprite}><span class="sprite-inner">
									<strong class="label">Pokemon</strong> {}
									<em>(choose species)</em>
								</span></span>
							</button>
						</div></td>
						<td colSpan={2} class="set-details"></td>
						<td rowSpan={2} class="set-moves"></td>
						<td rowSpan={2} class="set-stats"></td>
					</tr>
					<tr>
						<td class="set-ability"></td>
						<td class="set-item"></td>
					</tr>
				</table>
			</div>;
		}
		while (set.moves.length < 4) set.moves.push('');
		const overfull = set.moves.length > 4 ? ' overfull' : '';

		const cur = (t: SelectionType) => (
			editor.readonly || (editor.innerFocus?.type === t && editor.innerFocus.setIndex === i) ? ' cur' : ''
		);
		const species = editor.dex.species.get(set.species);
		const isCur = TeamEditorState.clipboard?.teams?.[editor.team.key]?.sets[i] ? ' cur' : '';
		return <div class={`set-button${isCur}`}>
			<div style="text-align:right">
				<button class="option" onClick={this.copySet} value={i}>
					<i class="fa fa-copy" aria-hidden></i> {
						isCur ? "Deselect" :
						TeamEditorState.clipboard ? "Add to clipboard" :
						editor.readonly ? "Copy" :
						"Copy/Move"
					}
				</button> {}
				{!(TeamEditorState.clipboard || editor.readonly) && <button class="option" onClick={this.deleteSet} value={i}>
					<i class="fa fa-trash" aria-hidden></i> Delete
				</button>}
			</div>
			<table>
				<tr>
					<td rowSpan={2} class="set-pokemon"><div class="border-collapse">
						<button class={`button button-first${cur('pokemon')}`} onClick={this.setFocus} value={`pokemon|${i}`}>
							<span class="sprite" style={sprite}><span class="sprite-inner">
								<strong class="label">Pokemon</strong> {}
								{set.species}
							</span></span>
						</button>
					</div></td>
					<td colSpan={2} class="set-details"><div class="border-collapse">
						<button class={`button button-middle${cur('details')}`} onClick={this.setFocus} value={`details|${i}`}>
							<span class="detailcell">
								<strong class="label">Types</strong> {}
								{species.types.map(type => <div><PSIcon type={type} /></div>)}
							</span>
							<span class="detailcell">
								<strong class="label">Level</strong> {}
								{set.level || editor.defaultLevel}
								{editor.narrow && set.shiny && <><br />
									<img src={`${Dex.resourcePrefix}sprites/misc/shiny.png`} width={22} height={22} alt="Shiny" />
								</>}
								{!editor.narrow && set.gender && set.gender !== 'N' && <>
									<br /><img
										src={`${Dex.fxPrefix}gender-${set.gender.toLowerCase()}.png`} alt={set.gender} width="7" height="10" class="pixelated"
									/>
								</>}
							</span>
							{!!(!editor.narrow && (set.shiny || editor.gen >= 2)) && <span class="detailcell">
								<strong class="label">Shiny</strong> {}
								{set.shiny ? <img src={`${Dex.resourcePrefix}sprites/misc/shiny.png`} width={22} height={22} alt="Yes" /> : '\u2014'}
							</span>}
							{editor.gen === 9 && <span class="detailcell">
								<strong class="label">Tera</strong> {}
								<PSIcon type={set.teraType || species.requiredTeraType || species.types[0]} />
							</span>}
							{editor.hpTypeMatters(set) && <span class="detailcell">
								<strong class="label">H.P.</strong> {}
								<PSIcon type={editor.getHPType(set)} />
							</span>}
						</button>
					</div></td>
					<td rowSpan={2} class="set-moves"><div class="border-collapse">
						<button class={`button button-middle${cur('move')}${overfull}`} onClick={this.setFocus} value={`move|${i}`}>
							<strong class="label">Moves</strong> {}
							{set.moves.map((move, mi) => <div>
								{!editor.narrow && <small class="gray">&bull;</small>}
								{mi >= 4 ? <span class="message-error">{move || (editor.narrow && '-') || ''}</span> : move || (editor.narrow && '-')}
							</div>)}
							{!set.moves.length && <em>(no moves)</em>}
						</button>
					</div></td>
					<td rowSpan={2} class="set-stats"><div class="border-collapse">
						<button class={`button button-last${cur('stats')}`} onClick={this.setFocus} value={`stats|${i}`}>
							{StatForm.renderStatGraph(set, this.props.editor, true)}
						</button>
					</div></td>
				</tr>
				<tr>
					<td class="set-ability"><div class="border-collapse">
						<button class={`button button-middle${cur('ability')}`} onClick={this.setFocus} value={`ability|${i}`}>
							{(editor.gen >= 3 || set.ability) && <>
								<strong class="label">Ability</strong> {}
								{(set.ability !== 'No Ability' && set.ability) ||
									(!set.ability ? <em>(choose ability)</em> : <em>(no ability)</em>)}
							</>}
						</button>
					</div></td>
					<td class="set-item"><div class="border-collapse">
						<button class={`button button-middle${cur('item')}`} onClick={this.setFocus} value={`item|${i}`}>
							{(editor.gen >= 2 || set.item) && <>
								{set.item && <PSIcon item={set.item} />}
								<strong class="label">Item</strong> {}
								{set.item || <em>(no item)</em>}
							</>}
						</button>
					</div></td>
				</tr>
			</table>
			<button class={`button set-nickname${cur('details')}`} onClick={this.setFocus} value={`details|${i}`}>
				<strong class="label">Nickname</strong> {}
				{editor.getNickname(set)}
			</button>
		</div>;
	}
	handleSetChange = () => {
		this.props.editor.save();
		this.props.onChange?.();
		this.forceUpdate();
	};
	clearSearchBox() {
		const searchBox = this.base!.querySelector<HTMLInputElement>('input[name=value]');
		if (searchBox) {
			searchBox.value = '';
			if (!TeamEditor.probablyMobile()) searchBox.focus();
		}
	}
	selectResult = (type: string | null, name: string, slot?: string, reverse?: boolean) => {
		const { editor } = this.props;
		this.clearSearchBox();
		if (type === null) {
			this.resetScroll();
			this.forceUpdate();
		} if (!type) {
			editor.setSearchValue('');
			this.resetScroll();
			this.forceUpdate();
		} else {
			const setIndex = editor.innerFocus!.setIndex;
			const set = (editor.sets[setIndex] ||= { species: '', moves: [] });
			switch (type) {
			case 'pokemon':
				editor.changeSpecies(set, name);
				this.changeFocus({
					setIndex,
					type: reverse ? 'details' : 'ability',
				});
				break;
			case 'ability':
				if (name === 'No Ability' && editor.gen <= 2) name = '';
				set.ability = name;
				this.changeFocus({
					setIndex,
					type: reverse ? 'pokemon' : 'item',
				});
				break;
			case 'item':
				set.item = name;
				this.changeFocus({
					setIndex,
					type: reverse ? 'ability' : 'move',
				});
				break;
			case 'move':
				if (slot) {
					// intentional; we're _removing_ from the slot
					const i = parseInt(slot) - 1;
					if (set.moves[i]) {
						set.moves[i] = '';
						// remove empty slots at the end
						if (i === set.moves.length - 1) {
							while (set.moves.length > 4 && !set.moves[set.moves.length - 1]) {
								set.moves.pop();
							}
						}
						// if we have more than 4 moves, move the last move into the newly-cleared slot
						if (set.moves.length > 4 && i < set.moves.length - 1) {
							set.moves[i] = set.moves.pop()!;
						}
					}
				} else if (set.moves.includes(name)) {
					set.moves.splice(set.moves.indexOf(name), 1);
				} else {
					for (let i = 0; i < set.moves.length + 1; i++) {
						if (!set.moves[i]) {
							set.moves[i] = name;
							break;
						}
					}
				}
				if (set.moves.length === 4 && set.moves.every(Boolean)) {
					this.changeFocus({
						setIndex,
						type: reverse ? 'item' : 'stats',
					});
				} else {
					if (editor.search.query) {
						this.resetScroll();
					}
					editor.updateSearchMoves(set);
				}
				break;
			}
			editor.save();
			this.props.onChange?.();
			this.forceUpdate();
		}
	};
	loadSampleSet = (setName: string) => {
		const { editor } = this.props;
		const setIndex = editor.innerFocus!.setIndex;
		const set = editor.sets[setIndex];
		if (!set?.species) return;

		const data = TeamEditorState.sampleSets?.[editor.format];
		const sid = toID(set.species);
		const setTemplate = data?.dex?.[set.species]?.[setName] ?? data?.dex?.[sid]?.[setName] ??
			data?.stats?.[set.species]?.[setName] ?? data?.stats?.[sid]?.[setName];
		if (!setTemplate) return;

		const applied: Partial<Dex.PokemonSet> = JSON.parse(JSON.stringify(setTemplate));
		Object.assign(set, applied);

		editor.save();
		this.props.onUpdate?.();
		this.forceUpdate();
	};
	handleLoadUserSet = (ev: Event) => {
		const setName = (ev.target as HTMLButtonElement).value;
		this.loadUserSet(setName);
	};
	loadUserSet = (setName: string) => {
		const { editor } = this.props;
		const setIndex = editor.innerFocus!.setIndex;
		const set = editor.sets[setIndex];
		if (!set?.species) return;

		const userSets = editor.getUserSets(set);
		const setTemplate = userSets?.[setName];
		if (!setTemplate) return;

		const applied: Partial<Dex.PokemonSet> = JSON.parse(JSON.stringify(setTemplate));
		delete applied.name;
		Object.assign(set, applied);

		editor.save();
		this.props.onUpdate?.();
		this.forceUpdate();
	};
	updateSearch = (ev: Event) => {
		const searchBox = ev.currentTarget as HTMLInputElement;
		this.props.editor.setSearchValue(searchBox.value);
		this.resetScroll();
		this.forceUpdate();
	};
	handleClickFilters = (ev: Event) => {
		const search = this.props.editor.search;
		let target = ev.target as HTMLElement | null;
		while (target && target.className !== 'dexlist') {
			if (target.tagName === 'BUTTON') {
				const filter = target.getAttribute('data-filter');
				if (filter) {
					search.removeFilter(filter.split(':') as any);
					const searchBox = this.base!.querySelector<HTMLInputElement>('input[name=value]');
					search.find(searchBox?.value || '');
					if (!TeamEditor.probablyMobile()) searchBox?.select();
					this.forceUpdate();
					ev.preventDefault();
					ev.stopPropagation();
					break;
				}
			}

			target = target.parentElement;
		}
	};
	keyDownSearch = (ev: KeyboardEvent) => {
		const searchBox = ev.currentTarget as HTMLInputElement;
		const { editor } = this.props;
		switch (ev.keyCode) {
		case 8: // backspace
			if (searchBox.selectionStart === 0 && searchBox.selectionEnd === 0) {
				editor.search.removeFilter();
				editor.setSearchValue(searchBox.value);
				this.resetScroll();
				this.forceUpdate();
			}
			break;
		case 38: // up
			editor.upSearchValue();
			const resultsUp = this.base!.querySelector('.wizardsearchresults');
			if (resultsUp) {
				resultsUp.scrollTop = Math.max(0, editor.searchIndex * 33 - Math.trunc((window.innerHeight - 300) / 2));
			}
			this.forceUpdate();
			ev.preventDefault();
			break;
		case 40: // down
			editor.downSearchValue();
			const resultsDown = this.base!.querySelector('.wizardsearchresults');
			if (resultsDown) {
				resultsDown.scrollTop = Math.max(0, editor.searchIndex * 33 - Math.trunc((window.innerHeight - 300) / 2));
			}
			this.forceUpdate();
			ev.preventDefault();
			break;
		case 37: // left
			// prevent jumping to other rooms
			ev.stopImmediatePropagation();
			break;
		case 39: // right
			// prevent jumping to other rooms
			ev.stopImmediatePropagation();
			break;
		case 13: // enter
		case 9: // tab
			const value = editor.selectSearchValue();
			if (editor.innerFocus?.type !== 'move') searchBox.value = value || '';
			if (value !== null) {
				if (ev.keyCode === 9 && editor.innerFocus?.type === 'move') {
					this.changeFocus({
						setIndex: editor.innerFocus.setIndex,
						type: ev.shiftKey ? 'item' : 'stats',
					});
				} else {
					const [name, moveSlot] = value.split('|');
					this.selectResult(editor.innerFocus?.type || '', name, moveSlot, ev.keyCode === 9 && ev.shiftKey);
				}
			} else {
				this.clearSearchBox();
				editor.setSearchValue('');
				this.resetScroll();
				this.forceUpdate();
			}
			ev.preventDefault();
			break;
		}
	};
	scrollResults = (ev: Event) => {
		if (!(ev.currentTarget as HTMLElement).scrollTop) return;
		this.windowing = false;
		if (document.documentElement.clientWidth === document.documentElement.scrollWidth) {
			(ev.currentTarget as any).scrollIntoViewIfNeeded?.();
		}
		this.forceUpdate();
	};
	resetScroll() {
		this.windowing = true;
		const searchResults = this.base!.querySelector('.wizardsearchresults');
		if (searchResults) searchResults.scrollTop = 0;
	}
	windowResults() {
		if (this.windowing) {
			return Math.ceil(window.innerHeight / 33);
		}
		return null;
	}

	override componentDidUpdate() {
		const searchBox = this.base!.querySelector<HTMLInputElement>('input[name=value], input[name=nickname]');
		if (this.setSearchBox !== null) {
			if (searchBox) {
				searchBox.value = this.setSearchBox;
				if (!TeamEditor.probablyMobile()) searchBox.select();
			}
			this.setSearchBox = null;
		}
		const filters = this.base!.querySelector('.dexlist-filters');
		if (searchBox && searchBox.name === 'value') {
			if (filters) {
				const { width } = filters.getBoundingClientRect();
				searchBox.style.paddingLeft = `${width + 5}px`;
			} else {
				searchBox.style.paddingLeft = `3px`;
			}
		}
	}
	renderInnerFocus() {
		const { editor } = this.props;
		if (!editor.innerFocus) return null;
		const { type, setIndex } = editor.innerFocus;
		const set = this.props.editor.sets[setIndex] as Dex.PokemonSet | undefined;
		const cur = (i: number) => setIndex === i ? ' cur' : '';
		const sampleSets = type === 'ability' ? editor.getSampleSets(set!) : [];
		const userSets = type === 'ability' ? editor.getUserSets(set!) : null;
		return <div class="team-focus-editor">
			<ul class="tabbar">
				<li class="home-li"><button class="button" onClick={this.setFocus}>
					<i class="fa fa-chevron-left" aria-hidden></i> Back
				</button></li>
				{editor.sets.map((curSet, i) => <li><button
					class={`button picontab${cur(i)}`} onClick={this.setFocus} value={`${type}|${i}`}
				>
					<PSIcon pokemon={curSet} /><br />
					{editor.getNickname(curSet)}
				</button></li>)}
				{editor.canAdd() && <li><button
					class={`button picontab${cur(editor.sets.length)}`} onClick={this.setFocus} value={`pokemon|${editor.sets.length}`}
				>
					<i class="fa fa-plus"></i>
				</button></li>}
			</ul>
			<div class="pad" style="padding-top:0">{this.renderSet(set, setIndex)}</div>
			{type === 'stats' ? (
				<StatForm editor={editor} set={set!} onChange={this.handleSetChange} />
			) : type === 'details' ? (
				<DetailsForm editor={editor} set={set!} onChange={this.handleSetChange} />
			) : (
				<div>
					<div class="searchboxwrapper pad" onClick={this.handleClickFilters}>
						<input
							type="search" name="value" class="textbox" placeholder="Search or filter"
							onInput={this.updateSearch} onKeyDown={this.keyDownSearch} autocomplete="off"
						/>
						{PSSearchResults.renderFilters(editor.search)}
					</div>
					<div class="wizardsearchresults" onScroll={this.scrollResults}>
						<PSSearchResults
							search={editor.search} hideFilters resultIndex={editor.searchIndex}
							onSelect={this.selectResult} windowing={this.windowResults()}
						/>
						{sampleSets?.length !== 0 && (
							<div class="sample-sets">
								<h3>Sample sets</h3>
								{sampleSets ? (
									<div>
										{sampleSets.map(setName => <>
											<button class="button" onClick={() => this.loadSampleSet(setName)}>
												{setName}
											</button> {}
										</>)}
									</div>
								) : (
									<div>Loading...</div>
								)}
							</div>
						)}
						{userSets !== null && (
							<div class="sample-sets">
								<h3>Box sets</h3>
								{Object.keys(userSets).length > 0 ? (
									<div>
										{Object.keys(userSets).map(setName => <>
											<button class="button" value={setName} onClick={this.handleLoadUserSet}>
												{setName}
											</button> {}
										</>)}
									</div>
								) : (
									<div>No {set!.species} sets found in boxes</div>
								)}
							</div>
						)}
					</div>
				</div>
			)}
		</div>;
	}
	override render() {
		const { editor } = this.props;
		if (editor.innerFocus) return this.renderInnerFocus();
		if (editor.fetching) {
			return <div class="teameditor">Fetching Paste...</div>;
		}

		const clipboard = TeamEditorState.clipboard;
		const willNotMove = (i: number) => (
			clipboard?.teams && !clipboard.otherSets && clipboard.teams[editor.team.key] &&
			Object.keys(clipboard.teams[editor.team.key]?.sets).length === 1 &&
			!!(clipboard.teams[editor.team.key]?.sets[i] || clipboard.teams[editor.team.key]?.sets[i - 1])
		);
		const pasteControls = (i: number) => editor.readonly ? (
			null
		) : clipboard ? <p>
			<button class="button notifying" onClick={this.pasteSet} value={i}>
				<i class="fa fa-clipboard" aria-hidden></i> Paste copy here
			</button> {}
			{!willNotMove(i) && <button class="button notifying" onClick={this.moveSet} value={i} disabled={clipboard.readonly}>
				<i class="fa fa-arrow-right" aria-hidden></i> Move here
			</button>}
		</p> : editor.deletedSet?.index === i ? <p style="text-align:right">
			<button class="button" onClick={this.undeleteSet}>
				<i class="fa fa-undo" aria-hidden></i> Undo delete
			</button>
		</p> : null;
		return <div class="teameditor">
			{editor.sets.map((set, i) => [
				pasteControls(i),
				this.renderSet(set, i),
			])}
			{pasteControls(editor.sets.length)}
			{editor.canAdd() && <p><button class="button big" onClick={this.setFocus} value={`pokemon|${editor.sets.length}`}>
				<i class="fa fa-plus" aria-hidden></i> Add Pok&eacute;mon
			</button></p>}
		</div>;
	}
}

class StatForm extends preact.Component<{
	editor: TeamEditorState,
	set: Dex.PokemonSet,
	onChange: () => void,
}> {
	static renderStatGraph(set: Dex.PokemonSet, editor: TeamEditorState, evs?: boolean) {
		const defaultEV = (editor.gen > 2 ? 0 : 252);
		const ivs = editor.getIVs(set);
		return Dex.statNames.map(statID => {
			if (statID === 'spd' && editor.gen === 1) return null;

			const stat = editor.getStat(statID, set, ivs[statID]);
			let ev: number | string = set.evs ? (set.evs[statID] || 0) : defaultEV;
			let width = stat * 75 / 504;
			if (statID === 'hp') width = stat * 75 / 704;
			if (width > 75) width = 75;
			let hue = Math.floor(stat * 180 / 714);
			if (hue > 360) hue = 360;
			const statName = editor.gen === 1 && statID === 'spa' ? 'Spc' : BattleStatNames[statID];
			if (evs && !ev && !set.evs && statID === 'hp') ev = 'EVs';
			return <span class="statrow">
				<label>{statName}</label> {}
				<span class="statgraph">
					<span style={`width:${width}px;background:hsl(${hue},40%,75%);border-color:hsl(${hue},40%,45%)`}></span>
				</span> {}
				{!evs && <em>{stat}</em>}
				{evs && <em>{ev || ''}</em>}
				{evs && (BattleNatures[set.nature!]?.plus === statID ? (
					<small>+</small>
				) : BattleNatures[set.nature!]?.minus === statID ? (
					<small>&minus;</small>
				) : null)}
			</span>;
		});
	}
	renderIVMenu() {
		const { editor, set } = this.props;
		if (editor.gen <= 2) return null;

		const hpType = editor.getHPMove(set);
		const hpIVdata = hpType && !editor.canHyperTrain(set) && editor.getHPIVs(hpType) || null;
		const autoSpread = set.ivs && editor.defaultIVs(set, false);
		const autoSpreadValue = autoSpread && Object.values(autoSpread).join('/');
		if (!hpIVdata) {
			return <select name="ivspread" class="button" onChange={this.changeIVSpread}>
				<option value="" selected>IV spreads</option>
				{autoSpreadValue && <option value="auto">Auto ({autoSpreadValue})</option>}
				<optgroup label="min Atk">
					<option value="31/0/31/31/31/31">31/0/31/31/31/31</option>
				</optgroup>
				<optgroup label="min Atk, min Spe">
					<option value="31/0/31/31/31/0">31/0/31/31/31/0</option>
				</optgroup>
				<optgroup label="max all">
					<option value="31/31/31/31/31/31">31/31/31/31/31/31</option>
				</optgroup>
				<optgroup label="min Spe">
					<option value="31/31/31/31/31/0">31/31/31/31/31/0</option>
				</optgroup>
			</select>;
		}
		const minStat = editor.gen >= 6 ? 0 : 2;
		const hpIVs = hpIVdata.map(ivs => ivs.split('').map(iv => parseInt(iv)));

		return <select name="ivspread" class="button" onChange={this.changeIVSpread}>
			<option value="" selected>Hidden Power {hpType} IVs</option>
			{autoSpreadValue && <option value="auto">Auto ({autoSpreadValue})</option>}
			<optgroup label="min Atk">
				{hpIVs.map(ivs => {
					const spread = ivs.map((iv, i) => (i === 1 ? minStat : 30) + iv).join('/');
					return <option value={spread}>{spread}</option>;
				})}
			</optgroup>
			<optgroup label="min Atk, min Spe">
				{hpIVs.map(ivs => {
					const spread = ivs.map((iv, i) => (i === 5 || i === 1 ? minStat : 30) + iv).join('/');
					return <option value={spread}>{spread}</option>;
				})}
			</optgroup>
			<optgroup label="max all">
				{hpIVs.map(ivs => {
					const spread = ivs.map(iv => 30 + iv).join('/');
					return <option value={spread}>{spread}</option>;
				})}
			</optgroup>
			<optgroup label="min Spe">
				{hpIVs.map(ivs => {
					const spread = ivs.map((iv, i) => (i === 5 ? minStat : 30) + iv).join('/');
					return <option value={spread}>{spread}</option>;
				})}
			</optgroup>
		</select>;
	}
	smogdexLink(s: string) {
		const { editor } = this.props;
		const species = editor.dex.species.get(s);
		let format: string = editor.format;
		let smogdexid: string = toID(species.baseSpecies);

		if (species.id === 'meowstic') {
			smogdexid = 'meowstic-m';
		} else if (species.forme) {
			switch (species.baseSpecies) {
			case 'Alcremie':
			case 'Basculin':
			case 'Burmy':
			case 'Castform':
			case 'Cherrim':
			case 'Deerling':
			case 'Flabebe':
			case 'Floette':
			case 'Florges':
			case 'Furfrou':
			case 'Gastrodon':
			case 'Genesect':
			case 'Keldeo':
			case 'Mimikyu':
			case 'Minior':
			case 'Pikachu':
			case 'Polteageist':
			case 'Sawsbuck':
			case 'Shellos':
			case 'Sinistea':
			case 'Tatsugiri':
			case 'Vivillon':
				break;
			default:
				smogdexid += '-' + toID(species.forme);
				break;
			}
		}

		let generationNumber = 9;
		if (format.startsWith('gen')) {
			let number = parseInt(format.charAt(3), 10);
			if (1 <= number && number <= 8) {
				generationNumber = number;
			}
			format = format.slice(4);
		}
		const generation = ['rb', 'gs', 'rs', 'dp', 'bw', 'xy', 'sm', 'ss', 'sv'][generationNumber - 1];
		if (format === 'battlespotdoubles') {
			smogdexid += '/vgc15';
		} else if (format === 'doublesou' || format === 'doublesuu') {
			smogdexid += '/doubles';
		} else if (
			format === 'ou' || format === 'uu' || format === 'ru' || format === 'nu' || format === 'pu' ||
			format === 'lc' || format === 'monotype' || format === 'mixandmega' || format === 'nfe' ||
			format === 'nationaldex' || format === 'stabmons' || format === '1v1' || format === 'almostanyability'
		) {
			smogdexid += '/' + format;
		} else if (format === 'balancedhackmons') {
			smogdexid += '/bh';
		} else if (format === 'anythinggoes') {
			smogdexid += '/ag';
		} else if (format === 'nationaldexag') {
			smogdexid += '/national-dex-ag';
		}
		return `http://smogon.com/dex/${generation}/pokemon/${smogdexid}/`;
	}
	handleGuess = () => {
		const { editor, set } = this.props;
		const team = editor.team;

		const guess = new BattleStatGuesser(team.format).guess(set);
		set.evs = guess.evs;
		this.plus = guess.plusStat || null;
		this.minus = guess.minusStat || null;
		this.updateNatureFromPlusMinus();
		this.props.onChange();
	};
	handleOptimize = () => {
		const { editor, set } = this.props;
		const team = editor.team;

		const optimized = BattleStatOptimizer(set, team.format);
		if (!optimized) return;

		set.evs = optimized.evs;
		this.plus = optimized.plus || null;
		this.minus = optimized.minus || null;
		this.updateNatureFromPlusMinus();
		this.props.onChange();
	};
	renderSpreadGuesser() {
		const { editor, set } = this.props;
		const team = editor.team;

		if (editor.gen < 3) {
			return <p>
				(<a target="_blank" href={this.smogdexLink(set.species)}>Smogon&nbsp;analysis</a>)
			</p>;
		}

		const guess = new BattleStatGuesser(team.format).guess(set);
		const role = guess.role;

		const guessedEVs = guess.evs;
		const guessedPlus = guess.plusStat || null;
		const guessedMinus = guess.minusStat || null;
		return <p class="suggested">
			<small>Guessed spread: </small>
			{role === '?' ? (
				"(Please choose 4 moves to get a guessed spread)"
			) : (
				<button name="setStatFormGuesses" class="button" onClick={this.handleGuess}>{role}: {}
					{
						Dex.statNames.map(statID => guessedEVs[statID] ? `${guessedEVs[statID]} ${BattleStatNames[statID]}` : null)
							.filter(Boolean).join(' / ')
					}
					{!!(guessedPlus && guessedMinus) && (
						` (+${BattleStatNames[guessedPlus]}, -${BattleStatNames[guessedMinus]})`
					)}
				</button>
			)}
			<small> (<a target="_blank" href={this.smogdexLink(set.species)}>Smogon&nbsp;analysis</a>)</small>
			{/* <small>
				({role} | bulk: phys {Math.round(guess.moveCount.physicalBulk / 1000)}
				{} + spec {Math.round(guess.moveCount.specialBulk / 1000)}
				{} = {Math.round(guess.moveCount.bulk / 1000)})
			</small> */}
		</p>;
	}
	renderStatOptimizer() {
		const optimized = BattleStatOptimizer(this.props.set, this.props.editor.format);
		if (!optimized) return null;

		return <p>
			<small><em>Protip:</em> Use a different nature to {
				optimized.savedEVs ?
					`save ${optimized.savedEVs} EVs` :
					'get higher stats'
			}: </small>
			<button name="setStatFormOptimization" class="button" onClick={this.handleOptimize}>
				{
					Dex.statNames.map(statID => optimized.evs[statID] ? `${optimized.evs[statID]} ${BattleStatNames[statID]}` : null)
						.filter(Boolean).join(' / ')
				}
				{!!(optimized.plus && optimized.minus) && (
					` (+${BattleStatNames[optimized.plus]}, -${BattleStatNames[optimized.minus]})`
				)}
			</button>
		</p>;
	}
	setInput(name: string, value: string) {
		const evInput = this.base!.querySelector<HTMLInputElement>(`input[name="${name}"]`);
		if (evInput) evInput.value = value;
	}
	update(init?: boolean) {
		const { set } = this.props;
		const nature = BattleNatures[set.nature!];
		const skipID = !init ? this.base!.querySelector<HTMLInputElement>('input:focus')?.name : undefined;
		if (nature?.plus) {
			this.plus = nature?.plus || null;
			this.minus = nature?.minus || null;
		} else if (this.plus && this.minus) {
			// if only one of plus or minus is set, clearing Nature doesn't change them
			this.plus = null;
			this.minus = null;
		}
		for (const statID of Dex.statNames) {
			const ev = `${set.evs?.[statID] || ''}`;
			const plusMinus = this.plus === statID ? '+' : this.minus === statID ? '-' : '';
			const iv = this.ivToDv(set.ivs?.[statID]);
			if (skipID !== `ev-${statID}`) this.setInput(`ev-${statID}`, ev + plusMinus);
			if (skipID !== `iv-${statID}`) this.setInput(`iv-${statID}`, iv);
		}
	}
	override componentDidMount(): void {
		this.update(true);
	}
	override componentDidUpdate(): void {
		this.update();
	}
	plus: Dex.StatNameExceptHP | null = null;
	minus: Dex.StatNameExceptHP | null = null;
	renderStatbar(stat: number, statID: StatName) {
		let width = stat * 180 / 504;
		if (statID === 'hp') width = Math.floor(stat * 180 / 704);
		if (width > 179) width = 179;
		let hue = Math.floor(stat * 180 / 714);
		if (hue > 360) hue = 360;
		return <span
			style={`width:${Math.floor(width)}px;background:hsl(${hue},85%,45%);border-color:hsl(${hue},85%,35%)`}
		></span>;
	}
	changeEV = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { set } = this.props;
		const statID = target.name.split('-')[1] as Dex.StatName;
		let value = Math.abs(parseInt(target.value));

		if (isNaN(value)) {
			if (set.evs) delete set.evs[statID];
		} else {
			if (this.maxEVs() < 6 * 252 || this.props.editor.isLetsGo) {
				set.evs ||= {};
			} else {
				set.evs ||= { hp: 252, atk: 252, def: 252, spa: 252, spd: 252, spe: 252 };
			}
			set.evs[statID] = value;
		}

		if (target.type === 'range') {
			// enforce limit
			const maxEv = this.maxEVs();
			if (maxEv < 6 * 252) {
				let totalEv = 0;
				for (const curEv of Object.values(set.evs || {})) totalEv += curEv;
				if (totalEv > maxEv && totalEv - value <= maxEv) {
					set.evs![statID] = maxEv - (totalEv - value) - (maxEv % 4);
				}
			}
		} else {
			if (target.value.includes('+')) {
				if (statID === 'hp') {
					alert("Natures cannot raise or lower HP.");
					return;
				}
				this.plus = statID;
			} else if (this.plus === statID) {
				this.plus = null;
			}
			if (target.value.includes('-')) {
				if (statID === 'hp') {
					alert("Natures cannot raise or lower HP.");
					return;
				}
				this.minus = statID;
			} else if (this.minus === statID) {
				this.minus = null;
			}
			this.updateNatureFromPlusMinus();
		}

		this.props.onChange();
	};
	updateNatureFromPlusMinus = () => {
		const { set } = this.props;
		set.nature = Teams.getNatureFromPlusMinus(this.plus, this.minus) || undefined;
	};
	/** Converts DV/IV in a textbox to the value in set. */
	dvToIv(dvOrIvString?: string): number | null {
		const dvOrIv = Number(dvOrIvString);
		if (isNaN(dvOrIv)) return null;
		const useIVs = this.props.editor.gen > 2;
		return useIVs ? dvOrIv : (dvOrIv === 15 ? 31 : dvOrIv * 2);
	}
	/** Converts set.iv value to a DV/IV for a text box. */
	ivToDv(iv?: number | null): string {
		if (iv === null || iv === undefined) return '';
		const useIVs = this.props.editor.gen > 2;
		return `${useIVs ? iv : Math.trunc(iv / 2)}`;
	}
	changeIV = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { set } = this.props;
		const statID = target.name.split('-')[1] as StatName;
		const value = this.dvToIv(target.value);
		if (value === null) {
			if (set.ivs) {
				delete set.ivs[statID];
				if (Object.values(set.ivs).every(iv => iv === undefined)) {
					set.ivs = undefined;
				}
			}
		} else {
			set.ivs ||= { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
			set.ivs[statID] = value;
		}
		this.props.onChange();
	};
	changeNature = (ev: Event) => {
		const target = ev.currentTarget as HTMLSelectElement;
		const { set } = this.props;
		const nature = target.value as Dex.NatureName;
		if (nature === 'Serious') {
			delete set.nature;
		} else {
			set.nature = nature;
		}
		this.props.onChange();
	};
	changeIVSpread = (ev: Event) => {
		const target = ev.currentTarget as HTMLSelectElement;
		const { set } = this.props;
		if (!target.value) return;

		if (target.value === 'auto') {
			set.ivs = undefined;
		} else {
			const [hp, atk, def, spa, spd, spe] = target.value.split('/').map(Number);
			set.ivs = { hp, atk, def, spa, spd, spe };
		}
		this.props.onChange();
	};
	maxEVs() {
		const editor = this.props.editor;
		const useEVs = !editor.isLetsGo && editor.gen >= 3;
		return useEVs ? 510 : Infinity;
	}
	override render() {
		const { editor, set } = this.props;
		const species = editor.dex.species.get(set.species);

		const baseStats = species.baseStats;

		const nature = BattleNatures[set.nature || 'Serious'];

		const useEVs = !editor.isLetsGo;
		// const useAVs = !useEVs && team.format.endsWith('norestrictions');
		const maxEV = useEVs ? 252 : 200;
		const stepEV = useEVs ? 4 : 1;
		const defaultEV = useEVs && editor.gen <= 2 && !set.evs ? maxEV : 0;
		const useIVs = editor.gen > 2;

		// label column
		const statNames = {
			hp: 'HP',
			atk: 'Attack',
			def: 'Defense',
			spa: 'Sp. Atk.',
			spd: 'Sp. Def.',
			spe: 'Speed',
		};
		if (editor.gen === 1) statNames.spa = 'Special';

		const ivs = editor.getIVs(set);
		const stats = Dex.statNames.filter(statID => editor.gen > 1 || statID !== 'spd').map(statID => [
			statID, statNames[statID], editor.getStat(statID, set, ivs[statID]),
		] as const);

		let remaining = null;
		const maxEVs = this.maxEVs();
		if (maxEVs < 6 * 252) {
			let totalEv = 0;
			for (const ev of Object.values(set.evs || {})) totalEv += ev;
			if (totalEv <= maxEVs) {
				remaining = (totalEv > (maxEVs - 2) ? 0 : (maxEVs - 2) - totalEv);
			} else {
				remaining = maxEVs - totalEv;
			}
			remaining ||= null;
		}
		const defaultIVs = editor.defaultIVs(set);

		return <div style="font-size:10pt" role="dialog" aria-label="Stats">
			<div class="resultheader"><h3>EVs, IVs, and Nature</h3></div>
			<div class="pad">
				{this.renderSpreadGuesser()}
				<table>
					<tr>
						<th>{/* Stat name */}</th>
						<th>Base</th>
						<th class="setstatbar">{/* Stat bar */}</th>
						<th>{useEVs ? 'EVs' : 'AVs'}</th>
						<th>{/* EV slider */}</th>
						<th>{useIVs ? 'IVs' : 'DVs'}</th>
						<th>{/* Final stat */}</th>
					</tr>
					{stats.map(([statID, statName, stat]) => <tr>
						<th style="text-align:right;font-weight:normal">{statName}</th>
						<td style="text-align:right"><strong>{baseStats[statID]}</strong></td>
						<td class="setstatbar">{this.renderStatbar(stat, statID)}</td>
						<td><input
							name={`ev-${statID}`} placeholder={`${defaultEV || ''}`}
							type="text" inputMode="numeric" class="textbox default-placeholder" style="width:40px"
							onInput={this.changeEV} onChange={this.changeEV}
						/></td>
						<td><input
							name={`evslider-${statID}`} value={set.evs?.[statID] ?? defaultEV} min="0" max={maxEV} step={stepEV}
							type="range" class="evslider" tabIndex={-1} aria-hidden
							onInput={this.changeEV} onChange={this.changeEV}
						/></td>
						<td><input
							name={`iv-${statID}`} min={0} max={useIVs ? 31 : 15} placeholder={`${defaultIVs[statID]}`} style="width:40px"
							type="number" inputMode="numeric" class="textbox default-placeholder" onInput={this.changeIV} onChange={this.changeIV}
						/></td>
						<td style="text-align:right"><strong>{stat}</strong></td>
					</tr>)}
					<tr>
						<td colSpan={2}></td>
						<td class="setstatbar" style="text-align:right">{remaining !== null ? 'Remaining:' : ''}</td>
						<td style="text-align:center">{remaining && remaining < 0 ? <b class="message-error">{remaining}</b> : remaining}</td>
						<td colSpan={3} style="text-align:right">{this.renderIVMenu()}</td>
					</tr>
				</table>
				{editor.gen >= 3 && <p>
					Nature: <select name="nature" class="button" onChange={this.changeNature}>
						{Object.entries(BattleNatures).map(([natureName, curNature]) => (
							<option value={natureName} selected={curNature === nature}>
								{natureName}
								{curNature.plus && ` (+${BattleStatNames[curNature.plus]}, -${BattleStatNames[curNature.minus!]})`}
							</option>
						))}
					</select>
				</p>}
				{editor.gen >= 3 && <p>
					<small><em>Protip:</em> You can also set natures by typing <kbd>+</kbd> and <kbd>-</kbd> in the EV box.</small>
				</p>}
				{editor.gen >= 3 && this.renderStatOptimizer()}
			</div>
		</div>;
	}
}

class DetailsForm extends preact.Component<{
	editor: TeamEditorState,
	set: Dex.PokemonSet,
	onChange: () => void,
}> {
	update(init?: boolean) {
		const { set } = this.props;
		const skipID = !init ? this.base!.querySelector<HTMLInputElement>('input:focus')?.name : undefined;

		const nickname = this.base!.querySelector<HTMLInputElement>('input[name="nickname"]');
		if (nickname && skipID !== 'nickname') nickname.value = set.name || '';
	}
	override componentDidMount(): void {
		this.update(true);
	}
	override componentDidUpdate(): void {
		this.update();
	}
	changeNickname = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { set } = this.props;
		if (target.value) {
			set.name = target.value.trim();
		} else {
			delete set.name;
		}
		this.props.onChange();
	};
	changeTera = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { editor, set } = this.props;
		const species = editor.dex.species.get(set.species);
		if (!target.value || target.value === (species.requiredTeraType || species.types[0])) {
			delete set.teraType;
		} else {
			set.teraType = target.value.trim();
		}
		this.props.onChange();
	};
	changeLevel = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { set } = this.props;
		if (target.value) {
			set.level = parseInt(target.value.trim());
		} else {
			delete set.level;
		}
		this.props.onChange();
	};
	changeGender = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { set } = this.props;
		if (target.value) {
			set.gender = target.value.trim();
		} else {
			delete set.gender;
		}
		this.props.onChange();
	};
	changeHappiness = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { set } = this.props;
		if (target.value) {
			set.happiness = parseInt(target.value.trim());
		} else {
			delete set.happiness;
		}
		this.props.onChange();
	};
	changeShiny = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { set } = this.props;
		if (target.value) {
			set.shiny = true;
		} else {
			delete set.shiny;
		}
		this.props.onChange();
	};
	changeDynamaxLevel = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { set } = this.props;
		if (target.value) {
			set.dynamaxLevel = parseInt(target.value.trim());
		} else {
			delete set.dynamaxLevel;
		}
		this.props.onChange();
	};
	changeGigantamax = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { set } = this.props;
		if (target.checked) {
			set.gigantamax = true;
		} else {
			delete set.gigantamax;
		}
		this.props.onChange();
	};
	changeHPType = (ev: Event) => {
		const target = ev.currentTarget as HTMLInputElement;
		const { set } = this.props;
		if (target.value) {
			set.hpType = target.value;
		} else {
			delete set.hpType;
		}
		this.props.onChange();
	};
	renderGender(gender: Dex.GenderName) {
		const genderTable = { 'M': "Male", 'F': "Female" };
		if (gender === 'N') return 'Unknown';
		return <>
			<img src={`${Dex.fxPrefix}gender-${gender.toLowerCase()}.png`} alt="" width="7" height="10" class="pixelated" /> {}
			{genderTable[gender]}
		</>;
	}
	render() {
		const { editor, set } = this.props;
		const species = editor.dex.species.get(set.species);
		return <div style="font-size:10pt" role="dialog" aria-label="Details">
			<div class="resultheader"><h3>Details</h3></div>
			<div class="pad">
				<p><label class="label">Nickname: <input
					name="nickname" class="textbox default-placeholder" placeholder={species.baseSpecies}
					onInput={this.changeNickname} onChange={this.changeNickname}
				/></label></p>
				<p><label class="label">Level: <input
					name="level" value={set.level ?? ''} placeholder={`${editor.defaultLevel}`}
					type="number" inputMode="numeric" min="1" max="100" step="1"
					class="textbox inputform numform default-placeholder" style="width: 50px"
					onInput={this.changeLevel} onChange={this.changeLevel}
				/></label><small>(You probably want to change the team's levels by changing the format, not here)</small></p>
				{editor.gen > 1 && (<>
					<p><div class="label">Shiny: <div class="labeled">
						<label class="checkbox inline"><input
							type="radio" name="shiny" value="true" checked={set.shiny}
							onInput={this.changeShiny} onChange={this.changeShiny}
						/> <img src={`${Dex.resourcePrefix}sprites/misc/shiny.png`} width={22} height={22} alt="Shiny" /> Yes</label>
						<label class="checkbox inline"><input
							type="radio" name="shiny" value="" checked={!set.shiny}
							onInput={this.changeShiny} onChange={this.changeShiny}
						/> No</label>
					</div></div></p>
					<p><div class="label">Gender: {species.gender ? (
						<strong>{this.renderGender(species.gender)}</strong>
					) : (
						<div class="labeled">
							<label class="checkbox inline"><input
								type="radio" name="gender" value="M" checked={set.gender === 'M'}
								onInput={this.changeGender} onChange={this.changeGender}
							/> {this.renderGender('M')}</label>
							<label class="checkbox inline"><input
								type="radio" name="gender" value="F" checked={set.gender === 'F'}
								onInput={this.changeGender} onChange={this.changeGender}
							/> {this.renderGender('F')}</label>
							<label class="checkbox inline"><input
								type="radio" name="gender" value="" checked={!set.gender || set.gender === 'N'}
								onInput={this.changeGender} onChange={this.changeGender}
							/> Random</label>
						</div>
					)}</div></p>
					{editor.isLetsGo ? (
						<p><label class="label">Happiness: <input
							name="happiness" value="" placeholder="70"
							type="number" inputMode="numeric"
							class="textbox inputform numform default-placeholder" style="width: 50px"
							onInput={this.changeHappiness} onChange={this.changeHappiness}
						/></label></p>
					) : (editor.gen < 8 || editor.isNatDex) && (
						<p><label class="label">Happiness: <input
							name="happiness" value={set.happiness ?? ''} placeholder="255"
							type="number" inputMode="numeric" min="0" max="255" step="1"
							class="textbox inputform numform default-placeholder" style="width: 50px"
							onInput={this.changeHappiness} onChange={this.changeHappiness}
						/></label></p>
					)}
				</>
				)}
				{editor.gen === 8 && !editor.isBDSP && !species.cannotDynamax && (
					<p>
						<label class="label" style="display:inline">Dynamax Level: <input
							name="dynamaxlevel" value={set.dynamaxLevel ?? ''} placeholder="10"
							type="number" inputMode="numeric" min="0" max="10" step="1" class="textbox inputform numform default-placeholder"
							onInput={this.changeDynamaxLevel} onChange={this.changeDynamaxLevel}
						/></label> {}
						{species.canGigantamax ? (
							<label class="checkbox inline"><input
								type="checkbox" name="gigantamax" value="true" checked={set.gigantamax}
								onInput={this.changeGigantamax} onChange={this.changeGigantamax}
							/> Gigantamax</label>
						) : species.forme === 'Gmax' && (
							<label class="checkbox inline"><input
								type="checkbox" checked disabled
							/> Gigantamax</label>
						)}
					</p>
				)}
				{((!editor.isLetsGo && editor.gen === 7) || editor.isNatDex || species.baseSpecies === 'Unown') && <p>
					<label class="label">Hidden Power Type: <select name="hptype" class="button" onChange={this.changeHPType}>
						{Dex.types.all().map(type => (
							type.HPivs && <option value={type.name} selected={editor.getHPType(set) === type.name}>
								{type.name}
							</option>
						))}
					</select></label>
				</p>}
				{editor.gen === 9 && <p>
					<label class="label" title="Tera Type">
						Tera Type: {}
						{species.requiredTeraType && editor.formeLegality === 'normal' ? (
							<select name="teratype" class="button cur" disabled><option>{species.requiredTeraType}</option></select>
						) : (
							<select name="teratype" class="button" onChange={this.changeTera}>
								{Dex.types.all().map(type => (
									<option value={type.name} selected={(set.teraType || species.requiredTeraType || species.types[0]) === type.name}>
										{type.name}
									</option>
								))}
							</select>
						)}
					</label>
				</p>}
				{species.cosmeticFormes && <div>
					<p><strong>Form:</strong></p>
					<div style="display:flex;flex-wrap:wrap;gap:6px;max-width:400px;">
						{(() => {
							const baseId = toID(species.baseSpecies);
							const forms = species.cosmeticFormes?.length ? [baseId, ...species.cosmeticFormes.map(toID)] : [baseId];
							return forms.map(id => {
								const sp = editor.dex.species.get(id);
								const isCur = toID(set.species) === id;
								return <button
									value={id} class={`button piconbtn${isCur ? ' cur' : ''}`}
									style={{ padding: '2px' }} onClick={this.selectSprite}
								>
									<PSIcon pokemon={{ species: sp.name } as Dex.PokemonSet} />
									<br />{sp.forme || sp.baseForme || sp.baseSpecies}
								</button>;
							});
						})()}
					</div>
				</div>}
			</div>
		</div>;
	}

	selectSprite = (ev: Event) => {
		const target = ev.currentTarget as HTMLButtonElement;
		const formId = target.value;
		const { editor, set } = this.props;
		const species = editor.dex.species.get(formId);
		if (!species.exists) return;
		editor.changeSpecies(set, species.name);
		this.props.onChange();
		this.forceUpdate();
	};
}
