CountyCollective Reference

ArcGIS Boundary Import

Original ArcGIS Portal and FeatureServer import, conversion, and source-change detection logic.

Back to CountyCollective Reference

wordpress-plugin/includes/gis/class-cgop-arcgis-boundary-import.php

<?php
/**
 * County GOP Core — ArcGIS Portal / FeatureServer boundary import.
 *
 * Extracted from theme/cgop-theme/inc/arcgis-boundary-import.php in Pass 08.
 * Extends the GIS Boundary Import admin page with ArcGIS REST support.
 * Public pages must never make live ArcGIS requests — this is admin import only.
 *
 * Cron lifecycle: daily check unscheduled on plugin deactivation (not switch_theme)
 * per Pass 08 decision P08-002.
 *
 * Option keys, function names, admin slugs, and action names are frozen legacy
 * identifiers — do not rename without explicit owner approval.
 *
 * @package CountyGOPCore
 */

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

define( 'CGOP_ARCGIS_SOURCES_OPTION', 'CGOP_arcgis_boundary_sources' );
define( 'CGOP_ARCGIS_MAPS_OPTION', 'CGOP_arcgis_generated_maps' );
define( 'CGOP_ARCGIS_SUBDIR', 'arcgis' );

// ---------------------------------------------------------------------------
// Source settings helpers
// ---------------------------------------------------------------------------

function CGOP_arcgis_get_sources() {
	$saved = get_option( CGOP_ARCGIS_SOURCES_OPTION, array() );
	return is_array( $saved ) ? $saved : array();
}

function CGOP_arcgis_save_sources( array $sources ) {
	update_option( CGOP_ARCGIS_SOURCES_OPTION, $sources );
}

function CGOP_arcgis_get_generated_maps() {
	$saved = get_option( CGOP_ARCGIS_MAPS_OPTION, array() );
	return is_array( $saved ) ? $saved : array();
}

function CGOP_arcgis_save_generated_maps( array $maps ) {
	update_option( CGOP_ARCGIS_MAPS_OPTION, $maps );
}

function CGOP_arcgis_generate_source_id() {
	$sources = CGOP_arcgis_get_sources();
	for ( $i = 0; $i < 20; $i++ ) {
		$id = 'arc-' . substr( wp_generate_uuid4(), 0, 13 );
		if ( ! isset( $sources[ $id ] ) ) {
			return $id;
		}
	}
	return 'arc-' . wp_generate_uuid4();
}

function CGOP_arcgis_generate_map_id() {
	$maps = CGOP_arcgis_get_generated_maps();
	for ( $i = 0; $i < 20; $i++ ) {
		$id = 'arcmap-' . substr( wp_generate_uuid4(), 0, 13 );
		if ( ! isset( $maps[ $id ] ) ) {
			return $id;
		}
	}
	return 'arcmap-' . wp_generate_uuid4();
}

/**
 * Parse a Portal item URL or bare item ID and extract the item ID and portal base URL.
 *
 * @param string $input Full portal URL or bare 32-char item ID.
 * @param string $default_base Default portal base URL.
 * @return array { item_id, portal_base_url } or { item_id => '', portal_base_url => $default_base } on failure.
 */
function CGOP_arcgis_parse_portal_input( $input, $default_base = 'https://gisdata.in.gov/portal' ) {
	$input = trim( (string) $input );
	$result = array(
		'item_id'         => '',
		'portal_base_url' => $default_base,
	);

	if ( ! $input ) {
		return $result;
	}

	// Bare 32-hex-char item ID.
	if ( preg_match( '/^[a-f0-9]{32}$/i', $input ) ) {
		$result['item_id'] = strtolower( $input );
		return $result;
	}

	// Full portal item URL: .../portal/home/item.html?id=... or .../portal/sharing/rest/content/items/...
	if ( preg_match( '/[?&]id=([a-f0-9]{32})/i', $input, $m ) ) {
		$result['item_id'] = strtolower( $m[1] );
	} elseif ( preg_match( '/\/items\/([a-f0-9]{32})/i', $input, $m ) ) {
		$result['item_id'] = strtolower( $m[1] );
	}

	// Extract portal base URL.
	if ( preg_match( '#^(https?://[^/]+/[^/]+)/#i', $input, $m ) ) {
		$result['portal_base_url'] = rtrim( $m[1], '/' );
	}

	return $result;
}

// ---------------------------------------------------------------------------
// File path/URL helpers
// ---------------------------------------------------------------------------

/**
 * Return the ArcGIS generated-files directory (absolute path).
 *
 * Delegates to CGOP_gis_get_generated_dir() for the county-aware base
 * path, then appends the ArcGIS subdirectory.
 *
 * @return string Absolute filesystem path (no trailing slash).
 */
function CGOP_arcgis_get_generated_dir() {
	$base = CGOP_gis_get_generated_dir();
	return trailingslashit( $base ) . CGOP_ARCGIS_SUBDIR;
}

/**
 * Return a public URL for an ArcGIS generated file.
 *
 * @param string $relative_file Filename relative to the ArcGIS subdirectory.
 * @return string Full URL.
 */
function CGOP_arcgis_get_generated_url( $relative_file ) {
	return CGOP_gis_get_generated_file_url( CGOP_ARCGIS_SUBDIR . '/' . ltrim( $relative_file, '/' ) );
}

// ---------------------------------------------------------------------------
// ArcGIS REST fetch helpers
// ---------------------------------------------------------------------------

function CGOP_arcgis_wp_get( $url, $extra_args = array() ) {
	$args = wp_parse_args( $extra_args, array(
		'timeout'   => 20,
		'sslverify' => true,
	) );
	$response = wp_safe_remote_get( esc_url_raw( $url ), $args );

	if ( is_wp_error( $response ) ) {
		return $response;
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	if ( $code < 200 || $code >= 300 ) {
		return new WP_Error( 'http_error', 'HTTP ' . $code );
	}

	$body = wp_remote_retrieve_body( $response );
	$data = json_decode( $body, true );

	if ( null === $data ) {
		return new WP_Error( 'json_parse', 'Response is not valid JSON.' );
	}

	if ( isset( $data['error'] ) ) {
		$msg = is_array( $data['error'] )
			? ( $data['error']['message'] ?? 'ArcGIS error.' )
			: (string) $data['error'];
		return new WP_Error( 'arcgis_error', $msg );
	}

	return $data;
}

/**
 * Fetch ArcGIS Portal item JSON.
 *
 * @param string $portal_base Portal base URL.
 * @param string $item_id     32-char item ID.
 * @return array|WP_Error
 */
function CGOP_arcgis_fetch_item_json( $portal_base, $item_id ) {
	$url = trailingslashit( $portal_base ) . 'sharing/rest/content/items/' . rawurlencode( $item_id ) . '?f=json';
	return CGOP_arcgis_wp_get( $url );
}

/**
 * Discover FeatureServer URL from item JSON.
 *
 * @param array $item Item JSON decoded.
 * @return string Service URL or empty string.
 */
function CGOP_arcgis_discover_service_url( array $item ) {
	// Field 'url' is the direct service URL for hosted feature layers.
	if ( ! empty( $item['url'] ) ) {
		return rtrim( (string) $item['url'], '/' );
	}
	return '';
}

/**
 * Fetch FeatureServer layer list.
 *
 * @param string $service_url FeatureServer base URL.
 * @return array|WP_Error Array with 'layers' key.
 */
function CGOP_arcgis_fetch_layers( $service_url ) {
	$url = rtrim( $service_url, '/' ) . '?f=json';
	$data = CGOP_arcgis_wp_get( $url );
	if ( is_wp_error( $data ) ) {
		return $data;
	}
	return isset( $data['layers'] ) ? $data : new WP_Error( 'no_layers', 'No layers found in FeatureServer metadata.' );
}

/**
 * Fetch a lightweight preview of a layer (5 records, no geometry).
 *
 * @param string $service_url FeatureServer base URL.
 * @param int    $layer_id    Layer ID.
 * @return array|WP_Error
 */
function CGOP_arcgis_preview_layer( $service_url, $layer_id ) {
	$url = rtrim( $service_url, '/' ) . '/' . (int) $layer_id
		. '/query?where=1%3D1&outFields=*&returnGeometry=false&resultRecordCount=5&f=json';
	return CGOP_arcgis_wp_get( $url );
}

/**
 * Fetch layer features as GeoJSON. Tries native GeoJSON first, falls back to ArcGIS JSON conversion.
 *
 * @param string $service_url FeatureServer base URL.
 * @param int    $layer_id    Layer ID.
 * @param string $where       WHERE clause (default: 1=1).
 * @return array|WP_Error GeoJSON FeatureCollection array.
 */
function CGOP_arcgis_fetch_layer_geojson( $service_url, $layer_id, $where = '1=1' ) {
	$base  = rtrim( $service_url, '/' ) . '/' . (int) $layer_id . '/query';
	$query = '?where=' . rawurlencode( $where ) . '&outFields=*&returnGeometry=true&outSR=4326&f=geojson';

	$response = wp_safe_remote_get( esc_url_raw( $base . $query ), array( 'timeout' => 30 ) );

	if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) {
		$body = wp_remote_retrieve_body( $response );
		$data = json_decode( $body, true );
		if ( is_array( $data ) && isset( $data['type'] ) && 'FeatureCollection' === $data['type'] ) {
			return $data;
		}
	}

	// Fallback: fetch ArcGIS JSON and convert.
	$query_json = '?where=' . rawurlencode( $where ) . '&outFields=*&returnGeometry=true&outSR=4326&f=json';
	$data       = CGOP_arcgis_wp_get( $base . $query_json );
	if ( is_wp_error( $data ) ) {
		return $data;
	}

	return CGOP_arcgis_convert_esri_to_geojson( $data );
}

