#!/usr/bin/env bash
#
# Apply exported site state to a WordPress installation.
#
# Applies imposer state modules, syncs postmark content, creates menus,
# restores YooTheme configuration, and deploys generated theme assets.
#
# Usage:
#   bash bin/imposer-apply.sh [options] [component ...]
#
# With no components specified, all steps run in order. To run only specific
# steps, list them by name:
#
#   bash bin/imposer-apply.sh menus attachments
#   bash bin/imposer-apply.sh --ignore-missing-plugins options content menus
#
# Available components (in default execution order):
#   domains      Rewrite domains in content and state files
#   options      Apply imposer state modules (plugins, theme, options, plugin-options)
#   roles        Import roles and capabilities
#   users        Import users
#   acf          Import ACF definitions (post types, taxonomies, field groups)
#   authors      Ensure content authors exist
#   content      Import postmark content (pages, posts, CPTs)
#   attachments  Import media attachment records
#   acf-remap    Remap ACF image/file field values to new attachment IDs
#   menus        Apply menu configuration + YooTheme menu ID remap
#   frontpage    Set front page and posts page
#   yootheme     Import YooTheme configuration + deploy assets
#   widgets      Import widget/sidebar configuration
#   gap-sidebar  Import GAP Sidebar configuration
#   paths        Rewrite wp-content/uploads paths in database
#   flush        Flush caches and rewrite rules
#
# Flags:
#   --ignore-missing-plugins  Skip plugins not present on the filesystem
#   --list                    List available components and exit
#
set -euo pipefail

# ---------------------------------------------------------------------------
# Component registry — defines execution order
# ---------------------------------------------------------------------------
ALL_COMPONENTS=(
  domains options roles users acf authors content attachments acf-remap
  menus frontpage permalink-manager-uris yootheme menu-locations widgets gap-sidebar paths flush
)

# ---------------------------------------------------------------------------
# Parse arguments
# ---------------------------------------------------------------------------
IGNORE_MISSING_PLUGINS=0
REQUESTED_COMPONENTS=()

for arg in "$@"; do
  case "$arg" in
    --ignore-missing-plugins) IGNORE_MISSING_PLUGINS=1 ;;
    --list)
      printf "Available components:\n"
      for c in "${ALL_COMPONENTS[@]}"; do printf "  %s\n" "$c"; done
      exit 0
      ;;
    --*) echo "Unknown option: $arg" >&2; exit 1 ;;
    *)   REQUESTED_COMPONENTS+=("$arg") ;;
  esac
done

# Validate requested components
for req in "${REQUESTED_COMPONENTS[@]}"; do
  found=0
  for c in "${ALL_COMPONENTS[@]}"; do
    [ "$req" = "$c" ] && found=1 && break
  done
  if [ "$found" -eq 0 ]; then
    echo "Unknown component: $req" >&2
    echo "Run with --list to see available components" >&2
    exit 1
  fi
done

