import { t as TaxonomyRepository } from "./taxonomy-BxGxZZdu.mjs";
import { i as resolveLocaleChain } from "./resolve-DR3wqMTy.mjs";
import { u as invalidateTermCache } from "./taxonomies-Cnb4F-Pb.mjs";

//#region src/import/wxr-taxonomies.ts
/**
* Thrown by `mirrorTermsToLocales` when a pre-existing locale row at the
* same `(taxonomy, slug)` belongs to a different `translation_group` than
* the canonical term. Callers in the route layer surface
* `publicMessage` to the operator (no internal data) while logging
* `cause` server-side.
*
* Marker class so the route layer can distinguish "operator-actionable
* taxonomy conflict" from any other DB / repository error that might
* escape the helper.
*/
var WxrTaxonomyConflictError = class extends Error {
	publicMessage;
	constructor(publicMessage, options) {
		super(publicMessage, options);
		this.name = "WxrTaxonomyConflictError";
		this.publicMessage = publicMessage;
	}
};
function isWxrTaxonomyConflictError(error) {
	return error instanceof WxrTaxonomyConflictError;
}
function makeState() {
	return { plan: {
		termsCreated: {},
		termsReused: {},
		missingTaxonomies: [],
		termIdByNameAndSlug: /* @__PURE__ */ new Map(),
		collectionsByTaxonomy: /* @__PURE__ */ new Map(),
		translationGroupByTermId: /* @__PURE__ */ new Map()
	} };
}
/**
* Record-keeping helpers — keep mutations centralised so the result object
* stays consistent.
*/
function bump(record, key) {
	record[key] = (record[key] ?? 0) + 1;
}
function rememberTerm(state, taxonomyName, slug, termId) {
	let bySlug = state.plan.termIdByNameAndSlug.get(taxonomyName);
	if (!bySlug) {
		bySlug = /* @__PURE__ */ new Map();
		state.plan.termIdByNameAndSlug.set(taxonomyName, bySlug);
	}
	bySlug.set(slug, termId);
}
/**
* Look up an EmDash taxonomy def by name. Definitions are per-locale but
* a def is conceptually site-wide -- the per-locale row carries only the
* label translations.
*
* Match the runtime helper `getTaxonomyDef` (in `src/taxonomies/index.ts`):
* walk `resolveLocaleChain(locale)` so the importer picks the same def the
* runtime would later resolve to. When the chain is empty (i18n disabled)
* or every locale in the chain misses, fall through to the lowest-locale
* row so single-locale installs still see seeded defs that were inserted
* at some non-empty locale value.
*
* Without this fallback, a user importing into a non-default locale would
* see every category dropped as `missingTaxonomies` even though the seeded
* defs exist (just at the site's default locale).
*/
function parseDefCollections(raw) {
	if (!raw) return [];
	try {
		const parsed = JSON.parse(raw);
		if (Array.isArray(parsed)) return parsed.filter((c) => typeof c === "string");
	} catch {}
	return [];
}
async function findTaxonomyDef(db, name, locale) {
	const chain = resolveLocaleChain(locale);
	if (chain.length === 0) {
		const row = await db.selectFrom("_emdash_taxonomy_defs").selectAll().where("name", "=", name).orderBy("locale", "asc").executeTakeFirst();
		return row ? {
			id: row.id,
			collections: parseDefCollections(row.collections)
		} : null;
	}
	for (const tryLocale of chain) {
		const row = await db.selectFrom("_emdash_taxonomy_defs").selectAll().where("name", "=", name).where("locale", "=", tryLocale).executeTakeFirst();
		if (row) return {
			id: row.id,
			collections: parseDefCollections(row.collections)
		};
	}
	return null;
}
/**
* Rebuild the taxonomy lookup maps from the database without creating
* anything. The chunked WP import runs term creation once (first content
* chunk); every later chunk only needs the maps to attach per-post
* assignments, and two SELECTs are far cheaper than re-walking every term
* through the ensure path.
*
* ponytail: first row wins per (name, slug), ordered by locale asc — the
* same row `findBySlug` without a locale resolves to. Per-locale term
* variants beyond that are the mirror pass's job, not the importer's.
*/
async function loadTaxonomyPlanFromDb(db) {
	const state = makeState();
	const defs = await db.selectFrom("_emdash_taxonomy_defs").select(["name", "collections"]).orderBy("locale", "asc").execute();
	for (const def of defs) if (!state.plan.collectionsByTaxonomy.has(def.name)) state.plan.collectionsByTaxonomy.set(def.name, new Set(parseDefCollections(def.collections)));
	const terms = await db.selectFrom("taxonomies").select([
		"name",
		"slug",
		"id"
	]).orderBy("locale", "asc").execute();
	for (const term of terms) if (!state.plan.termIdByNameAndSlug.get(term.name)?.has(term.slug)) rememberTerm(state, term.name, term.slug, term.id);
	return state.plan;
}
/**
* Find or create a term in the given taxonomy. Returns the term id. Callers
* must verify the taxonomy def exists before calling — this helper assumes
* the def is present.
*
* Note: we don't resolve WordPress parent slugs into EmDash parent ids in
* this pass. WXR exports list categories in arbitrary order, so a category's
* parent may not exist yet when we first see it. Hierarchy is preserved at
* the data level (the parent slug is on `WxrCategory.parent`) but flattens
* in EmDash for now; restoring the tree is a follow-up improvement.
*/
async function ensureTerm(repo, state, taxonomyName, slug, label, description, locale) {
	const cached = state.plan.termIdByNameAndSlug.get(taxonomyName)?.get(slug);
	if (cached) return cached;
	const existing = await repo.findBySlug(taxonomyName, slug, locale);
	if (existing) {
		bump(state.plan.termsReused, taxonomyName);
		rememberTerm(state, taxonomyName, slug, existing.id);
		return existing.id;
	}
	const translationOf = (await repo.findBySlug(taxonomyName, slug))?.id;
	const created = await repo.create({
		name: taxonomyName,
		slug,
		label,
		data: description ? { description } : void 0,
		locale,
		translationOf
	});
	bump(state.plan.termsCreated, taxonomyName);
	rememberTerm(state, taxonomyName, slug, created.id);
	return created.id;
}
/**
* Retrieve the human label captured by the parser for a per-item
* `<category>` text body, falling back to the slug when the parser didn't
* see a label (e.g. self-closing tags or whitespace-only bodies).
*/
function labelFor(post, taxonomy, slug) {
	const key = `${taxonomy}\u0000${slug}`;
	return post.taxonomyLabels?.get(key) ?? slug;
}
/**
* Pre-import every term referenced by the WXR file.
*
* Pass 1: `wp:category` blocks. Each becomes a term in EmDash's seeded
*         `category` taxonomy.
* Pass 2: `wp:tag` blocks. Each becomes a term in `tag`.
* Pass 3: `wp:term` blocks (custom taxonomies). Skipped when no matching
*         EmDash def exists.
* Pass 4: per-item `<category domain="…" nicename="…">` assignments. WXR
*         exports sometimes reference taxonomies/terms that weren't declared
*         at the top level (older exports especially), so we backfill terms
*         from per-item assignments. Categories and tags use the seeded defs
*         and pick up the assignment text as the label; custom domains fall
*         back to the same "def must exist" rule.
*/
async function preImportWxrTaxonomies(db, posts, categories, tags, terms, locale) {
	const state = makeState();
	const repo = new TaxonomyRepository(db);
	const defCache = /* @__PURE__ */ new Map();
	const lookupDef = async (name) => {
		if (defCache.has(name)) return defCache.get(name) ?? null;
		const def = await findTaxonomyDef(db, name, locale);
		defCache.set(name, def);
		if (def) state.plan.collectionsByTaxonomy.set(name, new Set(def.collections));
		return def;
	};
	const categoryDef = await lookupDef("category");
	if (categoryDef) for (const cat of categories) {
		const slug = cat.nicename;
		const label = cat.name;
		if (!slug || !label) continue;
		await ensureTerm(repo, state, "category", slug, label, cat.description, locale);
	}
	else if (categories.length > 0) state.plan.missingTaxonomies.push("category");
	const tagDef = await lookupDef("tag");
	if (tagDef) for (const tag of tags) {
		const slug = tag.slug;
		const label = tag.name;
		if (!slug || !label) continue;
		await ensureTerm(repo, state, "tag", slug, label, tag.description, locale);
	}
	else if (tags.length > 0) state.plan.missingTaxonomies.push("tag");
	for (const term of terms) {
		if (term.taxonomy === "nav_menu" || term.taxonomy === "language") continue;
		const taxonomyName = term.taxonomy === "post_tag" ? "tag" : term.taxonomy;
		if (!await lookupDef(taxonomyName)) {
			if (!state.plan.missingTaxonomies.includes(taxonomyName)) state.plan.missingTaxonomies.push(taxonomyName);
			continue;
		}
		await ensureTerm(repo, state, taxonomyName, term.slug, term.name, term.description, locale);
	}
	let recordedMissingCategoryFromPosts = false;
	let recordedMissingTagFromPosts = false;
	for (const post of posts) {
		for (const slug of post.categories) {
			if (!categoryDef) {
				if (!recordedMissingCategoryFromPosts && !state.plan.missingTaxonomies.includes("category")) {
					state.plan.missingTaxonomies.push("category");
					recordedMissingCategoryFromPosts = true;
				}
				break;
			}
			if (state.plan.termIdByNameAndSlug.get("category")?.has(slug)) continue;
			await ensureTerm(repo, state, "category", slug, labelFor(post, "category", slug), void 0, locale);
		}
		for (const slug of post.tags) {
			if (!tagDef) {
				if (!recordedMissingTagFromPosts && !state.plan.missingTaxonomies.includes("tag")) {
					state.plan.missingTaxonomies.push("tag");
					recordedMissingTagFromPosts = true;
				}
				break;
			}
			if (state.plan.termIdByNameAndSlug.get("tag")?.has(slug)) continue;
			await ensureTerm(repo, state, "tag", slug, labelFor(post, "tag", slug), void 0, locale);
		}
		if (post.customTaxonomies) for (const [rawName, slugs] of post.customTaxonomies) {
			if (rawName === "nav_menu" || rawName === "language") continue;
			const taxonomyName = rawName === "post_tag" ? "tag" : rawName;
			if (!await lookupDef(taxonomyName)) {
				if (!state.plan.missingTaxonomies.includes(taxonomyName)) state.plan.missingTaxonomies.push(taxonomyName);
				continue;
			}
			for (const slug of slugs) {
				if (state.plan.termIdByNameAndSlug.get(taxonomyName)?.has(slug)) continue;
				await ensureTerm(repo, state, taxonomyName, slug, labelFor(post, taxonomyName, slug), void 0, locale);
			}
		}
	}
	invalidateTermCache();
	return state.plan;
}
/**
* Walk a parsed WXR post's per-item taxonomy assignments and return only
* the ones that resolve to a real EmDash term AND aren't filtered out by
* the taxonomy def's `collections` allowlist. Grouped by EmDash taxonomy
* name (so `post_tag` is already folded into `tag`). Deduplicated.
*
* This is the single source of truth for "what will the importer try to
* write for this post". Both the anchor (additive `attachToEntry`) and
* translation (per-taxonomy `setTermsForEntry`) paths drive from this map
* so they agree on which taxonomies need touching. In particular, the
* translation path uses the keys here -- not `postAssignedTaxonomies` --
* to decide which inherited pivot rows to clear, so a translation whose
* only assignment is filtered out by `collections` doesn't lose its
* inherited terms (see #1087 review feedback).
*
* Skipped taxonomies: `nav_menu` (handled by the menu importer) and
* `language` (Polylang's locale signal, already promoted to `post.locale`
* by the parser).
*/
function resolvePostTermAssignments(collection, post, plan) {
	const result = /* @__PURE__ */ new Map();
	const seen = /* @__PURE__ */ new Set();
	const tryResolve = (taxonomyName, slug) => {
		const termId = plan.termIdByNameAndSlug.get(taxonomyName)?.get(slug);
		if (!termId) return;
		const collectionFilter = plan.collectionsByTaxonomy.get(taxonomyName);
		if (collectionFilter && collectionFilter.size > 0 && !collectionFilter.has(collection)) return;
		const dedupeKey = `${taxonomyName}\u0000${termId}`;
		if (seen.has(dedupeKey)) return;
		seen.add(dedupeKey);
		const existing = result.get(taxonomyName);
		if (existing) existing.push(termId);
		else result.set(taxonomyName, [termId]);
	};
	for (const slug of post.categories) tryResolve("category", slug);
	for (const slug of post.tags) tryResolve("tag", slug);
	if (post.customTaxonomies) for (const [rawName, slugs] of post.customTaxonomies) {
		if (rawName === "nav_menu" || rawName === "language") continue;
		const taxonomyName = rawName === "post_tag" ? "tag" : rawName;
		for (const slug of slugs) tryResolve(taxonomyName, slug);
	}
	return result;
}
/**
* Attach the taxonomy assignments parsed for a single WXR post to a freshly-
* created EmDash content row. Additive (`attachToEntry` + `ON CONFLICT DO
* NOTHING`). Used for anchors -- translations need replace-semantics per
* taxonomy and should use `setPostTermAssignmentsReplacing` instead.
*
* Returns the number of pivot rows actually inserted (excludes rows that
* already existed via the `ON CONFLICT DO NOTHING` path), so the caller can
* roll them up into the import summary without over-counting on re-imports.
*/
async function attachPostTaxonomies(db, collection, entryId, post, plan) {
	const repo = new TaxonomyRepository(db);
	const resolved = resolvePostTermAssignments(collection, post, plan);
	let attached = 0;
	for (const [, termIds] of resolved) for (const termId of termIds) if (await attachToEntryCountingInserts(db, repo, plan, collection, entryId, termId)) attached++;
	return attached;
}
/**
* Replace assignments per-taxonomy from a parsed WXR post. Used for
* translations: WPML's "Translate Independently" mode lets translators
* override term assignments per-taxonomy, not per-post. A translation that
* overrides `category` shouldn't lose its inherited `tag` or `genre`. We
* only call `setTermsForEntry(name, ids)` for taxonomies where the
* translation actually resolved at least one term -- taxonomies with no
* resolvable+permitted terms are left alone so inherited rows from
* `copyEntryTerms` stay intact.
*
* Returns the number of pivot rows after replacement (sum of `termIds`
* lists across taxonomies actually touched). Note this counts logical
* assignments, not the delta from the prior state; the import summary
* treats this as an additive count for compatibility with `attachPost-
* Taxonomies`.
*/
async function setPostTermAssignmentsReplacing(db, collection, entryId, post, plan) {
	const repo = new TaxonomyRepository(db);
	const resolved = resolvePostTermAssignments(collection, post, plan);
	let attached = 0;
	for (const [taxonomyName, termIds] of resolved) {
		await repo.setTermsForEntry(collection, entryId, taxonomyName, termIds);
		attached += termIds.length;
	}
	return attached;
}
/**
* Resolve a term id to its `translation_group` (the value
* `content_taxonomies` stores). Caches the result on the plan so
* repeated attaches of the same term don't repeat the lookup.
*/
async function termTranslationGroup(repo, plan, termId) {
	const cached = plan.translationGroupByTermId.get(termId);
	if (cached !== void 0) return cached;
	const group = (await repo.findById(termId))?.translationGroup ?? null;
	plan.translationGroupByTermId.set(termId, group);
	return group;
}
/**
* Wrapper around `TaxonomyRepository.attachToEntry` that returns whether
* an actual row was inserted (vs. silently skipped by the `ON CONFLICT DO
* NOTHING` branch). Lets the importer's `assignments` counter reflect real
* writes rather than re-import no-ops.
*
* Best-effort: we check pivot existence first, then call `attachToEntry`.
* A concurrent insert between the check and the attach would make us
* report `false` while a row was in fact inserted -- the count is for
* summary display only, never correctness.
*/
async function attachToEntryCountingInserts(db, repo, plan, collection, entryId, termId) {
	const group = await termTranslationGroup(repo, plan, termId);
	if (!group) return false;
	if (await db.selectFrom("content_taxonomies").select("collection").where("collection", "=", collection).where("entry_id", "=", entryId).where("taxonomy_id", "=", group).executeTakeFirst()) return false;
	await repo.attachToEntry(collection, entryId, termId);
	return true;
}
/**
* Mirror every term in the plan into each additional locale used by the
* incoming posts. New rows share the canonical term's `translation_group`
* so per-locale lookups (`getTermsForEntry(..., locale)`) resolve correctly
* for translations whose locale differs from the import-wide one.
*
* Without this pass, multilingual WXR imports (#1080) write all term rows
* at the upload-wide locale; the `content_taxonomies` pivot is correct (it
* stores `translation_group`, not `term id`), but
* `getTermsForEntry(collection, arabicPostId, "category", "ar")` filters on
* `taxonomies.locale = "ar"` and returns zero rows. Users see "no tags" on
* every non-canonical translation.
*
* Idempotent: skips a locale when a row already exists at `(name, slug,
* locale)`. Safe to call after `preImportWxrTaxonomies` on subsequent
* imports.
*/
async function mirrorTermsToLocales(db, plan, postLocales, canonicalLocale) {
	const localeSet = /* @__PURE__ */ new Set();
	for (const locale of postLocales) {
		if (!locale || locale === canonicalLocale) continue;
		localeSet.add(locale);
	}
	if (localeSet.size === 0) return;
	const repo = new TaxonomyRepository(db);
	for (const [taxonomyName, bySlug] of plan.termIdByNameAndSlug) for (const [slug, canonicalTermId] of bySlug) {
		const cachedGroup = await termTranslationGroup(repo, plan, canonicalTermId);
		if (!cachedGroup) continue;
		const canonicalGroup = cachedGroup;
		for (const locale of localeSet) {
			const existing = await repo.findBySlug(taxonomyName, slug, locale);
			if (existing) {
				if (existing.translationGroup !== canonicalGroup) throw new WxrTaxonomyConflictError(`Cannot import: term "${taxonomyName}/${slug}" already exists at locale "${locale}" in a different translation group than the canonical row at this import's locale. Reconcile the rows in the admin (re-link via translationOf, or delete one) and retry.`);
				continue;
			}
			try {
				await repo.create({
					name: taxonomyName,
					slug,
					label: slug,
					locale,
					translationOf: canonicalTermId
				});
			} catch (error) {
				const message = error instanceof Error ? error.message.toLowerCase() : "";
				if (!(message.includes("unique constraint failed") || message.includes("duplicate key"))) throw error;
				const winner = await repo.findBySlug(taxonomyName, slug, locale);
				if (!winner) throw new WxrTaxonomyConflictError(`Cannot import: term "${taxonomyName}/${slug}" raced UNIQUE at locale "${locale}" but no row is visible afterwards. The concurrent transaction may have rolled back; retry the import.`, { cause: error });
				if (winner.translationGroup !== canonicalGroup) throw new WxrTaxonomyConflictError(`Cannot import: term "${taxonomyName}/${slug}" raced UNIQUE at locale "${locale}" with a different translation group. Reconcile the rows in the admin and retry.`, { cause: error });
				console.warn(`[WXR import] concurrent writer beat us to term "${taxonomyName}/${slug}" at locale "${locale}"; using existing row (same group).`);
			}
		}
	}
}

//#endregion
export { preImportWxrTaxonomies as a, mirrorTermsToLocales as i, isWxrTaxonomyConflictError as n, setPostTermAssignmentsReplacing as o, loadTaxonomyPlanFromDb as r, attachPostTaxonomies as t };
//# sourceMappingURL=wxr-taxonomies-BC3OYpvx.mjs.map