/**
 * Convert ArcGIS FeatureSet (f=json) to GeoJSON FeatureCollection.
 *
 * @param array $featureset ArcGIS FeatureSet.
 * @return array|WP_Error GeoJSON FeatureCollection.
 */
function CGOP_arcgis_convert_esri_to_geojson( array $featureset ) {
	if ( empty( $featureset['features'] ) ) {
		return new WP_Error( 'no_features', 'No features returned from query.' );
	}

	$geo_features = array();

	foreach ( $featureset['features'] as $f ) {
		$attrs    = is_array( $f['attributes'] ?? null ) ? $f['attributes'] : array();
		$geometry = $f['geometry'] ?? null;

		$geo_geom = CGOP_arcgis_esri_geometry_to_geojson( $geometry );
		if ( null === $geo_geom ) {
			continue;
		}

		$geo_features[] = array(
			'type'       => 'Feature',
			'properties' => $attrs,
			'geometry'   => $geo_geom,
		);
	}

	if ( empty( $geo_features ) ) {
		return new WP_Error( 'no_valid_features', 'No convertible polygon features found.' );
	}

	return array(
		'type'     => 'FeatureCollection',
		'features' => $geo_features,
	);
}

/**
 * Convert a single ArcGIS geometry object to GeoJSON geometry.
 *
 * @param array|null $esri_geom ArcGIS geometry.
 * @return array|null GeoJSON geometry or null on failure.
 */
function CGOP_arcgis_esri_geometry_to_geojson( $esri_geom ) {
	if ( ! is_array( $esri_geom ) ) {
		return null;
	}

	// Polygon: rings array. First ring = outer boundary, rest = holes.
	if ( isset( $esri_geom['rings'] ) && is_array( $esri_geom['rings'] ) ) {
		$rings = array_map(
			function ( $ring ) {
				return array_map( function ( $pt ) { return array( (float) $pt[0], (float) $pt[1] ); }, $ring );
			},
			$esri_geom['rings']
		);
		if ( count( $rings ) === 1 ) {
			return array( 'type' => 'Polygon', 'coordinates' => $rings );
		}
		return array( 'type' => 'Polygon', 'coordinates' => $rings );
	}

	// Polyline: paths. Convert to MultiLineString (usually not used for districts).
	if ( isset( $esri_geom['paths'] ) && is_array( $esri_geom['paths'] ) ) {
		$paths = array_map(
			function ( $path ) {
				return array_map( function ( $pt ) { return array( (float) $pt[0], (float) $pt[1] ); }, $path );
			},
			$esri_geom['paths']
		);
		return array( 'type' => 'MultiLineString', 'coordinates' => $paths );
	}

	// Point.
	if ( isset( $esri_geom['x'] ) && isset( $esri_geom['y'] ) ) {
		return array( 'type' => 'Point', 'coordinates' => array( (float) $esri_geom['x'], (float) $esri_geom['y'] ) );
	}

	return null;
}

// ---------------------------------------------------------------------------
// Template / slugify helpers
// ---------------------------------------------------------------------------

function CGOP_arcgis_slugify( $value ) {
	return sanitize_title( (string) $value );
}

/**
 * Apply {TOKEN} and {TOKEN_slug} substitutions from feature attributes.
 *
 * @param string $template Template string with {FIELD} tokens.
 * @param array  $attrs    Feature attribute key => value.
 * @return string Rendered string.
 */
function CGOP_arcgis_render_template( $template, array $attrs ) {
	$result = (string) $template;

	// Substitute {FIELD_slug} first (must come before {FIELD}).
	foreach ( $attrs as $key => $value ) {
		$slug_token = '{' . $key . '_slug}';
		$result     = str_replace( $slug_token, CGOP_arcgis_slugify( (string) $value ), $result );
	}

	// Substitute {FIELD}.
	foreach ( $attrs as $key => $value ) {
		$token  = '{' . $key . '}';
		$result = str_replace( $token, (string) $value, $result );
	}

	// Common aliases.
	foreach ( $attrs as $key => $value ) {
		$upper = strtoupper( $key );
		if ( $upper !== $key ) {
			$result = str_replace( '{' . $upper . '}', (string) $value, $result );
			$result = str_replace( '{' . $upper . '_slug}', CGOP_arcgis_slugify( (string) $value ), $result );
		}
	}

	return $result;
}

// ---------------------------------------------------------------------------
// File write helpers
// ---------------------------------------------------------------------------

/**
 * Ensure the arcgis output subdirectory exists and is writable.
 *
 * @return string|WP_Error Directory path on success.
 */
function CGOP_arcgis_ensure_dir() {
	$dir = CGOP_arcgis_get_generated_dir();

	if ( ! wp_mkdir_p( $dir ) ) {
		return new WP_Error( 'mkdir_failed', 'Could not create directory: ' . $dir );
	}

	return $dir;
}

/**
 * Write a GeoJSON array to a local file under the arcgis/ subdirectory.
 *
 * @param string $filename  Filename relative to the arcgis/ directory (must end in .geojson).
 * @param array  $geojson   GeoJSON array.
 * @return string|WP_Error Relative file path (arcgis/filename.geojson) on success.
 */
function CGOP_arcgis_write_geojson( $filename, array $geojson ) {
	$filename = sanitize_file_name( ltrim( $filename, '/' ) );
	if ( ! $filename ) {
		return new WP_Error( 'bad_filename', 'Empty filename.' );
	}
	if ( ! str_ends_with( $filename, '.geojson' ) && ! str_ends_with( $filename, '.json' ) ) {
		$filename .= '.geojson';
	}

	$dir = CGOP_arcgis_ensure_dir();
	if ( is_wp_error( $dir ) ) {
		return $dir;
	}

	$tmp  = $dir . '/' . $filename . '.tmp';
	$dest = $dir . '/' . $filename;

	$json = wp_json_encode( $geojson, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );
	if ( false === $json ) {
		return new WP_Error( 'json_encode', 'Could not encode GeoJSON.' );
	}

	global $wp_filesystem;
	if ( ! function_exists( 'WP_Filesystem' ) ) {
		require_once ABSPATH . 'wp-admin/includes/file.php';
	}
	WP_Filesystem();

	if ( ! $wp_filesystem->put_contents( $tmp, $json, FS_CHMOD_FILE ) ) {
		return new WP_Error( 'write_failed', 'Could not write file: ' . $tmp );
	}

	if ( file_exists( $dest ) ) {
		wp_delete_file( $dest );
	}

	if ( ! rename( $tmp, $dest ) ) {
		wp_delete_file( $tmp );
		return new WP_Error( 'rename_failed', 'Could not rename temp file to destination.' );
	}

	return CGOP_ARCGIS_SUBDIR . '/' . $filename;
}

// ---------------------------------------------------------------------------
// Import: one feature per file
// ---------------------------------------------------------------------------

/**
 * Import an ArcGIS layer, creating one local GeoJSON file per feature.
 *
 * @param array  $source           Source definition array.
 * @param int    $layer_id         Layer ID within the FeatureServer.
 * @param string $where            WHERE clause.
 * @param string $label_template   Template for the human-readable label.
 * @param string $filename_template Template for the filename (without .geojson).
 * @param bool   $single_file      When true, write all features into one file.
 * @return array { imported, skipped, errors } — results summary.
 */