# If no components specified, run all
if [ ${#REQUESTED_COMPONENTS[@]} -eq 0 ]; then
  REQUESTED_COMPONENTS=("${ALL_COMPONENTS[@]}")
fi

# Helper: check if a component was requested
should_run() {
  for c in "${REQUESTED_COMPONENTS[@]}"; do
    [ "$1" = "$c" ] && return 0
  done
  return 1
}

# ---------------------------------------------------------------------------
# Setup
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$SCRIPT_DIR"

WP="wp"
if [ -x vendor/bin/wp ]; then
  WP="vendor/bin/wp"
fi

IMPOSER="vendor/bin/imposer"
PLUGIN_DIR="$SCRIPT_DIR/web/app/plugins"

# ---------------------------------------------------------------------------
# Component: domains — Rewrite domains in content and state files
# ---------------------------------------------------------------------------
run_domains() {
  if [ -f .source-domain ] && [ -n "${TARGET_DOMAIN:-}" ]; then
    SOURCE_DOMAIN=$(cat .source-domain)

    escape_for_sed() { echo "$1" | sed 's/\./\\./g'; }

    REWRITE_DOMAINS=()
    if [ "$SOURCE_DOMAIN" != "$TARGET_DOMAIN" ]; then
      if grep -rFq "$SOURCE_DOMAIN" content/ imposer/ yootheme/ gap-sidebar/ 2>/dev/null; then
        REWRITE_DOMAINS+=("$SOURCE_DOMAIN")
      fi
    fi
    if [ -f .domain-rewritten ]; then
      PREV_TARGET=$(cat .domain-rewritten)
      if [ "$PREV_TARGET" != "$TARGET_DOMAIN" ] && [ "$PREV_TARGET" != "$SOURCE_DOMAIN" ]; then
        if grep -rFq "$PREV_TARGET" content/ imposer/ yootheme/ gap-sidebar/ 2>/dev/null; then
          REWRITE_DOMAINS+=("$PREV_TARGET")
        fi
      fi
    fi

    if [ ${#REWRITE_DOMAINS[@]} -gt 0 ]; then
      for REWRITE_FROM in "${REWRITE_DOMAINS[@]}"; do
        echo "==> Rewriting domain: $REWRITE_FROM → $TARGET_DOMAIN"
        SED_FROM=$(escape_for_sed "$REWRITE_FROM")
        find content/ -name '*.md' -exec \
          sed -i'' -e "/^Author:/!s|${SED_FROM}|${TARGET_DOMAIN}|g" {} + 2>/dev/null || true
        find imposer/ -name '*.state.md' -exec \
          sed -i'' -e "/email:/!s|${SED_FROM}|${TARGET_DOMAIN}|g" {} + 2>/dev/null || true
        find imposer/ -name '*.json' -exec \
          sed -i'' -e "s|${SED_FROM}|${TARGET_DOMAIN}|g" {} + 2>/dev/null || true
        find yootheme/ -name '*.json' -exec \
          sed -i'' -e "s|${SED_FROM}|${TARGET_DOMAIN}|g" {} + 2>/dev/null || true
        if [ -f gap-sidebar/config.yaml ]; then
          sed -i'' -e "s|${SED_FROM}|${TARGET_DOMAIN}|g" gap-sidebar/config.yaml
        fi
      done
      echo "$TARGET_DOMAIN" > .domain-rewritten
    else
      echo "==> No domain rewrite needed (files already use $TARGET_DOMAIN)."
    fi
  elif [ -f .source-domain ] && [ -z "${TARGET_DOMAIN:-}" ]; then
    echo "    NOTE: .source-domain exists but TARGET_DOMAIN not set — skipping domain rewrite."
    echo "    Set TARGET_DOMAIN env var if importing to a different domain."
  fi
}

# ---------------------------------------------------------------------------
# Component: options — Apply imposer state modules (plugins, theme, options)
# ---------------------------------------------------------------------------
run_options() {
  # When --ignore-missing-plugins is set, comment out missing plugins temporarily
  PLUGINS_STATE="imposer/plugins.state.md"
  PLUGINS_BACKUP=""
  if [ "$IGNORE_MISSING_PLUGINS" -eq 1 ] && [ -f "$PLUGINS_STATE" ]; then
    PLUGINS_BACKUP=$(mktemp)
    cp "$PLUGINS_STATE" "$PLUGINS_BACKUP"

    skipped=()
    while IFS= read -r plugin; do
      if [ ! -d "$PLUGIN_DIR/$plugin" ]; then
        skipped+=("$plugin")
        sed -i'' -e "s|^  ${plugin}:.*|  # ${plugin}: skipped (not present)|" "$PLUGINS_STATE"
      fi
    done < <(sed -n 's/^  \([a-z0-9_-]*\): .*/\1/p' "$PLUGINS_STATE")

    if [ ${#skipped[@]} -gt 0 ]; then
      echo "==> Ignoring missing plugins: ${skipped[*]}"
    fi
  fi

  # Restore original plugins state on function exit
  _cleanup_plugins() {
    if [ -n "$PLUGINS_BACKUP" ] && [ -f "$PLUGINS_BACKUP" ]; then
      cp "$PLUGINS_BACKUP" "$PLUGINS_STATE"
      rm -f "$PLUGINS_BACKUP"
    fi
  }
  trap _cleanup_plugins RETURN

  if [ -x "$IMPOSER" ]; then
    MODULES=()
    for f in imposer/*.state.md; do
      [ -f "$f" ] || continue
      name="$(basename "$f" .state.md)"
      [ "$name" = "menus" ] && continue
      [ "$name" = "users" ] && continue
      MODULES+=("$name")
    done

    if [ ${#MODULES[@]} -eq 0 ]; then
      echo "==> No state modules found in imposer/, skipping."
    else
      echo "==> Applying imposer state modules: ${MODULES[*]}"
      "$IMPOSER" apply "${MODULES[@]}"
    fi
  else
    echo "==> imposer not found at $IMPOSER — skipping state modules."
  fi
}

# ---------------------------------------------------------------------------
# Component: roles — Import roles and capabilities
# ---------------------------------------------------------------------------
run_roles() {
  if [ -f imposer/user_roles.json ]; then
    echo "==> Importing roles and capabilities..."
    $WP eval '
      global $wpdb;
      $imported = json_decode(file_get_contents("imposer/user_roles.json"), true);
      if (empty($imported)) { echo "    No roles in export file\n"; exit; }
      $option_name = $wpdb->get_blog_prefix() . "user_roles";
      $current = get_option($option_name) ?: [];
      $added_roles = 0; $updated_roles = 0;
      foreach ($imported as $role_slug => $role_data) {
        if (!isset($current[$role_slug])) {
          $current[$role_slug] = $role_data;
          $added_roles++;
        } else {
          $merged = false;
          foreach ($role_data["capabilities"] as $cap => $grant) {
            if (!isset($current[$role_slug]["capabilities"][$cap])) {
              $current[$role_slug]["capabilities"][$cap] = $grant;
              $merged = true;
            }
          }
          if ($merged) $updated_roles++;
        }
      }
      update_option($option_name, $current);
      echo "    Roles: $added_roles added, $updated_roles updated with new capabilities\n";
    '
  fi
}

# ---------------------------------------------------------------------------
# Component: users — Import users from exported state
# ---------------------------------------------------------------------------
run_users() {
  if [ -f imposer/users.state.md ] && grep -q '^\s*users:' imposer/users.state.md 2>/dev/null; then
    echo "==> Importing users..."
    $WP eval '
      $md = file_get_contents("imposer/users.state.md");
      if (!preg_match("/```yaml\n(.*?)```/s", $md, $m)) { echo "    No YAML block found in users.state.md\n"; exit; }
      preg_match_all("/- login: \"?([^\"\n]+)\"?\n\s+email: \"?([^\"\n]+)\"?\n\s+role: \"?([^\"\n]+)\"?/", $m[1], $matches, PREG_SET_ORDER);
      if (empty($matches)) { echo "    No user entries found\n"; exit; }
      $created = 0; $updated = 0; $skipped = 0;
      foreach ($matches as $u) {
        $login = trim($u[1], "\""); $email = trim($u[2], "\""); $role = trim($u[3], "\"");
        if (empty($login) || empty($email)) continue;
        $existing = get_user_by("email", $email);
        if (!$existing) $existing = get_user_by("login", $login);
        if ($existing) {
          if (!empty($role) && !in_array($role, $existing->roles)) {
            $existing->set_role($role);
            echo "    Updated role for {$existing->user_email}: " . implode(",", $existing->roles) . " → $role\n";
            $updated++;
          } else {
            $skipped++;
          }
        } else {
          $id = wp_insert_user(["user_login" => $login, "user_email" => $email, "role" => $role ?: "subscriber", "user_pass" => wp_generate_password()]);
          if (is_wp_error($id)) {
            echo "    WARNING: Could not create $email: " . $id->get_error_message() . "\n";
          } else {
            echo "    Created user: $email ($role)\n";
            $created++;
          }
        }
      }
      echo "    Users: $created created, $updated role-updated, $skipped unchanged\n";
    '
  fi
}

# ---------------------------------------------------------------------------
# Component: acf — Import ACF definitions
# ---------------------------------------------------------------------------
run_acf() {
  if [ -d acf/ ]; then
    echo "==> Importing ACF definitions..."
    for acf_type in acf-post-type acf-taxonomy acf-field-group; do
      [ -f "acf/${acf_type}.json" ] || continue
      $WP eval "
        // Read and strip any leading non-JSON noise (PHP notices etc.)
        \$raw = file_get_contents('acf/${acf_type}.json');
        \$start = min(
          (\$p = strpos(\$raw, '[')) !== false ? \$p : PHP_INT_MAX,
          (\$p = strpos(\$raw, '{')) !== false ? \$p : PHP_INT_MAX
        );
        if (\$start === PHP_INT_MAX) { echo '    ${acf_type}: no valid JSON found\n'; exit; }
        \$items = json_decode(substr(\$raw, \$start), true);
        if (empty(\$items)) { echo '    ${acf_type}: empty\n'; exit; }

        \$imported = 0; \$skipped = 0;
        // Detect export format: legacy exports have 'key' + 'fields' at top level
        \$is_legacy = ('${acf_type}' === 'acf-field-group' && isset(\$items[0]['key']) && isset(\$items[0]['fields']));

        foreach (\$items as \$item) {
          \$result = false;

          if (\$is_legacy && function_exists('acf_import_field_group')) {
            \$result = acf_import_field_group(\$item);
          } elseif (function_exists('acf_import_internal_post_type')) {
            \$result = acf_import_internal_post_type(\$item, '${acf_type}');
          } elseif ('${acf_type}' === 'acf-field-group' && function_exists('acf_import_field_group')) {
            \$result = acf_import_field_group(\$item);
          } else {
            fwrite(STDERR, '    WARNING: No ACF import function available for ${acf_type}\n');
            exit(1);
          }

          if (\$result && !is_wp_error(\$result)) {
            \$imported++;
          } else {
            \$skipped++;
          }
        }
        echo \"    ${acf_type}: \$imported imported, \$skipped skipped\n\";
      " 2>&1 | grep -v '^Deprecated:\|^Notice:\|^Warning:' || true
      if [ "${PIPESTATUS[0]}" -ne 0 ]; then
        echo "    WARNING: Failed to import ${acf_type}"
      fi
    done
  fi
}

# ---------------------------------------------------------------------------
# Component: authors — Ensure content authors exist
# ---------------------------------------------------------------------------
run_authors() {
  if [ -d content/ ]; then
    echo "==> Ensuring content authors exist..."
    grep -rh '^Author:' content/ 2>/dev/null | sort -u | \
      sed 's/^Author: *//' | while read -r email; do
        [ -z "$email" ] && continue
        if $WP user get "$email" --field=ID >/dev/null 2>&1; then
          continue
        fi

        mapped=""
        domain=$(echo "$email" | sed 's/.*@//')
        localpart=$(echo "$email" | sed 's/@.*//')
        if [ "$localpart" = "admin" ]; then
          mapped=$($WP user list --field=user_email --format=csv 2>/dev/null | \
            grep -i "^admin+.*@${domain}$" | head -1)
        fi

        if [ -n "$mapped" ]; then
          echo "    Mapping author: $email → $mapped"
          find content/ -name '*.md' -exec \
            sed -i'' -e "s|^Author: *${email}|Author: ${mapped}|g" {} + 2>/dev/null
        else
          login=$(echo "$email" | sed 's/@.*//')
          $WP user create "$login" "$email" --role=author 2>/dev/null && \
            echo "    Created user: $email" || \
            echo "    WARNING: Could not create user: $email"
        fi
      done
  fi
}

# ---------------------------------------------------------------------------
# Component: content — Import postmark content
# ---------------------------------------------------------------------------
run_content() {
  if [ -n "$(find content/ -name '*.md' -not -name '.gitkeep' 2>/dev/null | head -1)" ]; then
    # Pre-resolve all content dependencies (users, terms) before postmark runs.
    # Without this, imposer's promise system deadlocks when postmark can't resolve
    # a dependency (@wp-user for author, @wp-category-term for categories), and the
    # error appears as "@wp-post:guid 'X' not found" — masking the real cause.
    if $WP cli has-command "entity preload" 2>/dev/null; then
      echo "==> Pre-resolving content dependencies via entity framework..."
      $WP entity preload content/ 2>&1 | grep -v '^Deprecated:\|^Notice:\|^Warning:' || true
    fi

    # Ensure all taxonomy terms referenced in content exist before postmark runs.
    # Postmark passes WP-Terms to wp_insert_post's tax_input, which won't create
    # terms for hierarchical taxonomies (it expects IDs, not names). We pre-create
    # all referenced terms so they exist when postmark assigns them.
    echo "==> Ensuring taxonomy terms exist..."
    $WP eval '
      // Parse WP-Terms from all content markdown files.
      // Handles both inline YAML flow: WP-Terms: { tax: [ term1, term2 ] }
      // and block YAML:
      //   WP-Terms:
      //     tax:
      //       - term1
      function parse_wp_terms($raw) {
        $result = [];
        // Inline flow format: WP-Terms: { tax: [ t1, t2 ], tax2: [ t3 ] }
        if (preg_match("/^WP-Terms:\s*\{(.+)\}\s*$/m", $raw, $m)) {
          // Parse key: [ val, val ] pairs
          if (preg_match_all("/(\w[\w-]*):\s*\[([^\]]*)\]/", $m[1], $pairs, PREG_SET_ORDER)) {
            foreach ($pairs as $p) {
              $tax = trim($p[1]);
              $terms = array_map(function($t) { return trim($t, " \t\"'\''"); }, explode(",", $p[2]));
              $result[$tax] = array_merge($result[$tax] ?? [], array_filter($terms));
            }
          }
          return $result;
        }
        // Block format
        if (preg_match("/^WP-Terms:\s*\n((?:  .+\n)*)/m", $raw, $m)) {
          $current_tax = null;
          foreach (explode("\n", $m[1]) as $line) {
            if (preg_match("/^  (\S[^:]+):\s*$/", $line, $t)) {
              $current_tax = trim($t[1]);
            } elseif ($current_tax && preg_match("/^    - (.+)$/", $line, $v)) {
              $result[$current_tax][] = trim($v[1], "\"'\'' ");
            }
          }
        }
        return $result;
      }

      $terms_needed = [];
      $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator("content/"));
      foreach ($iterator as $file) {
        if ($file->getExtension() !== "md") continue;
        $raw = file_get_contents($file->getPathname());
        foreach (parse_wp_terms($raw) as $tax => $names) {
          $terms_needed[$tax] = array_merge($terms_needed[$tax] ?? [], $names);
        }
      }

      $created = 0;
      foreach ($terms_needed as $taxonomy => $names) {
        if (!taxonomy_exists($taxonomy)) {
          echo "    WARNING: taxonomy \"$taxonomy\" not registered, skipping its terms\n";
          continue;
        }
        $names = array_unique($names);
        foreach ($names as $name) {
          if (term_exists($name, $taxonomy)) continue;
          $result = wp_insert_term($name, $taxonomy);
          if (is_wp_error($result)) {
            echo "    WARNING: Could not create term \"$name\" in $taxonomy: " . $result->get_error_message() . "\n";
          } else {
            $created++;
          }
        }
      }
      if ($created) echo "    Created $created taxonomy terms\n";
    ' 2>&1 | grep -v '^Deprecated:\|^Notice:\|^Warning:' || true

    echo "==> Importing postmark content..."

    post_type_for_dir() {
      case "$1" in
        pages) echo "page" ;;
        posts) echo "post" ;;
        *)     echo "$1" ;;
      esac
    }

    REGISTERED_TYPES=$($WP post-type list --format=csv --fields=name 2>/dev/null | tail -n +2)

    for dir in content/*/; do
      [ -d "$dir" ] || continue
      dir_name=$(basename "$dir")
      ptype=$(post_type_for_dir "$dir_name")

      if echo "$REGISTERED_TYPES" | grep -qx "$ptype"; then
        echo "    Importing $ptype..."
        $WP postmark tree "$dir" --force --porcelain
      else
        file_count=$(find "$dir" -name '*.md' -not -name '.gitkeep' 2>/dev/null | wc -l | tr -d ' ')
        echo "    WARNING: Skipping $dir — post type '$ptype' is not registered ($file_count files)"
      fi
    done

    for f in content/*.md; do
      [ -f "$f" ] && [ "$(basename "$f")" != ".gitkeep" ] || continue
      $WP postmark sync "$f" --force --porcelain 2>/dev/null || true
    done

    # Fix taxonomy term assignments. wp_insert_post's tax_input silently ignores
    # term names for hierarchical taxonomies (it expects IDs). Re-apply all
    # WP-Terms from content files using wp_set_object_terms which accepts names.
    echo "==> Fixing taxonomy term assignments..."
    $WP eval '
      // parse_wp_terms is defined in the pre-create step but this is a separate
      // wp eval process, so redefine it here.
      function parse_wp_terms($raw) {
        $result = [];
        if (preg_match("/^WP-Terms:\s*\{(.+)\}\s*$/m", $raw, $m)) {
          if (preg_match_all("/(\w[\w-]*):\s*\[([^\]]*)\]/", $m[1], $pairs, PREG_SET_ORDER)) {
            foreach ($pairs as $p) {
              $tax = trim($p[1]);
              $terms = array_map(function($t) { return trim($t, " \t\"'\''"); }, explode(",", $p[2]));
              $result[$tax] = array_merge($result[$tax] ?? [], array_filter($terms));
            }
          }
          return $result;
        }
        if (preg_match("/^WP-Terms:\s*\n((?:  .+\n)*)/m", $raw, $m)) {
          $current_tax = null;
          foreach (explode("\n", $m[1]) as $line) {
            if (preg_match("/^  (\S[^:]+):\s*$/", $line, $t)) {
              $current_tax = trim($t[1]);
            } elseif ($current_tax && preg_match("/^    - (.+)$/", $line, $v)) {
              $result[$current_tax][] = trim($v[1], "\"'\'' ");
            }
          }
        }
        return $result;
      }

      global $wpdb;
      $fixed = 0; $skipped_tax = []; $skipped_post = 0;

      $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator("content/"));
      foreach ($iterator as $file) {
        if ($file->getExtension() !== "md") continue;
        $raw = file_get_contents($file->getPathname());

        // Get the post GUID (ID field in front matter)
        if (!preg_match("/^ID:\s*['\''\"]*(.+?)['\''\"]*\s*$/m", $raw, $id_match)) continue;
        $guid = html_entity_decode(trim($id_match[1]), ENT_QUOTES | ENT_HTML5, "UTF-8");

        $terms_by_tax = parse_wp_terms($raw);
        if (empty($terms_by_tax)) continue;

        // Find the post by GUID — try decoded, &#038;-encoded, and &amp;-encoded
        // WordPress stores GUIDs with &#038; for & characters
        $guid_038 = str_replace("&", "&#038;", $guid);
        $guid_amp = str_replace("&", "&amp;", $guid);
        $post_id = (int) $wpdb->get_var($wpdb->prepare(
          "SELECT ID FROM $wpdb->posts WHERE guid IN (%s, %s, %s) LIMIT 1",
          $guid, $guid_038, $guid_amp
        ));
        if (!$post_id) { $skipped_post++; continue; }

        foreach ($terms_by_tax as $taxonomy => $term_names) {
          if (!taxonomy_exists($taxonomy)) {
            $skipped_tax[$taxonomy] = true;
            continue;
          }
          wp_set_object_terms($post_id, $term_names, $taxonomy);
          $fixed++;
        }
      }
      if ($fixed) echo "    Fixed $fixed taxonomy assignments\n";
      if ($skipped_post) echo "    $skipped_post posts not found by GUID\n";
      if (!empty($skipped_tax)) echo "    Skipped unregistered taxonomies: " . implode(", ", array_keys($skipped_tax)) . "\n";
    ' 2>&1 | grep -v '^Deprecated:\|^Notice:\|^Warning:' || true

  else
    echo "==> No postmark content found, skipping."
  fi
}

# ---------------------------------------------------------------------------
# Component: attachments — Import media attachment records
# ---------------------------------------------------------------------------
run_attachments() {
  if [ -f imposer/attachments.json ]; then
    echo "==> Importing media attachments..."
    $WP eval '
      // Read and strip any leading non-JSON noise (PHP notices etc.)
      $raw = file_get_contents("imposer/attachments.json");
      $start = min(
        ($p = strpos($raw, "[")) !== false ? $p : PHP_INT_MAX,
        ($p = strpos($raw, "{")) !== false ? $p : PHP_INT_MAX
      );
      $atts = ($start < PHP_INT_MAX) ? json_decode(substr($raw, $start), true) : null;
      if (empty($atts)) { echo "    No attachments to import\n"; exit; }
      global $wpdb;
      $upload_dir = wp_upload_dir();
      $upload_base = $upload_dir["basedir"];
      $upload_url  = $upload_dir["baseurl"];
      $created = 0; $skipped = 0; $missing = 0; $orphaned = 0;
      foreach ($atts as $att) {
        $relative_file = $att["_wp_attached_file"];
        $target_guid = $upload_url . "/" . $relative_file;

        $existing = $wpdb->get_var($wpdb->prepare(
          "SELECT ID FROM $wpdb->posts WHERE (guid = %s OR guid = %s) AND post_type = %s",
          $att["guid"], $target_guid, "attachment"
        ));
        if ($existing) { $skipped++; continue; }

        $abs_file = $upload_base . "/" . $relative_file;
        if (!file_exists($abs_file)) { $missing++; continue; }

        // Resolve post_parent via GUID — source post IDs are meaningless here
        $parent_id = 0;
        if (!empty($att["post_parent_guid"])) {
          $parent_guid = $att["post_parent_guid"];
          $parent_id = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT ID FROM $wpdb->posts WHERE guid = %s AND post_type != %s LIMIT 1",
            $parent_guid, "attachment"
          ));
          if (!$parent_id) {
            $path = wp_parse_url($parent_guid, PHP_URL_PATH);
            if ($path) {
              $slug = basename(untrailingslashit($path));
              $slug = preg_replace("/\\.html?$/", "", $slug);
              if ($slug) {
                $parent_id = (int) $wpdb->get_var($wpdb->prepare(
                  "SELECT ID FROM $wpdb->posts WHERE post_name = %s AND post_type != %s AND post_status != %s LIMIT 1",
                  $slug, "attachment", "trash"
                ));
              }
            }
          }
          if (!$parent_id) { $orphaned++; }
        }

        $id = wp_insert_attachment([
          "guid"            => $target_guid,
          "post_title"      => $att["post_title"],
          "post_name"       => $att["post_name"],
          "post_content"    => $att["post_content"],
          "post_excerpt"    => $att["post_excerpt"],
          "post_status"     => $att["post_status"] ?: "inherit",
          "post_mime_type"  => $att["post_mime_type"],
          "post_date"       => $att["post_date"],
          "post_date_gmt"   => $att["post_date_gmt"],
          "post_modified"   => $att["post_modified"],
          "post_modified_gmt" => $att["post_modified_gmt"],
        ], $abs_file, $parent_id);

        if (is_wp_error($id)) {
          echo "    WARNING: " . $id->get_error_message() . "\n";
          continue;
        }

        if (!empty($att["_wp_attachment_metadata"])) {
          wp_update_attachment_metadata($id, $att["_wp_attachment_metadata"]);
        }
        if (!empty($att["_wp_attachment_image_alt"])) {
          update_post_meta($id, "_wp_attachment_image_alt", $att["_wp_attachment_image_alt"]);
        }
        $created++;
      }
      echo "    Attachments: $created created, $skipped already exist";
      if ($missing) echo ", $missing files not found on disk";
      if ($orphaned) echo ", $orphaned parent posts not found (unattached)";
      echo "\n";
    '
  fi

  # Reassign featured images (post GUID → attachment GUID mapping)
  # Postmark strips _thumbnail_id, so we restore it from the exported map.
  if [ -f imposer/featured_images.json ]; then
    echo "==> Importing featured image assignments..."
    $WP eval '
      $raw = file_get_contents("imposer/featured_images.json");
      $start = min(
        ($p = strpos($raw, "[")) !== false ? $p : PHP_INT_MAX,
        ($p = strpos($raw, "{")) !== false ? $p : PHP_INT_MAX
      );
      $map = ($start < PHP_INT_MAX) ? json_decode(substr($raw, $start), true) : null;
      if (empty($map)) { echo "    No featured image assignments to import\n"; exit; }

      global $wpdb;
      $set = 0; $missing_post = 0; $missing_att = 0; $already = 0;
      $debug_post_samples = []; $debug_att_samples = [];

      // Helper: find a post by GUID, with multiple fallback strategies.
      // WordPress GUIDs may contain HTML entities (&#038; for &), may differ
      // by domain (source vs target), or by protocol (http vs https).
      $find_post = function($guid, $exclude_type = "") use ($wpdb) {
        // Decode HTML entities — WordPress stores GUIDs with &#038; for &
        $guid = html_entity_decode($guid, ENT_QUOTES | ENT_HTML5, "UTF-8");

        $where_type = $exclude_type
          ? $wpdb->prepare("AND post_type != %s", $exclude_type)
          : "";

        // Try exact GUID match (with and without HTML entity encoding)
        $guid_encoded = htmlspecialchars($guid, ENT_QUOTES, "UTF-8");
        $id = (int) $wpdb->get_var($wpdb->prepare(
          "SELECT ID FROM $wpdb->posts WHERE (guid = %s OR guid = %s) $where_type LIMIT 1",
          $guid, $guid_encoded
        ));
        if ($id) return $id;

        // Try with swapped protocol (http↔https)
        $alt = preg_replace("#^https?://#", "", $guid);
        $id = (int) $wpdb->get_var($wpdb->prepare(
          "SELECT ID FROM $wpdb->posts WHERE (guid = %s OR guid = %s) $where_type LIMIT 1",
          "http://$alt", "https://$alt"
        ));
        if ($id) return $id;

        // Strip domain entirely — match on path+query portion only.
        // This handles source→target domain mismatches without needing domain rewrite.
        $path = wp_parse_url($guid, PHP_URL_PATH) ?: "/";
        $query = wp_parse_url($guid, PHP_URL_QUERY) ?: "";
        $path_query = $path . ($query ? "?$query" : "");

        if (strlen($path_query) > 1) {
          // LIKE match on the path+query suffix of any GUID
          $id = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT ID FROM $wpdb->posts WHERE guid LIKE %s $where_type LIMIT 1",
            "%" . $wpdb->esc_like($path_query)
          ));
          if ($id) return $id;

          // Also try with HTML-encoded ampersands in query string
          $path_query_enc = str_replace("&", "&#038;", $path_query);
          if ($path_query_enc !== $path_query) {
            $id = (int) $wpdb->get_var($wpdb->prepare(
              "SELECT ID FROM $wpdb->posts WHERE guid LIKE %s $where_type LIMIT 1",
              "%" . $wpdb->esc_like($path_query_enc)
            ));
            if ($id) return $id;
          }
        }

        // Try matching by slug from path
        if ($path && $path !== "/") {
          $slug = basename(untrailingslashit($path));
          $slug = preg_replace("/\\.html?$/", "", $slug);
          if ($slug && $slug !== "/" && !is_numeric($slug)) {
            $id = (int) $wpdb->get_var($wpdb->prepare(
              "SELECT ID FROM $wpdb->posts WHERE post_name = %s $where_type AND post_status != %s LIMIT 1",
              $slug, "trash"
            ));
            if ($id) return $id;
          }
        }
        return 0;
      };

      foreach ($map as $entry) {
        $post_guid = $entry["post_guid"];
        $att_guid  = $entry["attachment_guid"];

        $post_id = $find_post($post_guid, "attachment");
        if (!$post_id) {
          $missing_post++;
          if (count($debug_post_samples) < 5) $debug_post_samples[] = $post_guid;
          continue;
        }

        $att_id = $find_post($att_guid);
        // Also try attachment-specific GUID format (upload URL based)
        if (!$att_id) {
          // Extract the relative file path from the GUID and check against upload URL
          $upload_url = wp_upload_dir()["baseurl"];
          $path = wp_parse_url($att_guid, PHP_URL_PATH);
          if ($path && preg_match("#(?:uploads?|app/uploads)/(.+)$#", $path, $m)) {
            $target_guid = $upload_url . "/" . $m[1];
            $att_id = (int) $wpdb->get_var($wpdb->prepare(
              "SELECT ID FROM $wpdb->posts WHERE guid = %s AND post_type = %s LIMIT 1",
              $target_guid, "attachment"
            ));
          }
        }
        if (!$att_id) {
          $missing_att++;
          if (count($debug_att_samples) < 5) $debug_att_samples[] = $att_guid;
          continue;
        }

        // Check if already set correctly
        $current = (int) get_post_meta($post_id, "_thumbnail_id", true);
        if ($current === $att_id) { $already++; continue; }

        update_post_meta($post_id, "_thumbnail_id", $att_id);
        $set++;
      }
      echo "    Featured images: $set set, $already already correct";
      if ($missing_post) echo ", $missing_post posts not found";
      if ($missing_att) echo ", $missing_att attachments not found";
      echo "\n";
      if (!empty($debug_post_samples)) {
        echo "    DEBUG: Sample unmatched post GUIDs from featured_images.json:\n";
        foreach ($debug_post_samples as $g) echo "      JSON: $g\n";
        // Show post types with counts to see if video CPT exists
        $type_counts = $wpdb->get_results("SELECT post_type, COUNT(*) as c FROM $wpdb->posts GROUP BY post_type ORDER BY c DESC LIMIT 10", ARRAY_A);
        echo "    DEBUG: Post types in database:\n";
        foreach ($type_counts as $r) echo "      {$r['post_type']}: {$r['c']}\n";
        // Show a few GUIDs from the most common content types
        $db_samples = $wpdb->get_col("SELECT guid FROM $wpdb->posts WHERE post_type IN ('page','post','video') AND post_status != 'trash' LIMIT 5");
        echo "    DEBUG: Sample post GUIDs in database:\n";
        foreach ($db_samples as $g) echo "      DB:   $g\n";
      }
      if (!empty($debug_att_samples)) {
        echo "    DEBUG: Sample unmatched attachment GUIDs from featured_images.json:\n";
        foreach ($debug_att_samples as $g) echo "      JSON: $g\n";
        $db_samples = $wpdb->get_col("SELECT guid FROM $wpdb->posts WHERE post_type = 'attachment' LIMIT 5");
        echo "    DEBUG: Sample attachment GUIDs in database:\n";
        foreach ($db_samples as $g) echo "      DB:   $g\n";
      }
    '
  fi
}

# ---------------------------------------------------------------------------
# Component: acf-remap — Remap ACF image/file/gallery field values to new
#   attachment IDs. Source attachment IDs are meaningless after import; this
#   step uses the acf_attachments.json mapping (source_id → source_guid) to
#   resolve new IDs via the GUID that run_attachments assigned.
# ---------------------------------------------------------------------------
run_acf-remap() {
  if [ ! -f imposer/acf_attachments.json ]; then
    return
  fi

  echo "==> Remapping ACF attachment field IDs..."
  $WP eval '
    global $wpdb;

    $raw = file_get_contents("imposer/acf_attachments.json");
    $pos = strpos($raw, "{");
    if ($pos === false) { echo "    No mapping data\n"; exit; }
    $id_map = json_decode(substr($raw, $pos), true);
    if (empty($id_map)) { echo "    Empty mapping\n"; exit; }

    // Build old_id → new_id map by looking up each GUID in current database.
    $upload_url = wp_upload_dir()["baseurl"];
    $remap = [];

    $unresolved = 0;
    foreach ($id_map as $old_id => $source_guid) {
      $source_guid = html_entity_decode($source_guid, ENT_QUOTES | ENT_HTML5, "UTF-8");
      $path = wp_parse_url($source_guid, PHP_URL_PATH);

      // Try exact GUID match
      $new_id = (int) $wpdb->get_var($wpdb->prepare(
        "SELECT ID FROM $wpdb->posts WHERE guid = %s AND post_type = %s LIMIT 1",
        $source_guid, "attachment"
      ));

      // Try with HTML-encoded ampersands
      if (!$new_id) {
        $guid_enc = str_replace("&", "&#038;", $source_guid);
        $new_id = (int) $wpdb->get_var($wpdb->prepare(
          "SELECT ID FROM $wpdb->posts WHERE guid = %s AND post_type = %s LIMIT 1",
          $guid_enc, "attachment"
        ));
      }

      // Try target-domain GUID (upload_url-based)
      if (!$new_id && $path) {
        if (preg_match("#(?:uploads?|app/uploads)/(.+)$#", $path, $m)) {
          $target_guid = $upload_url . "/" . $m[1];
          $new_id = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT ID FROM $wpdb->posts WHERE guid = %s AND post_type = %s LIMIT 1",
            $target_guid, "attachment"
          ));
        }
      }

      // Try stripping domain — match on path suffix
      if (!$new_id && $path) {
        $new_id = (int) $wpdb->get_var($wpdb->prepare(
          "SELECT ID FROM $wpdb->posts WHERE guid LIKE %s AND post_type = %s LIMIT 1",
          "%" . $wpdb->esc_like($path), "attachment"
        ));
      }

      // Try matching by filename only
      if (!$new_id && $path) {
        $filename = basename($path);
        $new_id = (int) $wpdb->get_var($wpdb->prepare(
          "SELECT ID FROM $wpdb->posts WHERE guid LIKE %s AND post_type = %s LIMIT 1",
          "%" . $wpdb->esc_like($filename), "attachment"
        ));
      }

      if ($new_id) {
        $remap[(int) $old_id] = $new_id;
      } else {
        $unresolved++;
        if ($unresolved <= 5) echo "    WARNING: Could not resolve source attachment $old_id: $source_guid\n";
      }
    }

    echo "    Resolved " . count($remap) . " of " . count($id_map) . " source attachments\n";

    if (empty($remap)) {
      echo "    No attachment IDs could be resolved\n";
      exit;
    }

    // Remap postmeta values: update any meta row where the value matches
    // a source attachment ID and the key does not start with underscore.
    // Use a targeted approach: find all candidate rows first, then update.
    $old_ids = array_keys($remap);
    $old_id_strings = array_map("strval", $old_ids);
    $placeholders_vals = implode(",", array_fill(0, count($old_id_strings), "%s"));

    // Find all postmeta rows that have a source attachment ID as their value
    // and whose key does not start with underscore (ACF internal keys start with _)
    $candidates = $wpdb->get_results($wpdb->prepare(
      "SELECT meta_id, post_id, meta_key, meta_value FROM $wpdb->postmeta
       WHERE meta_value IN ($placeholders_vals)
         AND SUBSTRING(meta_key, 1, 1) != %s",
      ...array_merge($old_id_strings, ["_"])
    ), ARRAY_A);

    $remapped = 0;
    foreach ($candidates as $row) {
      $old_val = (int) $row["meta_value"];
      if (!isset($remap[$old_val])) continue;
      $new_val = $remap[$old_val];
      $wpdb->update(
        $wpdb->postmeta,
        ["meta_value" => (string) $new_val],
        ["meta_id" => (int) $row["meta_id"]]
      );
      $remapped++;
    }

    // Remap serialized arrays (gallery fields)
    $gallery_rows = $wpdb->get_results(
      "SELECT meta_id, meta_value FROM $wpdb->postmeta
       WHERE SUBSTRING(meta_key, 1, 1) != '_' AND meta_value LIKE 'a:%'",
      ARRAY_A
    );
    $gallery_fixed = 0;
    foreach ($gallery_rows as $row) {
      $val = maybe_unserialize($row["meta_value"]);
      if (!is_array($val)) continue;
      $changed = false;
      foreach ($val as &$item) {
        $item_int = (int) $item;
        if ($item_int > 0 && isset($remap[$item_int])) {
          $item = $remap[$item_int];
          $changed = true;
        }
      }
      unset($item);
      if ($changed) {
        $wpdb->update(
          $wpdb->postmeta,
          ["meta_value" => maybe_serialize($val)],
          ["meta_id" => $row["meta_id"]]
        );
        $gallery_fixed++;
      }
    }

    echo "    Remapped $remapped single-value ACF fields";
    if ($gallery_fixed) echo ", $gallery_fixed gallery fields";
    echo " (from " . count($remap) . " resolved attachments)\n";

    $unresolved = count($id_map) - count($remap);
    if ($unresolved > 0) {
      echo "    WARNING: $unresolved source attachments could not be resolved\n";
    }
  ' 2>&1 | grep -v '^Deprecated:\|^Notice:\|^Warning:' || true
}

# ---------------------------------------------------------------------------
# Component: menus — Apply menu configuration + YooTheme menu ID remap
# ---------------------------------------------------------------------------
run_menus() {
  if [ -x "$IMPOSER" ] && [ -f imposer/menus.state.md ]; then
    if grep -q '^\s*items:' imposer/menus.state.md 2>/dev/null; then
      echo "==> Applying menu configuration (with items)..."

      # Stamp existing menu items with imposer-format GUIDs so imposer recognizes
      # them by content rather than creating duplicates. This matches each item's
      # type/object/URL to compute the GUID imposer would generate, then updates
      # the post's guid field. Only menus named in the state file are touched.
      $WP eval '
        // Parse menu names from the YAML block in menus.state.md
        $raw = file_get_contents("imposer/menus.state.md");
        if (!preg_match("/```yaml\s*\n(.*?)```/s", $raw, $m)) exit;
        if (!preg_match("/^menus:\s*\n((?:  .+\n)*)/m", $m[1], $m2)) exit;
        preg_match_all("/^  (?:\"([^\"]+)\"|([^\"\s][^:]*?)):\s/m", $m2[1], $names);
        $menu_names = array_filter(array_merge($names[1], $names[2]));
        if (empty($menu_names)) exit;

        global $wpdb;
        $stamped = 0;

        foreach ($menu_names as $menu_name) {
          $menu = wp_get_nav_menu_object($menu_name);
          if (!$menu) continue;
          $term_id = $menu->term_id;
          $items = wp_get_nav_menu_items($term_id, ["update_post_term_cache" => false]);
          if (empty($items)) continue;

          foreach ($items as $item) {
            // Skip items that already have an imposer GUID
            if (strpos($item->guid, "urn:x-wp-menu-item:") === 0) continue;

            // Compute the imposer GUID based on item content
            $name = null;
            switch ($item->type) {
              case "post_type":
                $name = "page:" . ($item->object ?: "post") . ":" . $item->object_id;
                break;
              case "custom":
                $name = "custom:" . $item->url;
                break;
              case "post_type_archive":
                $name = "archive:" . $item->object;
                break;
              case "taxonomy":
                $term = get_term((int)$item->object_id, $item->object);
                if ($term && !is_wp_error($term)) {
                  $name = "term:" . $item->object . ":" . $term->name;
                }
                break;
            }

            if ($name === null) continue;
            $imposer_guid = "urn:x-wp-menu-item:" . urlencode($name) . "@" . $term_id;

            $wpdb->update($wpdb->posts, ["guid" => $imposer_guid], ["ID" => $item->db_id]);
            clean_post_cache($item->db_id);
            $stamped++;
          }
        }
        if ($stamped) echo "    Stamped $stamped existing menu items with imposer GUIDs\n";
      ' 2>&1 | grep -v '^Deprecated:\|^Notice:\|^Warning:' || true

      "$IMPOSER" apply menus
    else
      echo "==> WARNING: menus.state.md has no items — skipping menu import."
      echo "    To import menus, replace the template with the site-specific exported menus.state.md."
    fi
  fi

  # Remap YooTheme menu item IDs (old IDs → new IDs by GUID matching)
  if [ -f yootheme/menu-item-map.json ]; then
    echo "==> Remapping YooTheme menu item IDs..."
    $WP eval '
      $map_file = "yootheme/menu-item-map.json";
      $old_map = json_decode(file_get_contents($map_file), true);
      if (empty($old_map)) { echo "No menu items to remap.\n"; exit; }

      global $wpdb;
      $guid_to_new = [];
      $menu_items = $wpdb->get_results(
          "SELECT ID, guid FROM $wpdb->posts WHERE post_type = \"nav_menu_item\"",
          ARRAY_A
      );
      foreach ($menu_items as $row) {
          $guid_to_new[$row["guid"]] = (int)$row["ID"];
      }

      $stylesheet = get_option("stylesheet");
      $mods = get_option("theme_mods_$stylesheet");
      if (empty($mods["config"])) { echo "No theme config to remap.\n"; exit; }

      $cfg = json_decode($mods["config"], true);
      $items = $cfg["menu"]["items"] ?? [];
      if (empty($items)) { echo "No styled menu items.\n"; exit; }

      $new_items = [];
      $remapped = 0;
      foreach ($items as $old_id => $value) {
          $guid = $old_map[$old_id] ?? null;
          $new_id = $guid ? ($guid_to_new[$guid] ?? null) : null;
          if ($new_id) {
              $new_items[(string)$new_id] = $value;
              $remapped++;
          }
      }

      if ($remapped) {
          $cfg["menu"]["items"] = $new_items;
          $mods["config"] = json_encode($cfg, JSON_UNESCAPED_SLASHES);
          update_option("theme_mods_$stylesheet", $mods);
          echo "    Remapped $remapped menu item IDs.\n";
      } else {
          echo "    No matching GUIDs found for remapping.\n";
      }
    '
  fi
}

# ---------------------------------------------------------------------------
# Component: frontpage — Set front page and posts page by slug
# ---------------------------------------------------------------------------
run_frontpage() {
  if [ -f imposer/base.state.md ]; then
    front_slug=$(sed -n 's/^Front page slug: `\([^`]*\)`.*/\1/p' imposer/base.state.md 2>/dev/null || true)
    posts_slug=$(sed -n 's/^Posts page slug: `\([^`]*\)`.*/\1/p' imposer/base.state.md 2>/dev/null || true)

    if [ -n "$front_slug" ]; then
      front_id=$($WP post list --post_type=page --name="$front_slug" --field=ID 2>/dev/null || true)
      if [ -n "$front_id" ]; then
        $WP option update show_on_front page
        $WP option update page_on_front "$front_id"
        echo "==> Front page set to: $front_slug (ID: $front_id)"
      else
        echo "    WARNING: Front page slug '$front_slug' not found"
      fi
    fi

    if [ -n "$posts_slug" ]; then
      posts_id=$($WP post list --post_type=page --name="$posts_slug" --field=ID 2>/dev/null || true)
      if [ -n "$posts_id" ]; then
        $WP option update page_for_posts "$posts_id"
        echo "==> Posts page set to: $posts_slug (ID: $posts_id)"
      fi
    fi
  fi
}

# ---------------------------------------------------------------------------
# Component: yootheme — Import YooTheme configuration + deploy assets
# ---------------------------------------------------------------------------
yootheme_import_json() {
  local file="$1" option="$2"
  local abs_file="$SCRIPT_DIR/$file"
  local tmpphp
  tmpphp=$(mktemp)
  cat > "$tmpphp" <<'PHPEOF'
<?php
$file   = getenv('YT_IMPORT_FILE');
$option = getenv('YT_IMPORT_OPTION');
$json   = file_get_contents($file);
// Strip any leading non-JSON content (PHP notices baked into file)
$pos = strpos($json, '{');
$pos2 = strpos($json, '[');
if ($pos === false) $pos = $pos2;
elseif ($pos2 !== false && $pos2 < $pos) $pos = $pos2;
if ($pos !== false && $pos > 0) $json = substr($json, $pos);
$data = json_decode($json, true);
if ($data === null && json_last_error() !== JSON_ERROR_NONE) {
  fwrite(STDERR, "ERROR: Invalid JSON in $file: " . json_last_error_msg() . "\n");
  @unlink(getenv('YT_TMPPHP'));
  exit(1);
}
update_option($option, $data, 'no');
echo "    $option updated from " . basename($file) . "\n";
@unlink(getenv('YT_TMPPHP'));
PHPEOF
  YT_IMPORT_FILE="$abs_file" YT_IMPORT_OPTION="$option" YT_TMPPHP="$tmpphp" \
    $WP eval-file "$tmpphp" 2>&1 | grep -v '^Deprecated:\|^Notice:\|^Warning: .*was called' || true
  local rc=${PIPESTATUS[0]}
  rm -f "$tmpphp"
  if [ "$rc" -ne 0 ]; then
    echo "    ERROR: Failed to import $file (exit $rc)"
    return 1
  fi
}

# ---------------------------------------------------------------------------
# Component: permalink-manager-uris — Apply Permalink Manager URI overrides
# (Sprint 8 — see [[2026-04-11-generic-plugin-options-export]] for the
# framework rationale; replaces the anti-pattern that was reverted in cf83626.)
# ---------------------------------------------------------------------------
run_permalink-manager-uris() {
  local pm_file="imposer/entities/permalink-manager/uris.json"
  if [ ! -f "$pm_file" ]; then
    return 0
  fi
  if ! $WP cli has-command "entity import" 2>/dev/null; then
    echo "==> permalink-manager-uris: entity framework unavailable, skip"
    echo "    (install greenleafadvancement/entity-framework to enable Sprint 8)"
    return 0
  fi
  echo "==> Applying Permalink Manager URI overrides via entity framework..."
  $WP entity import permalink-manager-uris "$pm_file" 2>&1 | \
    grep -v '^Deprecated:\|^Notice:\|^Warning: .*was called' || true
}

run_yootheme() {
  # Rewrite wp-content/uploads → app/uploads in YooTheme JSON before importing.
  # The export script should do this, but as a safety net (older exports, manual
  # edits, or YooTheme content with absolute URLs) we rewrite here too.
  for f in yootheme/*.json; do
    [ -f "$f" ] || continue
    if grep -q 'wp-content/uploads' "$f" 2>/dev/null; then
      sed -i'' -e 's|wp-content/uploads|app/uploads|g' "$f"
      echo "    Rewrote wp-content/uploads → app/uploads in $(basename "$f")"
    fi
  done

  if [ -f yootheme/customizer.json ]; then
    echo "==> Importing YooTheme customizer settings..."
    yootheme_import_json yootheme/customizer.json theme_mods_yootheme
  fi

  if [ -f yootheme/config.json ]; then
    echo "==> Importing YooTheme builder configuration..."
    yootheme_import_json yootheme/config.json yootheme_config
  fi

  if [ -f yootheme/yootheme.json ]; then
    echo "==> Importing YooTheme layouts/library/templates..."
    # YooTheme's Storage class expects the 'yootheme' option to contain a JSON
    # string, not a PHP array. If we use update_option with a decoded array,
    # YooTheme's addJson(get_option('yootheme')) would call json_decode on an
    # array, returning null and losing all templates/layouts.
    # Store as a JSON string directly.
    local abs_file="$SCRIPT_DIR/yootheme/yootheme.json"
    $WP eval "
      \$json = file_get_contents('$abs_file');
      // Handle double-encoded JSON FIRST (before any leading-noise strip):
      // site-export sometimes writes the file as a JSON-encoded string of
      // JSON (entire file is a quoted string like \"{\\\"library\\\":...\"}\").
      // Detect by probing whether the first non-whitespace char is a quote,
      // and unwrap one level.
      \$probe = ltrim(\$json);
      if (strlen(\$probe) >= 1 && \$probe[0] === '\"') {
        \$unwrapped = json_decode(\$json, true);
        if (is_string(\$unwrapped)) {
          \$json = \$unwrapped;
        }
      }
      // Strip leading non-JSON noise (BOM, leading whitespace, debug prints)
      \$pos = strpos(\$json, '{');
      \$pos2 = strpos(\$json, '[');
      if (\$pos === false) \$pos = \$pos2;
      elseif (\$pos2 !== false && \$pos2 < \$pos) \$pos = \$pos2;
      if (\$pos !== false && \$pos > 0) \$json = substr(\$json, \$pos);
      // Validate
      \$data = json_decode(\$json, true);
      if (\$data === null && json_last_error() !== JSON_ERROR_NONE) {
        fwrite(STDERR, 'ERROR: Invalid JSON in yootheme.json: ' . json_last_error_msg() . \"\n\");
        exit(1);
      }
      // Re-encode to a clean JSON string (what YooTheme Storage expects)
      \$clean = json_encode(\$data, JSON_UNESCAPED_SLASHES);
      update_option('yootheme', \$clean, false);
      \$tpl_count = isset(\$data['templates']) ? count(\$data['templates']) : 0;
      echo \"    yootheme updated (\$tpl_count templates)\n\";
    " 2>&1 | grep -v '^Deprecated:\|^Notice:\|^Warning:' || true
  fi

  # Restore per-page YooTheme builder content.
  # Postmark exports post_content as rendered HTML, stripping the builder JSON.
  # This step restores the original builder JSON from the separate export.
  if [ -f yootheme/builder_content.json ]; then
    echo "==> Restoring YooTheme per-page builder content..."
    $WP eval '
      global $wpdb;
      $raw = file_get_contents("yootheme/builder_content.json");
      $entries = json_decode($raw, true);
      if (empty($entries)) { echo "    No builder content to restore\n"; exit; }

      $restored = 0; $skipped = 0; $not_found = 0;

      foreach ($entries as $entry) {
        $guid = $entry["guid"];
        $builder = $entry["builder_content"];

        // Find the post by GUID (try exact, protocol swap, and path match)
        $post_id = 0;
        $guid_decoded = html_entity_decode($guid, ENT_QUOTES | ENT_HTML5, "UTF-8");
        $guid_encoded = str_replace("&", "&#038;", $guid_decoded);

        $post_id = (int) $wpdb->get_var($wpdb->prepare(
          "SELECT ID FROM $wpdb->posts WHERE guid IN (%s, %s) AND post_type != %s LIMIT 1",
          $guid_decoded, $guid_encoded, "attachment"
        ));

        if (!$post_id) {
          // Try path+query match
          $path = wp_parse_url($guid_decoded, PHP_URL_PATH) ?: "/";
          $query = wp_parse_url($guid_decoded, PHP_URL_QUERY) ?: "";
          $suffix = $path . ($query ? "?$query" : "");
          if (strlen($suffix) > 1) {
            $post_id = (int) $wpdb->get_var($wpdb->prepare(
              "SELECT ID FROM $wpdb->posts WHERE guid LIKE %s AND post_type != %s LIMIT 1",
              "%" . $wpdb->esc_like($suffix), "attachment"
            ));
          }
        }

        if (!$post_id && !empty($entry["post_name"])) {
          $post_id = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT ID FROM $wpdb->posts WHERE post_name = %s AND post_type = %s LIMIT 1",
            $entry["post_name"], $entry["post_type"]
          ));
        }

        if (!$post_id) { $not_found++; continue; }

        // Check if current content is already builder JSON
        $current = get_post_field("post_content", $post_id, "raw");
        $current_decoded = json_decode($current, true);
        if ($current_decoded !== null && isset($current_decoded["children"])) {
          $skipped++;
          continue;
        }

        // Restore builder JSON
        $json = json_encode($builder, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $wpdb->update($wpdb->posts, ["post_content" => $json], ["ID" => $post_id]);
        clean_post_cache($post_id);
        $restored++;
      }

      echo "    Builder content: $restored restored, $skipped already correct";
      if ($not_found) echo ", $not_found posts not found";
      echo "\n";
    ' 2>&1 | grep -v '^Deprecated:\|^Notice:\|^Warning:' || true
  fi

  # Deploy generated assets (compiled CSS, fonts)
  if [ -d yootheme/generated ]; then
    echo "==> Deploying YooTheme generated assets..."
    ASSET_TARGET="$SCRIPT_DIR/web/app/uploads/yootheme"
    mkdir -p "$ASSET_TARGET"
    for subdir in css fonts; do
      if [ -d "yootheme/generated/$subdir" ] && [ -n "$(ls -A yootheme/generated/$subdir 2>/dev/null)" ]; then
        mkdir -p "$ASSET_TARGET/$subdir"
        cp -r "yootheme/generated/$subdir/"* "$ASSET_TARGET/$subdir/" 2>/dev/null || true
        echo "    Deployed $subdir/"
      fi
    done
  fi
}

# ---------------------------------------------------------------------------
# Component: menu-locations — Re-assign menu locations after yootheme overwrite
#
# YooTheme stores nav_menu_locations inside theme_mods_yootheme. The yootheme
# component overwrites the entire theme_mods option with the SOURCE's values,
# which contain stale source-side menu term_ids. That blows away the
# locations we just set via `imposer apply menus`. Re-run the location
# assignment based on the canonical names + current term_ids on the target.
# ---------------------------------------------------------------------------
run_menu-locations() {
  if [ -f imposer/menus.state.md ] && [ -x "$IMPOSER" ]; then
    echo "==> Re-asserting menu locations after yootheme import..."
    "$IMPOSER" apply menus 2>&1 | grep -vE "^(Deprecated|Notice|Warning):" || true
  fi
}

# ---------------------------------------------------------------------------
# Component: widgets — Import widget/sidebar configuration
# ---------------------------------------------------------------------------
run_widgets() {
  if [ -f imposer/sidebars_widgets.json ]; then
    echo "==> Importing widget configuration..."
    $WP option update sidebars_widgets --format=json < imposer/sidebars_widgets.json
  fi

  if [ -f imposer/widget_instances.json ]; then
    echo "==> Importing widget instances..."
    $WP eval '
      $widgets = json_decode(file_get_contents("imposer/widget_instances.json"), true);
      $count = 0;
      foreach ($widgets as $name => $value) {
          update_option($name, $value);
          $count++;
      }
      echo "    $count widget options imported.\n";
    '
  fi
}

# ---------------------------------------------------------------------------
# Component: gap-sidebar — Import GAP Sidebar configuration
# ---------------------------------------------------------------------------
run_gap-sidebar() {
  if [ -f gap-sidebar/config.yaml ]; then
    echo "==> Importing GAP Sidebar configuration..."
    $WP eval '
      if (!defined("GAP_SIDEBAR_VERSION")) require_once WP_PLUGIN_DIR . "/gap-sidebar/gap-sidebar.php";
      $config = new \GapSidebar\Config();
      $handler = new \GapSidebar\YamlHandler($config);
      $yaml = file_get_contents("gap-sidebar/config.yaml");
      $result = $handler->import($yaml);
      echo $result === true ? "GAP Sidebar config imported." : "Error: $result";
      echo "\n";
    '
  fi
}

# ---------------------------------------------------------------------------
# Component: paths — Rewrite wp-content/uploads paths in database
# ---------------------------------------------------------------------------
run_paths() {
  echo "==> Rewriting wp-content/uploads → app/uploads in database..."
  $WP search-replace 'wp-content/uploads' 'app/uploads' --all-tables --quiet 2>/dev/null && \
    echo "    Database paths rewritten" || \
    echo "    WARNING: search-replace failed (may need to run manually)"
}

# ---------------------------------------------------------------------------
# Component: flush — Flush caches and rewrite rules
# ---------------------------------------------------------------------------
run_flush() {
  echo "==> Flushing caches..."
  $WP cache flush
  $WP rewrite flush
}

# ---------------------------------------------------------------------------
# Run requested components in order
# ---------------------------------------------------------------------------
for component in "${ALL_COMPONENTS[@]}"; do
  if should_run "$component"; then
    "run_${component}"
  fi
done

echo "==> Done."
