<?php
/**
 * PK Core - the single site-level topic pack: storage, curation, seeding.
 *
 * THE constraint that makes the platform (PK-CORE-SPEC-00 section 8): exactly
 * ONE pack, core-owned, generated once, curated by the user, used identically
 * by every plugin. The pack is a SITE ASSET stored in the pkcore_pack option.
 *
 * Storage holds core- and related-ring topics only; the fixed peripheral ring
 * is declared in code here and appended at read, never user-edited.
 *
 * Revisions: every edit bumps 'revision'. Edits that change classification
 * outcomes (add/accept/merge/archive/ring/keywords) also bump
 * 'structural_revision'; label-only renames do not, so they never trigger
 * mass reclassification (spec 8.6).
 *
 * Merges never delete: the losing slug is archived and recorded in the
 * aliases map (old-slug => current-slug, flattened on write), and all read
 * APIs resolve aliases so stored rows and older consumers keep working.
 *
 * @package PublisherKits\Core
 * @since   0.1.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

if ( ! class_exists( 'PKCORE_Pack' ) ) {

	/**
	 * Class PKCORE_Pack
	 */
	class PKCORE_Pack {

		/**
		 * Option holding the pack.
		 */
		const OPTION = 'pkcore_pack';

		/**
		 * The empty pack shape.
		 *
		 * @return array
		 */
		public static function defaults() {
			return array(
				'revision'            => 1,
				'structural_revision' => 1,
				'generated_at'        => '',
				'seeded_from'         => '',
				'topics'              => array(),
				'content_types'       => self::default_content_types(),
				'aliases'             => array(),
			);
		}

		/**
		 * The default content-type vocabulary (never removed by the core).
		 *
		 * @return string[]
		 */
		public static function default_content_types() {
			return array( 'news', 'analysis', 'feature', 'opinion', 'interview', 'how-to', 'research', 'report', 'event', 'announcement', 'sponsored' );
		}

		/**
		 * The fixed, shared peripheral ring: a deliberately tiny, generic
		 * "everything else" set appended to every pack so off-domain content
		 * lands somewhere. Declared once here, never user-edited.
		 *
		 * @return array[]
		 */
		public static function peripheral_topics() {
			return array(
				array(
					'slug'     => 'general-business-and-economy',
					'label'    => 'General Business & Economy',
					'parent'   => null,
					'ring'     => 'peripheral',
					'origin'   => 'seed',
					'status'   => 'active',
					'keywords' => array(),
				),
				array(
					'slug'     => 'politics-and-policy',
					'label'    => 'Politics & Policy',
					'parent'   => null,
					'ring'     => 'peripheral',
					'origin'   => 'seed',
					'status'   => 'active',
					'keywords' => array(),
				),
				array(
					'slug'     => 'consumer-and-lifestyle',
					'label'    => 'Consumer & Lifestyle',
					'parent'   => null,
					'ring'     => 'peripheral',
					'origin'   => 'seed',
					'status'   => 'active',
					'keywords' => array(),
				),
				array(
					'slug'     => 'other',
					'label'    => 'Other / Off-topic',
					'parent'   => null,
					'ring'     => 'peripheral',
					'origin'   => 'seed',
					'status'   => 'active',
					'keywords' => array(),
				),
			);
		}

		/**
		 * The full pack: stored topics + the fixed peripheral ring appended
		 * (stored slugs win a collision, so core beats related beats
		 * peripheral by construction).
		 *
		 * @return array Pack per PK-CORE-SPEC-00 8.1 (+ structural_revision).
		 */
		public static function get() {
			$stored = get_option( self::OPTION, array() );
			if ( ! is_array( $stored ) ) {
				$stored = array();
			}
			$pack = array_merge( self::defaults(), $stored );

			$seen = array();
			foreach ( (array) $pack['topics'] as $topic ) {
				if ( ! empty( $topic['slug'] ) ) {
					$seen[ $topic['slug'] ] = true;
				}
			}
			foreach ( self::peripheral_topics() as $topic ) {
				if ( ! isset( $seen[ $topic['slug'] ] ) ) {
					$pack['topics'][] = $topic;
				}
			}
			return $pack;
		}

		/**
		 * Topics filtered by status.
		 *
		 * @param string $status 'active' | 'suggested' | 'archived' | 'all'.
		 * @return array[]
		 */
		public static function topics( $status = 'active' ) {
			$pack = self::get();
			if ( 'all' === $status ) {
				return $pack['topics'];
			}
			$out = array();
			foreach ( $pack['topics'] as $topic ) {
				if ( isset( $topic['status'] ) && $topic['status'] === $status ) {
					$out[] = $topic;
				}
			}
			return $out;
		}

		/**
		 * One topic by slug (alias-resolved), or null.
		 *
		 * @param string $slug Topic slug.
		 * @return array|null
		 */
		public static function topic( $slug ) {
			$slug = self::resolve_alias( $slug );
			foreach ( self::get()['topics'] as $topic ) {
				if ( isset( $topic['slug'] ) && $topic['slug'] === $slug ) {
					return $topic;
				}
			}
			return null;
		}

		/**
		 * Map topic slug => ring, for active topics.
		 *
		 * @return array<string,string>
		 */
		public static function ring_map() {
			$map = array();
			foreach ( self::topics( 'active' ) as $topic ) {
				$map[ $topic['slug'] ] = isset( $topic['ring'] ) ? $topic['ring'] : 'core';
			}
			return $map;
		}

		/**
		 * Resolve a possibly-merged slug to its current slug. The stored map
		 * is flattened on write, so lookups are single-hop.
		 *
		 * @param string $slug Topic slug (possibly archived-by-merge).
		 * @return string
		 */
		public static function resolve_alias( $slug ) {
			$slug    = (string) $slug;
			$aliases = self::get()['aliases'];
			return isset( $aliases[ $slug ] ) ? (string) $aliases[ $slug ] : $slug;
		}

		/**
		 * Current pack revision.
		 *
		 * @return int
		 */
		public static function revision() {
			return (int) self::get()['revision'];
		}

		/**
		 * Current STRUCTURAL revision (the staleness watermark, spec 8.6).
		 *
		 * @return int
		 */
		public static function structural_revision() {
			return (int) self::get()['structural_revision'];
		}

		/* ------------------------------------------------------------------
		 * Curation operations (each bumps revision; structural ops also bump
		 * structural_revision). All return true on success, false otherwise.
		 * ---------------------------------------------------------------- */

		/**
		 * Add a topic.
		 *
		 * @param string $label    Display label.
		 * @param string $ring     'core' | 'related'.
		 * @param array  $keywords Match vocabulary.
		 * @param string $origin   'seed' | 'starter' | 'ai' | 'user'.
		 * @param string $status   'active' | 'suggested'.
		 * @return string|false The new slug, or false.
		 */
		public static function add_topic( $label, $ring = 'core', $keywords = array(), $origin = 'user', $status = 'active' ) {
			$label = sanitize_text_field( (string) $label );
			if ( '' === $label ) {
				return false;
			}
			$slug = sanitize_title( $label );
			if ( '' === $slug ) {
				return false;
			}
			$pack = self::stored();
			foreach ( $pack['topics'] as $topic ) {
				if ( $topic['slug'] === $slug ) {
					return false; // Slug exists (slugs are immutable and unique).
				}
			}
			foreach ( self::peripheral_topics() as $topic ) {
				if ( $topic['slug'] === $slug ) {
					return false; // Reserved by the fixed peripheral ring.
				}
			}
			// Core-ring cap: an ACTIVE core topic cannot be added once the
			// analysis axis is full - merge or move one to Related first.
			// Suggested topics do not count until accepted.
			if ( 'core' === $ring && 'active' === $status && self::core_ring_full() ) {
				return false;
			}
			$pack['topics'][] = array(
				'slug'     => $slug,
				'label'    => $label,
				'parent'   => null,
				'ring'     => in_array( $ring, array( 'core', 'related' ), true ) ? $ring : 'core',
				'origin'   => in_array( $origin, array( 'seed', 'starter', 'ai', 'user' ), true ) ? $origin : 'user',
				'status'   => in_array( $status, array( 'active', 'suggested' ), true ) ? $status : 'active',
				'keywords' => self::clean_keywords( $keywords, $label ),
			);
			self::save( $pack, true );
			return $slug;
		}

		/**
		 * Accept a suggested topic (suggested -> active). Structural.
		 *
		 * @param string $slug Topic slug.
		 * @return bool
		 */
		public static function accept_topic( $slug ) {
			// Core-ring cap: accepting into a full core ring is refused;
			// move the suggestion to Related (or make room) first.
			$topic = self::topic( $slug );
			if ( null !== $topic && 'core' === $topic['ring'] && self::core_ring_full() ) {
				return false;
			}
			return self::mutate_topic( $slug, array( 'status' => 'active' ), true, 'suggested' );
		}

		/**
		 * Rename a topic's label. Slug immutable. NON-structural (spec 8.6).
		 *
		 * @param string $slug  Topic slug.
		 * @param string $label New label.
		 * @return bool
		 */
		public static function rename_topic( $slug, $label ) {
			$label = sanitize_text_field( (string) $label );
			if ( '' === $label ) {
				return false;
			}
			return self::mutate_topic( $slug, array( 'label' => $label ), false );
		}

		/**
		 * Archive a topic (drop). Never deletes. Structural.
		 *
		 * @param string $slug Topic slug.
		 * @return bool
		 */
		public static function archive_topic( $slug ) {
			return self::mutate_topic( $slug, array( 'status' => 'archived' ), true );
		}

		/**
		 * Move a topic to another ring. Structural.
		 *
		 * @param string $slug Topic slug.
		 * @param string $ring 'core' | 'related'.
		 * @return bool
		 */
		public static function move_ring( $slug, $ring ) {
			if ( ! in_array( $ring, array( 'core', 'related' ), true ) ) {
				return false;
			}
			// Core-ring cap: promoting an ACTIVE topic into a full core ring
			// is refused - make room first.
			if ( 'core' === $ring ) {
				$topic = self::topic( $slug );
				if ( null !== $topic && 'active' === $topic['status'] && 'core' !== $topic['ring'] && self::core_ring_full() ) {
					return false;
				}
			}
			return self::mutate_topic( $slug, array( 'ring' => $ring ), true );
		}

		/**
		 * Replace a topic's keyword vocabulary. Structural.
		 *
		 * @param string $slug     Topic slug.
		 * @param array  $keywords New keyword list.
		 * @return bool
		 */
		public static function set_topic_keywords( $slug, $keywords ) {
			$topic = self::topic( $slug );
			if ( null === $topic ) {
				return false;
			}
			return self::mutate_topic( $slug, array( 'keywords' => self::clean_keywords( $keywords, $topic['label'] ) ), true );
		}

		/**
		 * Merge topic $from into topic $into: $from is archived, the alias
		 * map gains from => into (flattened), $into inherits $from's
		 * keywords. Structural.
		 *
		 * @param string $from Losing topic slug.
		 * @param string $into Surviving topic slug.
		 * @return bool
		 */
		public static function merge_topics( $from, $into ) {
			$from = (string) $from;
			$into = self::resolve_alias( (string) $into );
			if ( '' === $from || '' === $into || $from === $into ) {
				return false;
			}
			$pack     = self::stored();
			$from_idx = null;
			$into_ok  = false;
			foreach ( $pack['topics'] as $i => $topic ) {
				if ( $topic['slug'] === $from ) {
					$from_idx = $i;
				}
				if ( $topic['slug'] === $into ) {
					$into_ok = true;
				}
			}
			foreach ( self::peripheral_topics() as $topic ) {
				if ( $topic['slug'] === $into ) {
					$into_ok = true; // Merging into a peripheral bucket is allowed.
				}
			}
			if ( null === $from_idx || ! $into_ok ) {
				return false;
			}

			// Survivor inherits the loser's keywords (only stored topics have
			// editable keywords; a peripheral survivor stays keyword-less).
			foreach ( $pack['topics'] as $i => $topic ) {
				if ( $topic['slug'] === $into ) {
					$pack['topics'][ $i ]['keywords'] = array_values( array_unique( array_merge(
						(array) $topic['keywords'],
						(array) $pack['topics'][ $from_idx ]['keywords']
					) ) );
				}
			}

			$pack['topics'][ $from_idx ]['status'] = 'archived';

			// Record and FLATTEN: anything already pointing at $from now
			// points at $into (single-hop lookups forever).
			$pack['aliases'][ $from ] = $into;
			foreach ( $pack['aliases'] as $old => $current ) {
				if ( $current === $from ) {
					$pack['aliases'][ $old ] = $into;
				}
			}

			self::save( $pack, true );
			return true;
		}

		/**
		 * Add a site-specific content type. The default vocabulary is never
		 * removed. Non-structural (content types do not affect topic
		 * classification).
		 *
		 * @param string $type Content-type key.
		 * @return bool
		 */
		public static function add_content_type( $type ) {
			$type = sanitize_key( str_replace( ' ', '-', strtolower( trim( (string) $type ) ) ) );
			if ( '' === $type ) {
				return false;
			}
			$pack = self::stored();
			if ( in_array( $type, $pack['content_types'], true ) ) {
				return false;
			}
			$pack['content_types'][] = $type;
			self::save( $pack, false );
			return true;
		}

		/* ------------------------------------------------------------------
		 * Seeding (the deterministic auto-pack - spec 8.7)
		 * ---------------------------------------------------------------- */

		/**
		 * Seed the pack from the site's own categories and tags.
		 *
		 * Categories above a usage threshold become core-ring topics; frequent
		 * tags become keywords on a matching topic, or suggested topics when
		 * they match nothing. Idempotent per slug: existing topics are never
		 * overwritten, only missing ones added.
		 *
		 * @param bool $auto_accept Create seeded topics as 'active' (fresh
		 *                          install "just use this") instead of
		 *                          'suggested'.
		 * @return array { topics_added:int, keywords_added:int }
		 */
		public static function seed_from_taxonomy( $auto_accept = false ) {
			/**
			 * Filter the minimum post count for a category to seed a topic.
			 *
			 * @since 0.1.0
			 * @param int $threshold Default 3.
			 */
			$cat_min = (int) apply_filters( 'pkcore_seed_category_min_count', 3 );

			/**
			 * Filter the minimum usage count for a tag to be considered.
			 *
			 * @since 0.1.0
			 * @param int $threshold Default 5.
			 */
			$tag_min = (int) apply_filters( 'pkcore_seed_tag_min_count', 5 );

			/**
			 * Filter the maximum number of suggested topics created from
			 * unmatched tags per seeding run. Tags are scanned most-used
			 * first, so the cap keeps the top tags and skips the long tail
			 * (a site with thousands of tags would otherwise flood the
			 * suggestion list). Categories are never capped - they are the
			 * site's deliberate structure.
			 *
			 * @since 0.7.0
			 * @param int $cap Default 20.
			 */
			$tag_topic_cap  = (int) apply_filters( 'pkcore_seed_tag_topic_cap', 20 );
			$tag_topics_new = 0;

			$status = $auto_accept ? 'active' : 'suggested';
			$stats  = array(
				'topics_added'   => 0,
				'keywords_added' => 0,
				'tags_capped'    => 0,
			);

			$categories = get_terms(
				array(
					'taxonomy'   => 'category',
					'hide_empty' => true,
					'orderby'    => 'count',
					'order'      => 'DESC',
					'number'     => 200,
				)
			);
			if ( is_wp_error( $categories ) ) {
				$categories = array();
			}
			foreach ( $categories as $term ) {
				if ( (int) $term->count < $cat_min ) {
					continue;
				}
				if ( 'uncategorized' === $term->slug ) {
					continue;
				}
				$added = self::add_topic( $term->name, 'core', array( $term->name ), 'seed', $status );
				if ( false !== $added ) {
					$stats['topics_added']++;
				}
			}

			$tags = get_terms(
				array(
					'taxonomy'   => 'post_tag',
					'hide_empty' => true,
					'orderby'    => 'count',
					'order'      => 'DESC',
					'number'     => 500,
				)
			);
			if ( is_wp_error( $tags ) ) {
				$tags = array();
			}
			foreach ( $tags as $term ) {
				if ( (int) $term->count < $tag_min ) {
					continue;
				}
				$home = self::find_topic_for_term( $term->name );
				if ( null !== $home ) {
					$topic    = self::topic( $home );
					$existing = null !== $topic ? (array) $topic['keywords'] : array();
					$keyword  = strtolower( trim( $term->name ) );
					if ( ! in_array( $keyword, $existing, true ) ) {
						$existing[] = $keyword;
						if ( self::set_topic_keywords( $home, $existing ) ) {
							$stats['keywords_added']++;
						}
					}
				} else {
					if ( $tag_topics_new >= $tag_topic_cap ) {
						$stats['tags_capped']++;
						continue;
					}
					$added = self::add_topic( $term->name, 'core', array( $term->name ), 'seed', 'suggested' );
					if ( false !== $added ) {
						$stats['topics_added']++;
						$tag_topics_new++;
					}
				}
			}

			$pack                 = self::stored();
			$pack['generated_at'] = current_time( 'mysql', true );
			if ( '' === $pack['seeded_from'] ) {
				$pack['seeded_from'] = 'taxonomy';
			}
			self::save( $pack, false );

			return $stats;
		}

		/**
		 * Find the stored (non-peripheral) topic a term name belongs under:
		 * exact/containment match against topic labels and slugs.
		 *
		 * @param string $name Term name.
		 * @return string|null Topic slug, or null when nothing matches.
		 */
		private static function find_topic_for_term( $name ) {
			$needle = strtolower( trim( (string) $name ) );
			if ( '' === $needle ) {
				return null;
			}
			$needle_slug = sanitize_title( $needle );
			foreach ( self::stored()['topics'] as $topic ) {
				if ( 'archived' === $topic['status'] ) {
					continue;
				}
				$label = strtolower( (string) $topic['label'] );
				if ( $label === $needle || $topic['slug'] === $needle_slug ) {
					return $topic['slug'];
				}
				// Containment either way, whole-word ("HR" under "HR Strategy",
				// "employment law" under "Law"), guarded against short noise.
				if ( strlen( $needle ) >= 4 && false !== strpos( $label, $needle ) ) {
					return $topic['slug'];
				}
				if ( strlen( $label ) >= 4 && false !== strpos( $needle, $label ) ) {
					return $topic['slug'];
				}
			}
			return null;
		}

		/**
		 * The maximum ACTIVE core-ring topics (owner product rule: charts and
		 * tables beyond ~12-15 topics become statistically feeble and
		 * visually messy; the core ring is the only analysis axis). Related
		 * and peripheral rings are uncapped holding buckets.
		 *
		 * @since 0.8.0
		 * @return int
		 */
		public static function core_ring_cap() {
			/**
			 * Filter the active core-ring topic cap.
			 *
			 * @since 0.8.0
			 * @param int $cap Default 15.
			 */
			return max( 1, (int) apply_filters( 'pkcore_core_ring_cap', 15 ) );
		}

		/**
		 * Count of ACTIVE core-ring topics (peripheral excluded by ring).
		 *
		 * @since 0.8.0
		 * @return int
		 */
		public static function core_ring_count() {
			$count = 0;
			foreach ( self::stored()['topics'] as $topic ) {
				if ( 'active' === $topic['status'] && 'core' === $topic['ring'] ) {
					$count++;
				}
			}
			return $count;
		}

		/**
		 * Whether the active core ring is at its cap.
		 *
		 * @since 0.8.0
		 * @return bool
		 */
		public static function core_ring_full() {
			return self::core_ring_count() >= self::core_ring_cap();
		}

		/**
		 * Discard suggested topics - one by slug, or ALL when $slug is null.
		 *
		 * Deliberate, narrow exception to the never-delete rule: that rule
		 * protects HISTORY (stored classification rows and aliases reference
		 * active/archived topics), but a topic still in 'suggested' status
		 * has never been classified against and can never be an alias
		 * target, so removing it loses nothing. Lets the operator clear a
		 * noisy seeding run instead of archiving junk one row at a time.
		 *
		 * @since 0.7.0
		 * @param string|null $slug Suggested topic slug, or null for all.
		 * @return int Number of suggestions removed.
		 */
		public static function discard_suggested( $slug = null ) {
			$slug    = null === $slug ? null : sanitize_title( (string) $slug );
			$pack    = self::stored();
			$kept    = array();
			$removed = 0;
			foreach ( $pack['topics'] as $topic ) {
				if ( 'suggested' === $topic['status'] && ( null === $slug || $topic['slug'] === $slug ) ) {
					$removed++;
					continue;
				}
				$kept[] = $topic;
			}
			if ( $removed > 0 ) {
				$pack['topics'] = $kept;
				self::save( $pack, false ); // Suggestions never classify - non-structural.
			}
			return $removed;
		}

		/* ------------------------------------------------------------------
		 * Starters (curated accelerator packs - spec 8.8)
		 *
		 * Hand-authored sector packs live in provider plugins (the full
		 * Classifier ships the reference set) and register on the
		 * 'pkcore_starters' filter. Auto-derive (seeding) stays the DEFAULT;
		 * a starter is an optional accelerator the user explicitly imports.
		 * ---------------------------------------------------------------- */

		/**
		 * The registered starter packs, validated and keyed by slug.
		 *
		 * Starter shape (what providers put on the 'pkcore_starters' filter):
		 *   slug          string   unique, e.g. 'commercial-property'
		 *   label         string   e.g. 'Commercial Property'
		 *   description   string   optional one-liner (e.g. the vertical)
		 *   topics        array[]  each: { slug?: string (canonical; derived
		 *                          from label when absent), label: string,
		 *                          ring?: 'core'|'related' (default 'core'),
		 *                          keywords?: string[] }
		 *   content_types string[] optional additions to the vocabulary
		 *
		 * @since 0.3.0
		 * @return array<string,array>
		 */
		public static function starters() {
			/**
			 * Filter the available starter packs.
			 *
			 * @since 0.3.0
			 * @param array $starters List of starter definitions (see
			 *                        PKCORE_Pack::starters() docblock).
			 */
			$registered = apply_filters( 'pkcore_starters', array() );

			$out = array();
			foreach ( (array) $registered as $starter ) {
				if ( ! is_array( $starter ) || empty( $starter['slug'] ) || empty( $starter['label'] ) ) {
					continue;
				}
				$slug = sanitize_key( (string) $starter['slug'] );
				if ( '' === $slug || isset( $out[ $slug ] ) ) {
					continue;
				}
				$out[ $slug ] = array(
					'slug'          => $slug,
					'label'         => sanitize_text_field( (string) $starter['label'] ),
					'description'   => sanitize_text_field( (string) ( isset( $starter['description'] ) ? $starter['description'] : '' ) ),
					'topics'        => isset( $starter['topics'] ) && is_array( $starter['topics'] ) ? $starter['topics'] : array(),
					'content_types' => isset( $starter['content_types'] ) && is_array( $starter['content_types'] ) ? $starter['content_types'] : array(),
				);
			}
			return $out;
		}

		/**
		 * Import a registered starter into THE site pack. Structural.
		 *
		 * Topics are created with origin 'starter' and status 'active' (an
		 * import is an explicit user choice, unlike machine seeding). The
		 * starter's canonical topic slugs are honoured so the same starter
		 * produces the same slugs on every site. Idempotent per slug:
		 * existing topics are never overwritten, only missing ones added -
		 * importing over a seeded or curated pack only fills gaps.
		 *
		 * @since 0.3.0
		 * @param string $slug Starter slug (must be registered).
		 * @return array|false { topics_added:int, topics_skipped:int,
		 *                       content_types_added:int }, or false when the
		 *                       starter is not registered.
		 */
		public static function import_starter( $slug ) {
			$slug     = sanitize_key( (string) $slug );
			$starters = self::starters();
			if ( '' === $slug || ! isset( $starters[ $slug ] ) ) {
				return false;
			}
			$starter = $starters[ $slug ];

			$stats = array(
				'topics_added'        => 0,
				'topics_skipped'      => 0,
				'topics_to_related'   => 0,
				'content_types_added' => 0,
			);

			$pack        = self::stored();
			$existing    = array();
			$core_active = 0;
			foreach ( $pack['topics'] as $topic ) {
				$existing[ $topic['slug'] ] = true;
				if ( 'active' === $topic['status'] && 'core' === $topic['ring'] ) {
					$core_active++;
				}
			}
			foreach ( self::peripheral_topics() as $topic ) {
				$existing[ $topic['slug'] ] = true; // Reserved by the fixed ring.
			}

			foreach ( $starter['topics'] as $topic ) {
				if ( ! is_array( $topic ) ) {
					continue;
				}
				$label = sanitize_text_field( (string) ( isset( $topic['label'] ) ? $topic['label'] : '' ) );
				if ( '' === $label ) {
					continue;
				}
				$tslug = isset( $topic['slug'] ) && '' !== (string) $topic['slug'] ? sanitize_title( (string) $topic['slug'] ) : sanitize_title( $label );
				if ( '' === $tslug ) {
					continue;
				}
				if ( isset( $existing[ $tslug ] ) ) {
					$stats['topics_skipped']++;
					continue;
				}
				$ring = isset( $topic['ring'] ) && in_array( $topic['ring'], array( 'core', 'related' ), true ) ? $topic['ring'] : 'core';
				// Core-ring cap: overflow lands in the Related ring instead
				// of failing the import - the operator can re-ring freely.
				if ( 'core' === $ring ) {
					if ( $core_active >= self::core_ring_cap() ) {
						$ring = 'related';
						$stats['topics_to_related']++;
					} else {
						$core_active++;
					}
				}
				$pack['topics'][] = array(
					'slug'     => $tslug,
					'label'    => $label,
					'parent'   => null,
					'ring'     => $ring,
					'origin'   => 'starter',
					'status'   => 'active',
					'keywords' => self::clean_keywords( isset( $topic['keywords'] ) ? $topic['keywords'] : array(), $label ),
				);
				$existing[ $tslug ] = true;
				$stats['topics_added']++;
			}

			foreach ( $starter['content_types'] as $type ) {
				$type = sanitize_key( str_replace( ' ', '-', strtolower( trim( (string) $type ) ) ) );
				if ( '' !== $type && ! in_array( $type, $pack['content_types'], true ) ) {
					$pack['content_types'][] = $type;
					$stats['content_types_added']++;
				}
			}

			if ( $stats['topics_added'] > 0 || $stats['content_types_added'] > 0 ) {
				$pack['generated_at'] = current_time( 'mysql', true );
				if ( '' === $pack['seeded_from'] ) {
					$pack['seeded_from'] = 'starter:' . $slug;
				}
				self::save( $pack, true );
			}

			return $stats;
		}

		/* ------------------------------------------------------------------
		 * Internals
		 * ---------------------------------------------------------------- */

		/**
		 * The stored pack only (no peripheral append), defaults merged.
		 *
		 * @return array
		 */
		private static function stored() {
			$stored = get_option( self::OPTION, array() );
			if ( ! is_array( $stored ) ) {
				$stored = array();
			}
			$pack           = array_merge( self::defaults(), $stored );
			$pack['topics'] = array_values( (array) $pack['topics'] );
			return $pack;
		}

		/**
		 * Apply changes to one stored topic and save.
		 *
		 * @param string      $slug           Topic slug (alias-resolved).
		 * @param array       $changes        key => value to set on the topic.
		 * @param bool        $structural     Whether this bumps the staleness
		 *                                    watermark.
		 * @param string|null $require_status Only mutate when the topic is in
		 *                                    this status (null = any).
		 * @return bool
		 */
		private static function mutate_topic( $slug, $changes, $structural, $require_status = null ) {
			$slug = self::resolve_alias( $slug );
			$pack = self::stored();
			foreach ( $pack['topics'] as $i => $topic ) {
				if ( $topic['slug'] !== $slug ) {
					continue;
				}
				if ( null !== $require_status && $topic['status'] !== $require_status ) {
					return false;
				}
				foreach ( $changes as $key => $value ) {
					$pack['topics'][ $i ][ $key ] = $value;
				}
				self::save( $pack, $structural );
				return true;
			}
			return false;
		}

		/**
		 * Persist the stored pack, bump revision(s), announce.
		 *
		 * @param array $pack       Stored-pack array (no peripheral entries).
		 * @param bool  $structural Whether classification outcomes changed.
		 * @return void
		 */
		private static function save( $pack, $structural ) {
			$pack['revision'] = (int) $pack['revision'] + 1;
			if ( $structural ) {
				$pack['structural_revision'] = (int) $pack['revision'];
			}
			update_option( self::OPTION, $pack, false );

			/**
			 * Fires when the site pack changes.
			 *
			 * @since 0.1.0
			 * @param int   $revision New pack revision.
			 * @param array $pack     The full pack (peripheral ring included).
			 */
			do_action( 'pkcore_pack_updated', (int) $pack['revision'], self::get() );
		}

		/**
		 * Normalise a keyword list; always includes the topic's own label.
		 *
		 * @param array|string $keywords Raw keywords.
		 * @param string       $label    Topic label (always a keyword).
		 * @return string[]
		 */
		private static function clean_keywords( $keywords, $label = '' ) {
			if ( is_string( $keywords ) ) {
				$keywords = preg_split( '/[\r\n,]+/', $keywords );
			}
			if ( ! is_array( $keywords ) ) {
				$keywords = array();
			}
			if ( '' !== (string) $label ) {
				$keywords[] = (string) $label;
			}
			$out = array();
			foreach ( $keywords as $keyword ) {
				$keyword = strtolower( sanitize_text_field( trim( (string) $keyword ) ) );
				if ( '' !== $keyword ) {
					$out[] = $keyword;
				}
			}
			return array_values( array_unique( $out ) );
		}
	}
}