function CGOP_arcgis_import_layer( $source, $layer_id, $where, $label_template, $filename_template, $single_file = false ) {
	$service_url = $source['service_url'] ?? '';
	if ( ! $service_url ) {
		return array( 'imported' => 0, 'skipped' => 0, 'errors' => array( 'No service URL configured for this source.' ) );
	}

	$geojson = CGOP_arcgis_fetch_layer_geojson( $service_url, $layer_id, $where );
	if ( is_wp_error( $geojson ) ) {
		return array( 'imported' => 0, 'skipped' => 0, 'errors' => array( $geojson->get_error_message() ) );
	}

	if ( empty( $geojson['features'] ) ) {
		return array( 'imported' => 0, 'skipped' => 0, 'errors' => array( 'Query returned no features.' ) );
	}

	$imported    = 0;
	$skipped     = 0;
	$errors      = array();
	$now         = current_time( 'mysql' );
	$source_hash = md5( wp_json_encode( array( $source['portal_item_id'], (int) $layer_id, $where ) ) );
	$maps        = CGOP_arcgis_get_generated_maps();

	if ( $single_file ) {
		// All features in one file.
		$single_file_attrs = array();
		if ( 1 === count( $geojson['features'] ) ) {
			$single_file_attrs = is_array( $geojson['features'][0]['properties'] ?? null )
				? $geojson['features'][0]['properties']
				: array();
		}

		$rendered_filename = CGOP_arcgis_render_template( $filename_template, $single_file_attrs );
		if ( ! $rendered_filename ) {
			$rendered_filename = 'layer-' . $source['source_id'] . '-' . $layer_id;
		}
		$rendered_label = CGOP_arcgis_render_template( $label_template, $single_file_attrs );
		if ( ! $rendered_label ) {
			$rendered_label = $rendered_filename;
		}

		$result = CGOP_arcgis_write_geojson( $rendered_filename . '.geojson', $geojson );
		if ( is_wp_error( $result ) ) {
			$errors[] = $result->get_error_message();
		} else {
			$map_id        = CGOP_arcgis_generate_map_id();
			$maps[ $map_id ] = array(
				'map_id'         => $map_id,
				'source_id'      => $source['source_id'],
				'portal_item_id' => $source['portal_item_id'],
				'service_url'    => $service_url,
				'layer_id'       => (string) $layer_id,
				'layer_name'     => '',
				'feature_id'     => 'all',
				'feature_label'  => $rendered_label,
				'output_file'    => $result,
				'file_hash'      => md5( wp_json_encode( $geojson ) ),
				'source_hash'    => $source_hash,
				'created_at'     => $now,
				'updated_at'     => $now,
			);
			$imported++;
		}
	} else {
		// One file per feature.
		foreach ( $geojson['features'] as $feature ) {
			$attrs    = is_array( $feature['properties'] ?? null ) ? $feature['properties'] : array();
			$objectid = (string) ( $attrs['OBJECTID'] ?? $attrs['objectid'] ?? $attrs['FID'] ?? $imported );

			$rendered_filename = CGOP_arcgis_render_template( $filename_template, $attrs );
			if ( ! $rendered_filename ) {
				$rendered_filename = 'feature-' . $objectid;
			}

			$rendered_label = CGOP_arcgis_render_template( $label_template, $attrs );
			if ( ! $rendered_label ) {
				$rendered_label = $rendered_filename;
			}

			$single_geojson = array(
				'type'     => 'FeatureCollection',
				'features' => array( $feature ),
			);

			$result = CGOP_arcgis_write_geojson( $rendered_filename . '.geojson', $single_geojson );
			if ( is_wp_error( $result ) ) {
				$errors[] = 'Feature ' . $objectid . ': ' . $result->get_error_message();
				$skipped++;
				continue;
			}

			$map_id        = CGOP_arcgis_generate_map_id();
			$maps[ $map_id ] = array(
				'map_id'         => $map_id,
				'source_id'      => $source['source_id'],
				'portal_item_id' => $source['portal_item_id'],
				'service_url'    => $service_url,
				'layer_id'       => (string) $layer_id,
				'layer_name'     => '',
				'feature_id'     => $objectid,
				'feature_label'  => $rendered_label,
				'output_file'    => $result,
				'file_hash'      => md5( wp_json_encode( $single_geojson ) ),
				'source_hash'    => $source_hash,
				'created_at'     => $now,
				'updated_at'     => $now,
			);
			$imported++;
		}
	}

	if ( $imported > 0 ) {
		CGOP_arcgis_save_generated_maps( $maps );

		// Update source last_imported_at.
		$sources = CGOP_arcgis_get_sources();
		if ( isset( $sources[ $source['source_id'] ] ) ) {
			$sources[ $source['source_id'] ]['last_imported_at'] = $now;
			CGOP_arcgis_save_sources( $sources );
		}
	}

	return compact( 'imported', 'skipped', 'errors' );
}

// ---------------------------------------------------------------------------
// Auto-assign to Position Keys
// ---------------------------------------------------------------------------

/**
 * Attempt to match a feature label to existing Position Keys and assign the map.
 *
 * @param string $map_id     Map boundary map_id (from map_boundaries table).
 * @param string $label      Feature label to match against position labels.
 * @param bool   $overwrite  Whether to overwrite existing voting_area_map assignments.
 * @return int Number of positions updated.
 */
function CGOP_arcgis_auto_assign_to_positions( $map_id, $label, $overwrite = false ) {
	global $wpdb;

	$table  = CGOP_position_keys_table();
	$label  = trim( strtolower( (string) $label ) );
	$updated = 0;

	// County scope — skip auto-assign when no county context to avoid cross-county writes.
	$scope = function_exists( 'cgop_get_table_county_scope' ) ? cgop_get_table_county_scope( $table ) : false;
	if ( null === $scope ) {
		return 0;
	}

	if ( false !== $scope ) {
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT id, position_key, label, role_title, district, voting_area_map FROM `{$table}` WHERE page_location = 'representative' AND county_id = %s",
			$scope
		) );
	} else {
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results( "SELECT id, position_key, label, role_title, district, voting_area_map FROM `{$table}` WHERE page_location = 'representative'" );
	}

	if ( ! $rows ) {
		return 0;
	}

	foreach ( $rows as $row ) {
		if ( ! $overwrite && '' !== trim( (string) $row->voting_area_map ) ) {
			continue;
		}

		// Build normalized match string: role_title + district.
		$candidate = strtolower( trim( (string) $row->role_title . ' ' . $row->district ) );
		$candidate = preg_replace( '/\s+/', ' ', $candidate );

		// Simple containment match both ways.
		if (
			'' !== $candidate &&
			'' !== $label &&
			( str_contains( $label, $candidate ) || str_contains( $candidate, $label ) )
		) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			$wpdb->update(
				$table,
				array( 'voting_area_map' => $map_id ),
				array( 'id' => (int) $row->id ),
				array( '%s' ),
				array( '%d' )
			);
			$updated++;
		}
	}

	CGOP_position_keys_clear_cache();

	return $updated;
}

// ---------------------------------------------------------------------------
// Generated file choices integration
// ---------------------------------------------------------------------------

/**
 * Inject ArcGIS-generated maps into the GIS generated-file choices array.
 *
 * @param array $choices Existing choices from Beacon/composite maps.
 * @return array Extended choices including ArcGIS maps.
 */
function CGOP_arcgis_inject_file_choices( array $choices ) {
	$maps = CGOP_arcgis_get_generated_maps();
	if ( empty( $maps ) ) {
		return $choices;
	}

	foreach ( $maps as $map_id => $map ) {
		if ( empty( $map['output_file'] ) ) {
			continue;
		}

		$key             = 'arcgis:' . $map_id;
		$choices[ $key ] = array(
			'file'        => $map['output_file'],
			'url'         => CGOP_arcgis_get_generated_url( basename( $map['output_file'] ) ),
			'label'       => $map['feature_label'] ?: basename( $map['output_file'] ),
			'position_id' => '',
			'layer_label' => __( 'ArcGIS', 'county-gop-core' ),
			'is_arcgis'   => true,
			'arcgis_id'   => $map_id,
		);
	}

	return $choices;
}
add_filter( 'CGOP_gis_generated_file_choices', 'CGOP_arcgis_inject_file_choices' );

// ---------------------------------------------------------------------------
// Change checker
// ---------------------------------------------------------------------------

/**
 * Build a stable hash from ArcGIS item and service metadata.
 *
 * @param array $item_json    Decoded item JSON.
 * @param array $service_json Decoded FeatureServer root JSON (may be empty).
 * @return string MD5 hash.
 */
function CGOP_arcgis_build_source_hash( array $item_json, array $service_json ) {
	$parts = array(
		$item_json['modified']        ?? '',
		$item_json['url']             ?? '',
		$item_json['size']            ?? '',
		$item_json['numViews']        ?? '',
	);

	if ( ! empty( $service_json['layers'] ) && is_array( $service_json['layers'] ) ) {
		foreach ( $service_json['layers'] as $layer ) {
			$parts[] = ( $layer['id'] ?? '' ) . ':' . ( $layer['name'] ?? '' );
		}
	}

	if ( ! empty( $service_json['editingInfo'] ) ) {
		$parts[] = wp_json_encode( $service_json['editingInfo'] );
	}

	return md5( implode( '|', $parts ) );
}

/**
 * Run the change check for one source.
 *
 * @param array  $source    Source definition.
 * @param string $source_id Source ID key.
 */
function CGOP_arcgis_run_check_source( $source, $source_id ) {
	$item_json    = CGOP_arcgis_fetch_item_json( $source['portal_base_url'] ?? 'https://gisdata.in.gov/portal', $source['portal_item_id'] ?? '' );
	$service_json = array();
	$now          = current_time( 'mysql' );

	if ( is_wp_error( $item_json ) ) {
		$source['status']     = 'error';
		$source['last_error'] = $item_json->get_error_message();
		$source['last_checked_at'] = $now;
		return $source;
	}

	// Discover and try to fetch service metadata (lightweight — no geometry).
	$service_url = $source['service_url'] ?: CGOP_arcgis_discover_service_url( $item_json );
	if ( $service_url ) {
		$svc = CGOP_arcgis_wp_get( rtrim( $service_url, '/' ) . '?f=json' );
		if ( ! is_wp_error( $svc ) ) {
			$service_json = $svc;
		}
		if ( ! $source['service_url'] ) {
			$source['service_url'] = $service_url;
		}
	}

	$hash = CGOP_arcgis_build_source_hash( $item_json, $service_json );

	$source['last_checked_at'] = $now;
	$source['detected_hash']   = $hash;
	$source['last_error']      = '';

	if ( '' === $source['last_hash'] ) {
		// First check — store hash as baseline.
		$source['last_hash'] = $hash;
		$source['status']    = 'current';
	} elseif ( $hash !== $source['last_hash'] ) {
		$source['status'] = 'changed';
	} else {
		$source['status'] = 'current';
	}

	return $source;
}

/**
 * Run the daily change check for all enabled sources.
 */
function CGOP_arcgis_run_check_all() {
	$sources = CGOP_arcgis_get_sources();
	if ( empty( $sources ) ) {
		return;
	}

	$changed = false;
	foreach ( $sources as $source_id => $source ) {
		if ( empty( $source['enabled'] ) ) {
			continue;
		}
		if ( empty( $source['portal_item_id'] ) ) {
			continue;
		}
		$sources[ $source_id ] = CGOP_arcgis_run_check_source( $source, $source_id );
		$changed               = true;
	}

	if ( $changed ) {
		CGOP_arcgis_save_sources( $sources );
	}
}
add_action( 'CGOP_daily_arcgis_boundary_check', 'CGOP_arcgis_run_check_all' );

// ---------------------------------------------------------------------------
// WP-Cron scheduling
// ---------------------------------------------------------------------------

function CGOP_arcgis_schedule() {
	if ( ! wp_next_scheduled( 'CGOP_daily_arcgis_boundary_check' ) ) {
		wp_schedule_event( time(), 'daily', 'CGOP_daily_arcgis_boundary_check' );
	}
}
add_action( 'init', 'CGOP_arcgis_schedule' );

function CGOP_arcgis_unschedule() {
	$ts = wp_next_scheduled( 'CGOP_daily_arcgis_boundary_check' );
	if ( $ts ) {
		wp_unschedule_event( $ts, 'CGOP_daily_arcgis_boundary_check' );
	}
}
// Cron tied to plugin lifecycle, not theme switching (Pass 08 decision P08-002).
register_deactivation_hook( CGOP_CORE_PLUGIN_FILE, 'CGOP_arcgis_unschedule' );

// ---------------------------------------------------------------------------
// Admin notice
// ---------------------------------------------------------------------------

function CGOP_arcgis_admin_notice() {
	if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
		return;
	}

	$sources     = CGOP_arcgis_get_sources();
	$changed_ids = array();

	foreach ( $sources as $source_id => $source ) {
		if (
			'changed' === ( $source['status'] ?? '' ) &&
			! empty( $source['detected_hash'] ) &&
			$source['detected_hash'] !== ( $source['dismissed_hash'] ?? '' )
		) {
			$changed_ids[] = array( 'id' => $source_id, 'label' => $source['label'] ?? $source_id );
		}
	}

	if ( empty( $changed_ids ) ) {
		return;
	}

	$gis_url = function_exists( 'CGOP_gis_get_admin_url' ) ? CGOP_gis_get_admin_url() : admin_url( 'admin.php?page=cgop-gis-boundary-import' );

	foreach ( $changed_ids as $item ) {
		?>
		<div class="notice notice-warning">
			<p>
				<strong><?php esc_html_e( 'ArcGIS boundary source changed', 'county-gop-core' ); ?></strong>
				&mdash;
				<?php echo esc_html( sprintf( __( '"%s" may have changed. Review GIS Boundary Import.', 'county-gop-core' ), $item['label'] ) ); ?>
			</p>
			<p>
				<a href="<?php echo esc_url( $gis_url . '#cgop-arcgis-sources' ); ?>"><?php esc_html_e( 'Open GIS Boundary Import', 'county-gop-core' ); ?></a>
				&nbsp;&bull;&nbsp;
				<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline">
					<input type="hidden" name="action" value="CGOP_arcgis_dismiss_notice">
					<input type="hidden" name="source_id" value="<?php echo esc_attr( $item['id'] ); ?>">
					<?php wp_nonce_field( 'CGOP_arcgis_dismiss_notice_' . $item['id'] ); ?>
					<button type="submit" class="button-link"><?php esc_html_e( 'Dismiss for this change', 'county-gop-core' ); ?></button>
				</form>
			</p>
		</div>
		<?php
	}
}
add_action( 'admin_notices', 'CGOP_arcgis_admin_notice' );

// ---------------------------------------------------------------------------
// Admin-post handlers
// ---------------------------------------------------------------------------

function CGOP_arcgis_handle_add_source() {
	if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
		wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
	}
	check_admin_referer( 'CGOP_arcgis_add_source' );

	$portal_input  = sanitize_text_field( wp_unslash( $_POST['portal_item_url'] ?? '' ) );
	$portal_base   = esc_url_raw( trim( $_POST['portal_base_url'] ?? 'https://gisdata.in.gov/portal' ) );
	$label         = sanitize_text_field( wp_unslash( $_POST['arcgis_label'] ?? '' ) );
	$service_url   = esc_url_raw( trim( $_POST['service_url_override'] ?? '' ) );
	$enabled       = ! empty( $_POST['arcgis_enabled'] );
	$notes         = sanitize_textarea_field( wp_unslash( $_POST['arcgis_notes'] ?? '' ) );

	if ( ! $portal_input ) {
		CGOP_gis_redirect_with_error( __( 'Portal item URL or ID is required.', 'county-gop-core' ) );
	}

	$parsed     = CGOP_arcgis_parse_portal_input( $portal_input, $portal_base );
	$item_id    = $parsed['item_id'];
	$portal_base = $parsed['portal_base_url'];

	if ( ! $item_id ) {
		CGOP_gis_redirect_with_error( __( 'Could not parse an ArcGIS item ID from the input.', 'county-gop-core' ) );
	}

	$source_id = CGOP_arcgis_generate_source_id();
	$sources   = CGOP_arcgis_get_sources();

	$sources[ $source_id ] = array(
		'source_id'        => $source_id,
		'label'            => $label ?: 'ArcGIS Source ' . $item_id,
		'portal_item_url'  => esc_url_raw( 'https://' . wp_parse_url( $portal_base, PHP_URL_HOST ) . '/portal/home/item.html?id=' . $item_id ),
		'portal_item_id'   => $item_id,
		'portal_base_url'  => $portal_base,
		'service_url'      => $service_url,
		'enabled'          => $enabled,
		'notes'            => $notes,
		'last_checked_at'  => '',
		'last_imported_at' => '',
		'last_hash'        => '',
		'detected_hash'    => '',
		'status'           => 'never_checked',
		'last_error'       => '',
		'dismissed_hash'   => '',
	);

	CGOP_arcgis_save_sources( $sources );

	wp_safe_redirect( add_query_arg( 'arcgis_added', '1', CGOP_gis_get_admin_url() ) );
	exit;
}
add_action( 'admin_post_CGOP_arcgis_add_source', 'CGOP_arcgis_handle_add_source' );

function CGOP_arcgis_handle_delete_source() {
	if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
		wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
	}
	$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
	check_admin_referer( 'CGOP_arcgis_delete_source_' . $source_id );

	$sources = CGOP_arcgis_get_sources();
	unset( $sources[ $source_id ] );
	CGOP_arcgis_save_sources( $sources );

	wp_safe_redirect( add_query_arg( 'arcgis_deleted', '1', CGOP_gis_get_admin_url() ) );
	exit;
}
add_action( 'admin_post_CGOP_arcgis_delete_source', 'CGOP_arcgis_handle_delete_source' );

function CGOP_arcgis_handle_check_now() {
	if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
		wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
	}
	$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
	check_admin_referer( 'CGOP_arcgis_check_now_' . $source_id );

	$sources = CGOP_arcgis_get_sources();
	if ( ! isset( $sources[ $source_id ] ) ) {
		CGOP_gis_redirect_with_error( __( 'ArcGIS source not found.', 'county-gop-core' ) );
	}

	$sources[ $source_id ] = CGOP_arcgis_run_check_source( $sources[ $source_id ], $source_id );
	CGOP_arcgis_save_sources( $sources );

	wp_safe_redirect( add_query_arg( 'arcgis_checked', '1', CGOP_gis_get_admin_url() ) );
	exit;
}
add_action( 'admin_post_CGOP_arcgis_check_now', 'CGOP_arcgis_handle_check_now' );

function CGOP_arcgis_handle_dismiss_notice() {
	if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
		wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
	}
	$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
	check_admin_referer( 'CGOP_arcgis_dismiss_notice_' . $source_id );

	$sources = CGOP_arcgis_get_sources();
	if ( isset( $sources[ $source_id ] ) ) {
		$sources[ $source_id ]['dismissed_hash'] = $sources[ $source_id ]['detected_hash'] ?? '';
		CGOP_arcgis_save_sources( $sources );
	}

	$referer = wp_get_referer();
	if ( $referer && strpos( $referer, admin_url() ) === 0 ) {
		wp_safe_redirect( $referer );
	} else {
		wp_safe_redirect( CGOP_gis_get_admin_url() );
	}
	exit;
}
add_action( 'admin_post_CGOP_arcgis_dismiss_notice', 'CGOP_arcgis_handle_dismiss_notice' );

function CGOP_arcgis_handle_fetch_layers() {
	if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
		wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
	}
	$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
	check_admin_referer( 'CGOP_arcgis_fetch_layers_' . $source_id );

	$sources = CGOP_arcgis_get_sources();
	if ( ! isset( $sources[ $source_id ] ) ) {
		CGOP_gis_redirect_with_error( __( 'ArcGIS source not found.', 'county-gop-core' ) );
	}

	$source = $sources[ $source_id ];
	$item_json = CGOP_arcgis_fetch_item_json( $source['portal_base_url'], $source['portal_item_id'] );

	if ( is_wp_error( $item_json ) ) {
		CGOP_gis_redirect_with_error( __( 'Could not fetch item JSON: ', 'county-gop-core' ) . $item_json->get_error_message() );
	}

	$service_url = $source['service_url'] ?: CGOP_arcgis_discover_service_url( $item_json );

	if ( ! $service_url ) {
		CGOP_gis_redirect_with_error( __( 'No service URL found in item JSON. Add a Service URL override to this source.', 'county-gop-core' ) );
	}

	// Save discovered service URL.
	if ( ! $source['service_url'] ) {
		$sources[ $source_id ]['service_url'] = $service_url;
		CGOP_arcgis_save_sources( $sources );
	}

	$layers_data = CGOP_arcgis_fetch_layers( $service_url );
	if ( is_wp_error( $layers_data ) ) {
		CGOP_gis_redirect_with_error( __( 'Could not fetch layers: ', 'county-gop-core' ) . $layers_data->get_error_message() );
	}

	$uid = get_current_user_id();
	set_transient( 'CGOP_arcgis_layers_' . $source_id . '_' . $uid, array(
		'service_url'  => $service_url,
		'layers'       => $layers_data['layers'] ?? array(),
		'tables'       => $layers_data['tables'] ?? array(),
	), 300 );

	wp_safe_redirect( add_query_arg( array( 'arcgis_preview' => $source_id ), CGOP_gis_get_admin_url() ) );
	exit;
}
add_action( 'admin_post_CGOP_arcgis_fetch_layers', 'CGOP_arcgis_handle_fetch_layers' );

function CGOP_arcgis_handle_preview_layer() {
	if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
		wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
	}
	$source_id   = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
	$layer_id    = absint( $_POST['layer_id'] ?? 0 );
	$service_url = esc_url_raw( trim( $_POST['service_url'] ?? '' ) );
	check_admin_referer( 'CGOP_arcgis_preview_layer_' . $source_id );

	if ( ! $service_url ) {
		CGOP_gis_redirect_with_error( __( 'No service URL.', 'county-gop-core' ) );
	}

	$preview = CGOP_arcgis_preview_layer( $service_url, $layer_id );
	$uid     = get_current_user_id();

	set_transient( 'CGOP_arcgis_field_preview_' . $source_id . '_' . $layer_id . '_' . $uid, array(
		'service_url' => $service_url,
		'layer_id'    => $layer_id,
		'data'        => is_wp_error( $preview ) ? array( 'error' => $preview->get_error_message() ) : $preview,
	), 300 );

	wp_safe_redirect( add_query_arg( array(
		'arcgis_preview'       => $source_id,
		'arcgis_field_preview' => $layer_id,
	), CGOP_gis_get_admin_url() ) );
	exit;
}
add_action( 'admin_post_CGOP_arcgis_preview_layer', 'CGOP_arcgis_handle_preview_layer' );

function CGOP_arcgis_handle_import() {
	if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
		wp_die( esc_html__( 'Permission denied.', 'county-gop-core' ) );
	}
	$source_id = sanitize_key( wp_unslash( $_POST['source_id'] ?? '' ) );
	check_admin_referer( 'CGOP_arcgis_import_' . $source_id );

	$sources = CGOP_arcgis_get_sources();
	if ( ! isset( $sources[ $source_id ] ) ) {
		CGOP_gis_redirect_with_error( __( 'ArcGIS source not found.', 'county-gop-core' ) );
	}

	$source            = $sources[ $source_id ];
	$layer_id          = absint( $_POST['import_layer_id'] ?? 0 );
	$where             = sanitize_text_field( wp_unslash( $_POST['import_where'] ?? '1=1' ) ) ?: '1=1';
	$label_template    = sanitize_text_field( wp_unslash( $_POST['import_label_template'] ?? '' ) );
	$filename_template = sanitize_text_field( wp_unslash( $_POST['import_filename_template'] ?? '' ) );
	$single_file       = ! empty( $_POST['import_single_file'] );
	$auto_assign       = ! empty( $_POST['import_auto_assign'] );
	$overwrite_assign  = ! empty( $_POST['import_overwrite_assign'] );

	// Use submitted service_url if source doesn't have one.
	if ( empty( $source['service_url'] ) ) {
		$submitted_svc = esc_url_raw( trim( $_POST['service_url'] ?? '' ) );
		if ( $submitted_svc ) {
			$source['service_url'] = $submitted_svc;
		}
	}

	if ( ! $source['service_url'] ) {
		CGOP_gis_redirect_with_error( __( 'No service URL. Fetch layers first.', 'county-gop-core' ) );
	}

	$result = CGOP_arcgis_import_layer( $source, $layer_id, $where, $label_template, $filename_template, $single_file );

	// Store import result in transient for display.
	$uid = get_current_user_id();
	set_transient( 'CGOP_arcgis_import_result_' . $source_id . '_' . $uid, $result, 120 );

	// Auto-assign to Position Keys if requested.
	if ( $auto_assign && $result['imported'] > 0 ) {
		$maps = CGOP_arcgis_get_generated_maps();
		foreach ( $maps as $map_id => $map ) {
			if ( ( $map['source_id'] ?? '' ) !== $source_id ) {
				continue;
			}
			// Register a map_boundary record for this ArcGIS file so it can be assigned.
			$mb_result = CGOP_arcgis_register_map_boundary( $map );
			if ( ! is_wp_error( $mb_result ) ) {
				CGOP_arcgis_auto_assign_to_positions( $mb_result, $map['feature_label'] ?? '', $overwrite_assign );
			}
		}
	}

	wp_safe_redirect( add_query_arg( array(
		'arcgis_imported' => $result['imported'],
		'arcgis_source'   => $source_id,
	), CGOP_gis_get_admin_url() ) );
	exit;
}
add_action( 'admin_post_CGOP_arcgis_import', 'CGOP_arcgis_handle_import' );

/**
 * Handle deleting an ArcGIS generated map record and local file.
 */
function CGOP_arcgis_handle_delete_map() {
	if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
		wp_die( esc_html__( 'You do not have permission to manage GIS boundary imports.', 'county-gop-core' ) );
	}

	$map_id = sanitize_key( wp_unslash( $_POST['map_id'] ?? '' ) );
	check_admin_referer( 'CGOP_arcgis_delete_map_' . $map_id );

	$maps = CGOP_arcgis_get_generated_maps();
	if ( ! $map_id || ! isset( $maps[ $map_id ] ) ) {
		CGOP_gis_redirect_with_error( __( 'ArcGIS generated map not found.', 'county-gop-core' ) );
	}

	$map         = $maps[ $map_id ];
	$output_file = ltrim( (string) ( $map['output_file'] ?? '' ), '/' );
	$is_shared   = CGOP_arcgis_output_is_shared( $output_file, $map_id );

	if ( $output_file && ! $is_shared ) {
		$filename = basename( $output_file );
		$base     = wp_normalize_path( trailingslashit( CGOP_arcgis_get_generated_dir() ) );
		$path     = wp_normalize_path( $base . $filename );

		if ( ! str_starts_with( $path, $base ) ) {
			CGOP_gis_redirect_with_error( __( 'ArcGIS generated file path is outside the allowed map directory.', 'county-gop-core' ) );
		}

		if ( file_exists( $path ) && ! wp_delete_file( $path ) ) {
			CGOP_gis_redirect_with_error( __( 'Could not delete the ArcGIS generated GeoJSON file.', 'county-gop-core' ) );
		}
	}

	CGOP_arcgis_clear_deleted_map_assignments( $map_id, $output_file, ! $is_shared );

	unset( $maps[ $map_id ] );
	CGOP_arcgis_save_generated_maps( $maps );

	wp_safe_redirect( add_query_arg( 'arcgis_map_deleted', '1', CGOP_gis_get_admin_url() ) );
	exit;
}
add_action( 'admin_post_CGOP_arcgis_delete_map', 'CGOP_arcgis_handle_delete_map' );

/**
 * Register (or find existing) map_boundary record for an ArcGIS-generated map.
 *
 * @param array $map ArcGIS generated map record.
 * @return string|WP_Error map_id on success.
 */
function CGOP_arcgis_register_map_boundary( array $map ) {
	if ( ! function_exists( 'CGOP_get_map_boundary_rows' ) || ! function_exists( 'CGOP_insert_map_boundary' ) ) {
		return new WP_Error( 'no_map_boundaries', 'Map boundary functions unavailable.' );
	}

	$url = CGOP_arcgis_get_generated_url( basename( $map['output_file'] ?? '' ) );
	if ( ! $url ) {
		return new WP_Error( 'no_url', 'No URL for generated file.' );
	}

	// Check if a map_boundary with this URL already exists.
	foreach ( CGOP_get_map_boundary_rows() as $row ) {
		if ( $row->json_file === $url ) {
			return $row->map_id;
		}
	}

	// Create a new map_boundary record.
	return CGOP_insert_map_boundary( array(
		'position_key' => '',
		'json_file'    => $url,
	) );
}

/**
 * Determine whether another ArcGIS generated map uses the same output file.
 *
 * @param string $output_file    Relative output file path.
 * @param string $exclude_map_id Map ID being deleted.
 * @return bool
 */
function CGOP_arcgis_output_is_shared( $output_file, $exclude_map_id = '' ) {
	$output_file = ltrim( (string) $output_file, '/' );
	if ( '' === $output_file ) {
		return false;
	}

	foreach ( CGOP_arcgis_get_generated_maps() as $map_id => $map ) {
		if ( '' !== $exclude_map_id && (string) $map_id === (string) $exclude_map_id ) {
			continue;
		}
		if ( ltrim( (string) ( $map['output_file'] ?? '' ), '/' ) === $output_file ) {
			return true;
		}
	}

	return false;
}

/**
 * Clear Position Key assignments and map boundary rows for a deleted ArcGIS map.
 *
 * @param string $map_id         ArcGIS generated map ID.
 * @param string $output_file    Relative output file path.
 * @param bool   $clear_url_refs Whether URL references are safe to clear.
 * @return int Number of map boundary rows deleted.
 */
function CGOP_arcgis_clear_deleted_map_assignments( $map_id, $output_file, $clear_url_refs ) {
	$refs = array( 'arcgis:' . (string) $map_id );
	if ( $clear_url_refs && $output_file ) {
		$refs[] = CGOP_arcgis_get_generated_url( basename( $output_file ) );
	}
	$refs = array_values( array_unique( array_filter( array_map( 'strval', $refs ) ) ) );
	if ( ! $refs ) {
		return 0;
	}

	global $wpdb;
	$map_table      = CGOP_map_boundaries_table();
	$position_table = CGOP_position_keys_table();
	$deleted        = 0;

	// County scope for position_keys updates.
	$pk_scope = function_exists( 'cgop_get_table_county_scope' ) ? cgop_get_table_county_scope( $position_table ) : false;
	if ( null === $pk_scope ) {
		return 0; // Column exists but no county context — skip to avoid cross-county writes.
	}

	foreach ( $refs as $ref ) {
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$map_ids = $wpdb->get_col(
			$wpdb->prepare( "SELECT map_id FROM `{$map_table}` WHERE json_file = %s", $ref )
		);

		foreach ( $map_ids as $boundary_id ) {
			if ( false !== $pk_scope ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
				$wpdb->update( $position_table, array( 'live_in_map' => '' ), array( 'live_in_map' => $boundary_id, 'county_id' => $pk_scope ), array( '%s' ), array( '%s', '%s' ) );
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
				$wpdb->update( $position_table, array( 'voting_area_map' => '' ), array( 'voting_area_map' => $boundary_id, 'county_id' => $pk_scope ), array( '%s' ), array( '%s', '%s' ) );
			} else {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
				$wpdb->update( $position_table, array( 'live_in_map' => '' ), array( 'live_in_map' => $boundary_id ), array( '%s' ), array( '%s' ) );
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
				$wpdb->update( $position_table, array( 'voting_area_map' => '' ), array( 'voting_area_map' => $boundary_id ), array( '%s' ), array( '%s' ) );
			}

			if ( CGOP_delete_map_boundary( $boundary_id ) ) {
				$deleted++;
			}
		}
	}

	CGOP_position_keys_clear_cache();

	return $deleted;
}

// ---------------------------------------------------------------------------
// Admin section renderers (hooked into GIS page)
// ---------------------------------------------------------------------------

function CGOP_arcgis_render_gis_notices() {
	if ( ! empty( $_GET['arcgis_added'] ) ) {
		echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'ArcGIS source added.', 'county-gop-core' ) . '</p></div>';
	}
	if ( ! empty( $_GET['arcgis_deleted'] ) ) {
		echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'ArcGIS source deleted.', 'county-gop-core' ) . '</p></div>';
	}
	if ( ! empty( $_GET['arcgis_checked'] ) ) {
		echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'ArcGIS change check complete.', 'county-gop-core' ) . '</p></div>';
	}
	if ( ! empty( $_GET['arcgis_imported'] ) ) {
		echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( sprintf( __( 'ArcGIS import complete: %d file(s) created.', 'county-gop-core' ), (int) $_GET['arcgis_imported'] ) ) . '</p></div>';
	}
	if ( ! empty( $_GET['arcgis_map_deleted'] ) ) {
		echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'ArcGIS generated map deleted. Position Key assignments that pointed to it were cleared.', 'county-gop-core' ) . '</p></div>';
	}
}
add_action( 'CGOP_gis_admin_notices', 'CGOP_arcgis_render_gis_notices' );

function CGOP_arcgis_render_admin_sections() {
	if ( ! current_user_can( CGOP_GIS_IMPORT_CAP ) ) {
		return;
	}

	$sources     = CGOP_arcgis_get_sources();
	$arcgis_maps = CGOP_arcgis_get_generated_maps();
	$uid         = get_current_user_id();

	$preview_source_id = sanitize_key( $_GET['arcgis_preview'] ?? '' );
	$field_preview_lid = absint( $_GET['arcgis_field_preview'] ?? -1 );
	$import_source_id  = sanitize_key( $_GET['arcgis_source'] ?? '' );

	$layers_transient       = $preview_source_id ? get_transient( 'CGOP_arcgis_layers_' . $preview_source_id . '_' . $uid ) : null;
	$field_preview_transient = ( $preview_source_id && $field_preview_lid >= 0 )
		? get_transient( 'CGOP_arcgis_field_preview_' . $preview_source_id . '_' . $field_preview_lid . '_' . $uid )
		: null;
	$import_result = $import_source_id
		? get_transient( 'CGOP_arcgis_import_result_' . $import_source_id . '_' . $uid )
		: null;

	$status_labels = array(
		'never_checked' => __( 'Never checked', 'county-gop-core' ),
		'current'       => __( 'Current', 'county-gop-core' ),
		'changed'       => __( 'Changed', 'county-gop-core' ),
		'error'         => __( 'Error', 'county-gop-core' ),
	);
	?>

	<section class="cgop-gis-panel" id="cgop-arcgis-sources">
		<div class="cgop-gis-panel__header">
			<div>
				<h2><?php esc_html_e( 'ArcGIS State Boundary Sources', 'county-gop-core' ); ?></h2>
				<p><?php esc_html_e( 'Import district boundaries from ArcGIS Portal / FeatureServer sources. Imported files are stored locally; public pages do not make live ArcGIS requests.', 'county-gop-core' ); ?></p>
			</div>
		</div>

		<?php if ( $import_result ) : ?>
			<div class="notice notice-info" style="margin:0 0 1rem">
				<p>
					<?php
					printf(
						esc_html__( 'Import complete: %1$d imported, %2$d skipped.', 'county-gop-core' ),
						(int) ( $import_result['imported'] ?? 0 ),
						(int) ( $import_result['skipped'] ?? 0 )
					);
					?>
				</p>
				<?php if ( ! empty( $import_result['errors'] ) ) : ?>
					<ul style="margin:.5rem 0 0 1rem;list-style:disc">
						<?php foreach ( $import_result['errors'] as $err ) : ?>
							<li><?php echo esc_html( $err ); ?></li>
						<?php endforeach; ?>
					</ul>
				<?php endif; ?>
			</div>
		<?php endif; ?>

		<?php if ( $sources ) : ?>
		<table class="widefat striped cgop-gis-table">
			<thead>
				<tr>
					<th><?php esc_html_e( 'Label', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Portal Item ID', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Status', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Last Checked', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Actions', 'county-gop-core' ); ?></th>
				</tr>
			</thead>
			<tbody>
			<?php foreach ( $sources as $source_id => $source ) : ?>
				<tr>
					<td>
						<strong><?php echo esc_html( $source['label'] ?? '' ); ?></strong>
						<?php if ( ! empty( $source['notes'] ) ) : ?>
							<br><small style="color:#646970"><?php echo esc_html( $source['notes'] ); ?></small>
						<?php endif; ?>
						<?php if ( empty( $source['enabled'] ) ) : ?>
							<br><small style="color:#646970"><?php esc_html_e( '(disabled)', 'county-gop-core' ); ?></small>
						<?php endif; ?>
					</td>
					<td>
						<code><?php echo esc_html( $source['portal_item_id'] ?? '' ); ?></code>
						<?php if ( ! empty( $source['portal_item_url'] ) ) : ?>
							<br><a href="<?php echo esc_url( $source['portal_item_url'] ); ?>" target="_blank" rel="noopener noreferrer" style="font-size:.85em"><?php esc_html_e( 'View portal item', 'county-gop-core' ); ?></a>
						<?php endif; ?>
					</td>
					<td>
						<?php
						$s = $source['status'] ?? 'never_checked';
						echo esc_html( $status_labels[ $s ] ?? $s );
						if ( ! empty( $source['last_error'] ) ) :
							?>
							<br><code style="color:#c00;font-size:.8em"><?php echo esc_html( $source['last_error'] ); ?></code>
						<?php endif; ?>
					</td>
					<td><?php echo $source['last_checked_at'] ? esc_html( $source['last_checked_at'] ) : '<em>' . esc_html__( 'Never', 'county-gop-core' ) . '</em>'; ?></td>
					<td style="white-space:nowrap">
						<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline">
							<input type="hidden" name="action" value="CGOP_arcgis_fetch_layers">
							<input type="hidden" name="source_id" value="<?php echo esc_attr( $source_id ); ?>">
							<?php wp_nonce_field( 'CGOP_arcgis_fetch_layers_' . $source_id ); ?>
							<button type="submit" class="button button-small"><?php esc_html_e( 'Fetch Layers', 'county-gop-core' ); ?></button>
						</form>
						<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline">
							<input type="hidden" name="action" value="CGOP_arcgis_check_now">
							<input type="hidden" name="source_id" value="<?php echo esc_attr( $source_id ); ?>">
							<?php wp_nonce_field( 'CGOP_arcgis_check_now_' . $source_id ); ?>
							<button type="submit" class="button button-small"><?php esc_html_e( 'Check Now', 'county-gop-core' ); ?></button>
						</form>
						<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline" onsubmit="return confirm('<?php echo esc_js( __( 'Delete this ArcGIS source? Generated files will not be deleted.', 'county-gop-core' ) ); ?>')">
							<input type="hidden" name="action" value="CGOP_arcgis_delete_source">
							<input type="hidden" name="source_id" value="<?php echo esc_attr( $source_id ); ?>">
							<?php wp_nonce_field( 'CGOP_arcgis_delete_source_' . $source_id ); ?>
							<button type="submit" class="button button-small button-link-delete"><?php esc_html_e( 'Delete', 'county-gop-core' ); ?></button>
						</form>
					</td>
				</tr>
			<?php endforeach; ?>
			</tbody>
		</table>
		<?php endif; ?>

		<h3 style="margin-top:1.5rem"><?php esc_html_e( 'Add ArcGIS Source', 'county-gop-core' ); ?></h3>
		<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
			<input type="hidden" name="action" value="CGOP_arcgis_add_source">
			<?php wp_nonce_field( 'CGOP_arcgis_add_source' ); ?>
			<table class="form-table" role="presentation">
				<tr>
					<th scope="row"><label for="arcgis_label"><?php esc_html_e( 'Source Label', 'county-gop-core' ); ?></label></th>
					<td><input class="regular-text" id="arcgis_label" name="arcgis_label" type="text" placeholder="<?php esc_attr_e( 'Indiana State Districts', 'county-gop-core' ); ?>"></td>
				</tr>
				<tr>
					<th scope="row"><label for="arcgis_portal_item_url"><?php esc_html_e( 'Portal Item URL or Item ID', 'county-gop-core' ); ?></label></th>
					<td>
						<input class="large-text" id="arcgis_portal_item_url" name="portal_item_url" type="text" required placeholder="https://gisdata.in.gov/portal/home/item.html?id=06b2e034c864408992add7439a2e3eee">
						<p class="description"><?php esc_html_e( 'Paste the full Portal item page URL or just the 32-character item ID. The item ID is extracted automatically.', 'county-gop-core' ); ?></p>
					</td>
				</tr>
				<tr>
					<th scope="row"><label for="arcgis_portal_base_url"><?php esc_html_e( 'Portal Base URL', 'county-gop-core' ); ?></label></th>
					<td>
						<input class="regular-text" id="arcgis_portal_base_url" name="portal_base_url" type="url" value="https://gisdata.in.gov/portal">
						<p class="description"><?php esc_html_e( 'Default: Indiana GIS portal. Change only for other ArcGIS portals.', 'county-gop-core' ); ?></p>
					</td>
				</tr>
				<tr>
					<th scope="row"><label for="arcgis_service_url"><?php esc_html_e( 'Service URL Override', 'county-gop-core' ); ?></label></th>
					<td>
						<input class="large-text" id="arcgis_service_url" name="service_url_override" type="url" placeholder="https://services.arcgis.com/.../FeatureServer">
						<p class="description"><?php esc_html_e( 'Optional. Leave blank to auto-discover from the item JSON.', 'county-gop-core' ); ?></p>
					</td>
				</tr>
				<tr>
					<th scope="row"><?php esc_html_e( 'Enabled', 'county-gop-core' ); ?></th>
					<td><label><input type="checkbox" name="arcgis_enabled" value="1" checked> <?php esc_html_e( 'Include in daily change checks', 'county-gop-core' ); ?></label></td>
				</tr>
				<tr>
					<th scope="row"><label for="arcgis_notes"><?php esc_html_e( 'Notes', 'county-gop-core' ); ?></label></th>
					<td><input class="regular-text" id="arcgis_notes" name="arcgis_notes" type="text"></td>
				</tr>
			</table>
			<?php submit_button( __( 'Add ArcGIS Source', 'county-gop-core' ), 'secondary' ); ?>
		</form>
	</section>

	<?php if ( $layers_transient && $preview_source_id ) : ?>
		<?php CGOP_arcgis_render_layer_preview_section( $preview_source_id, $layers_transient, $field_preview_transient, $field_preview_lid, $sources ); ?>
	<?php endif; ?>

	<?php if ( $arcgis_maps ) : ?>
	<section class="cgop-gis-panel" id="cgop-arcgis-maps">
		<div class="cgop-gis-panel__header">
			<div>
				<h2><?php esc_html_e( 'ArcGIS Generated Maps', 'county-gop-core' ); ?></h2>
				<p><?php esc_html_e( 'Local GeoJSON files imported from ArcGIS sources. Each file is available in Position Keys map dropdowns under the "ArcGIS" group.', 'county-gop-core' ); ?></p>
			</div>
		</div>
		<table class="widefat striped cgop-gis-table">
			<thead>
				<tr>
					<th><?php esc_html_e( 'Label', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Output File', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Source', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Created', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Actions', 'county-gop-core' ); ?></th>
				</tr>
			</thead>
			<tbody>
			<?php foreach ( $arcgis_maps as $map_id => $map ) : ?>
				<?php $map_url = CGOP_arcgis_get_generated_url( basename( $map['output_file'] ?? '' ) ); ?>
				<tr>
					<td><?php echo esc_html( $map['feature_label'] ?? '' ); ?></td>
					<td>
						<code><?php echo esc_html( $map['output_file'] ?? '' ); ?></code>
						<?php if ( $map_url ) : ?>
							<a href="<?php echo esc_url( $map_url ); ?>" target="_blank" rel="noopener noreferrer" style="font-size:.85em"> <?php esc_html_e( 'View', 'county-gop-core' ); ?></a>
						<?php endif; ?>
					</td>
					<td>
						<?php
						$src = $sources[ $map['source_id'] ?? '' ] ?? null;
						echo esc_html( $src ? ( $src['label'] ?? $map['source_id'] ) : $map['source_id'] );
						?>
					</td>
					<td><?php echo esc_html( $map['created_at'] ?? '' ); ?></td>
					<td>
						<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
							<input type="hidden" name="action" value="CGOP_arcgis_delete_map">
							<input type="hidden" name="map_id" value="<?php echo esc_attr( $map_id ); ?>">
							<?php wp_nonce_field( 'CGOP_arcgis_delete_map_' . $map_id ); ?>
							<button type="submit" class="button button-link-delete" onclick="return confirm('<?php echo esc_attr__( 'Delete this ArcGIS generated map? Position map assignments that point to it will be cleared.', 'county-gop-core' ); ?>');">
								<?php esc_html_e( 'Delete map', 'county-gop-core' ); ?>
							</button>
						</form>
					</td>
				</tr>
			<?php endforeach; ?>
			</tbody>
		</table>
	</section>
	<?php endif; ?>
	<?php
}
add_action( 'CGOP_gis_admin_extra_sections', 'CGOP_arcgis_render_admin_sections' );

/**
 * Render the layer preview / import section.
 */
function CGOP_arcgis_render_layer_preview_section( $source_id, $layers_transient, $field_preview_transient, $field_preview_lid, $sources ) {
	$service_url = $layers_transient['service_url'] ?? '';
	$layers      = $layers_transient['layers'] ?? array();
	$source      = $sources[ $source_id ] ?? array();
	?>
	<section class="cgop-gis-panel" id="cgop-arcgis-preview">
		<div class="cgop-gis-panel__header">
			<div>
				<h2><?php esc_html_e( 'ArcGIS Layer Preview', 'county-gop-core' ); ?></h2>
				<p>
					<?php echo esc_html( sprintf( __( 'Source: %s', 'county-gop-core' ), $source['label'] ?? $source_id ) ); ?>
					&mdash;
					<code><?php echo esc_html( $service_url ); ?></code>
				</p>
			</div>
		</div>

		<?php if ( $layers ) : ?>
		<table class="widefat striped cgop-gis-table">
			<thead>
				<tr>
					<th><?php esc_html_e( 'Layer ID', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Name', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Geometry', 'county-gop-core' ); ?></th>
					<th><?php esc_html_e( 'Actions', 'county-gop-core' ); ?></th>
				</tr>
			</thead>
			<tbody>
			<?php foreach ( $layers as $layer ) : ?>
				<tr>
					<td><?php echo esc_html( $layer['id'] ?? '' ); ?></td>
					<td><?php echo esc_html( $layer['name'] ?? '' ); ?></td>
					<td><?php echo esc_html( $layer['geometryType'] ?? '' ); ?></td>
					<td>
						<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline">
							<input type="hidden" name="action" value="CGOP_arcgis_preview_layer">
							<input type="hidden" name="source_id" value="<?php echo esc_attr( $source_id ); ?>">
							<input type="hidden" name="layer_id" value="<?php echo esc_attr( $layer['id'] ?? '' ); ?>">
							<input type="hidden" name="service_url" value="<?php echo esc_attr( $service_url ); ?>">
							<?php wp_nonce_field( 'CGOP_arcgis_preview_layer_' . $source_id ); ?>
							<button type="submit" class="button button-small"><?php esc_html_e( 'Preview Fields', 'county-gop-core' ); ?></button>
						</form>
					</td>
				</tr>
			<?php endforeach; ?>
			</tbody>
		</table>
		<?php else : ?>
			<p><?php esc_html_e( 'No layers found in the FeatureServer.', 'county-gop-core' ); ?></p>
		<?php endif; ?>

		<?php if ( $field_preview_transient ) : ?>
			<?php CGOP_arcgis_render_field_preview( $source_id, $field_preview_transient, $service_url ); ?>
		<?php endif; ?>

		<h3 style="margin-top:1.5rem"><?php esc_html_e( 'Import Layer', 'county-gop-core' ); ?></h3>
		<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
			<input type="hidden" name="action" value="CGOP_arcgis_import">
			<input type="hidden" name="source_id" value="<?php echo esc_attr( $source_id ); ?>">
			<input type="hidden" name="service_url" value="<?php echo esc_attr( $service_url ); ?>">
			<?php wp_nonce_field( 'CGOP_arcgis_import_' . $source_id ); ?>
			<table class="form-table" role="presentation">
				<tr>
					<th scope="row"><label for="import_layer_id"><?php esc_html_e( 'Layer ID', 'county-gop-core' ); ?></label></th>
					<td>
						<input id="import_layer_id" name="import_layer_id" type="number" min="0" value="0" required>
					</td>
				</tr>
				<tr>
					<th scope="row"><label for="import_where"><?php esc_html_e( 'WHERE clause', 'county-gop-core' ); ?></label></th>
					<td>
						<input class="large-text" id="import_where" name="import_where" type="text" value="1=1">
						<p class="description"><?php esc_html_e( "Use 1=1 to import all features. Quote text fields, for example: district = '3'. Numeric fields can use unquoted numbers, for example: DISTRICT_ID = 3.", 'county-gop-core' ); ?></p>
					</td>
				</tr>
				<tr>
					<th scope="row"><label for="import_label_template"><?php esc_html_e( 'Label template', 'county-gop-core' ); ?></label></th>
					<td>
						<input class="large-text" id="import_label_template" name="import_label_template" type="text" value="State Senate District {DISTRICT}" placeholder="State Senate District {DISTRICT}">
						<p class="description"><?php esc_html_e( 'Tokens: {FIELD_NAME}, {FIELD_NAME_slug}. Shown in Position Keys dropdowns.', 'county-gop-core' ); ?></p>
					</td>
				</tr>
				<tr>
					<th scope="row"><label for="import_filename_template"><?php esc_html_e( 'Filename template', 'county-gop-core' ); ?></label></th>
					<td>
						<input class="large-text" id="import_filename_template" name="import_filename_template" type="text" value="state-senate-district-{DISTRICT_slug}" placeholder="state-senate-district-{DISTRICT_slug}">
						<p class="description"><?php esc_html_e( '.geojson is appended automatically. Files are saved under county-gop-maps/{county_slug}/generated/arcgis/.', 'county-gop-core' ); ?></p>
					</td>
				</tr>
				<tr>
					<th scope="row"><?php esc_html_e( 'Mode', 'county-gop-core' ); ?></th>
					<td>
						<label><input type="checkbox" name="import_single_file" value="1">
						<?php esc_html_e( 'Single file (all features in one GeoJSON)', 'county-gop-core' ); ?></label>
						<p class="description"><?php esc_html_e( 'Uncheck (default) to create one file per feature. Preferred for per-district position map assignments. If checked, template fields are only replaced when the query returns exactly one feature.', 'county-gop-core' ); ?></p>
					</td>
				</tr>
				<tr>
					<th scope="row"><?php esc_html_e( 'Auto-assign to positions', 'county-gop-core' ); ?></th>
					<td>
						<label><input type="checkbox" name="import_auto_assign" value="1">
						<?php esc_html_e( 'Try to match imported maps to Position Keys (voting_area_map only)', 'county-gop-core' ); ?></label>
						<br>
						<label><input type="checkbox" name="import_overwrite_assign" value="1">
						<?php esc_html_e( 'Overwrite existing voting_area_map assignments', 'county-gop-core' ); ?></label>
					</td>
				</tr>
			</table>
			<?php submit_button( __( 'Import Layer / Features', 'county-gop-core' ), 'primary' ); ?>
		</form>
	</section>
	<?php
}

/**
 * Render field preview for a layer.
 */
function CGOP_arcgis_render_field_preview( $source_id, $transient, $service_url ) {
	$layer_id = $transient['layer_id'] ?? 'unknown';
	$data     = $transient['data'] ?? array();

	if ( isset( $data['error'] ) ) {
		echo '<div class="notice notice-error"><p>' . esc_html( $data['error'] ) . '</p></div>';
		return;
	}

	$features = $data['features'] ?? array();
	$fields   = $data['fields'] ?? array();
	?>
	<div style="margin-top:1rem;padding:1rem;background:#f9f9f9;border:1px solid #ddd;border-radius:3px">
		<h4 style="margin:0 0 .75rem"><?php echo esc_html( sprintf( __( 'Field preview — Layer %s', 'county-gop-core' ), $layer_id ) ); ?></h4>

		<?php if ( $fields ) : ?>
			<p><strong><?php esc_html_e( 'Fields:', 'county-gop-core' ); ?></strong>
			<?php
			echo esc_html( implode( ', ', array_column( $fields, 'name' ) ) );
			?>
			</p>
		<?php endif; ?>

		<?php if ( $features ) : ?>
			<p><strong><?php esc_html_e( 'Sample attributes (up to 5):', 'county-gop-core' ); ?></strong></p>
			<table class="widefat" style="max-width:100%;overflow:auto">
				<thead>
					<tr>
						<?php foreach ( array_keys( $features[0]['attributes'] ?? array() ) as $col ) : ?>
							<th><?php echo esc_html( $col ); ?></th>
						<?php endforeach; ?>
					</tr>
				</thead>
				<tbody>
					<?php foreach ( $features as $feat ) : ?>
						<tr>
							<?php foreach ( $feat['attributes'] ?? array() as $val ) : ?>
								<td><?php echo esc_html( (string) $val ); ?></td>
							<?php endforeach; ?>
						</tr>
					<?php endforeach; ?>
				</tbody>
			</table>
		<?php else : ?>
			<p><?php esc_html_e( 'No sample features returned.', 'county-gop-core' ); ?></p>
		<?php endif; ?>
	</div>
	<?php
}