# BinaryPH AI SEO Plugin — Context Summary

## What it is
WordPress SEO plugin at `c:\Users\aminm\Downloads\binaryph-ai-seo\`.
Rewrote from v1.0.5 (basic AI focus-keyword tool) to **v2.0.0** (complete standalone SEO plugin) in one session, then evolved to **v2.1.0** in the next session.
Built for non-technical users with a 1-Click Optimize and Quick Setup wizard.

## Key constants (in main plugin file)
- `BINARYPH_AI_SEO_VERSION = '2.5.1'` (current release)
- `BPS_PREFIX = 'bps_'` (option/meta prefix; old prefix `binaryph_ai_seo_` auto-migrated)
- `BPS_TEXT_DOMAIN = 'binaryph-ai-seo'` (constant kept for backward compat; all i18n calls use the literal string)
- `BPS_Installer::DB_VERSION = '2.2.7'` (no schema changes since)
- `BPS_Optimizer::FEATURES = ['focus_kw','meta_desc','seo_title','image_alt','orphans']` (5 keys since v2.3.0)

## Architecture
- **Autoloader** (`includes/class-bps-autoload.php`) maps `BPS_Foo_Bar` → `class-bps-foo-bar.php` across `includes/`, `includes/adapters|audit|sitemap|schema|redirects/`, `admin/pages|metabox/`
- **Boot** in `BPS_Plugin::boot()` on `plugins_loaded`
- **SEO Adapter pattern**: `BPS_SEO_Adapter` interface, implementations: `Native`, `Yoast`, `RankMath`, `AIOSEO`. Factory at `BPS_Adapter_Factory`.
- **Coexistence modes** (option `bps_coexistence_mode`): `native` (own everything), `defer` (write to active third-party), `supplement`, `override`. Default is `native` for fresh installs, `defer` if Yoast/RM/AIOSEO detected on activation.
- **Custom capability**: `bps_manage_seo` (mapped to administrator)
- **Encryption**: API keys stored via `BPS_Crypto` using `sodium_crypto_secretbox` keyed off `wp_salt('auth')`. Falls back to plaintext + admin notice if libsodium missing. Auto-migrates plaintext on upgrade.

## Custom DB tables (created via dbDelta in `BPS_Installer`)
- `wp_bps_audit` — change history (post_id, field, old, new, source, user_id, ts)
- `wp_bps_redirects` — 301/302/307/410/451 redirects (source has UNIQUE index; regex sources wrapped in `~...~`)
- `wp_bps_404s` — 404 logger (url has UNIQUE index)
- `wp_bps_ai_calls` — provider/model/tokens/cost/success per AI request

## AI providers (in `includes/ai-integration.php`)
- **Pollinations** (default): `https://gen.pollinations.ai/v1/chat/completions`, model `openai-fast`, REQUIRES key from enter.pollinations.ai
- **Gemini**: `https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}` — uses query string, NOT Bearer (this was a critical fix; v1.x was silently failing)
- **OpenRouter**: standard OpenAI-compat
- **Grok**: `api.x.ai/v1/chat/completions`
- **Ollama**: local, no key
- **OpenWebUI**: self-hosted

Fallback chain (`bps_fallback_chain` option): default `pollinations,openrouter,gemini`. Helper `bps_has_any_provider_configured()` checks if any provider is usable.

**AI runs on the user's own account** — prominent messaging in setup wizard, AI Providers settings tab, and per-post metabox AI panel. Plugin never proxies, stores, or resells AI usage. API keys are encrypted (libsodium) and never exported via Settings IO.

## Critical fixes that must not be re-introduced
1. **Gemini** uses `?key=` query string, NOT `Authorization: Bearer`
2. **Pollinations** endpoint is `gen.pollinations.ai` (NOT `text.pollinations.ai`)
3. **Cron concurrency lock** via `bps_cron_lock` transient — `register_shutdown_function` registered BEFORE `set_transient`
4. **Sitemap meta_query** uses `OR (NOT EXISTS, != '1')` — using `compare='!='` alone silently excludes posts with no noindex meta
5. **Prompt sanitization** via `bps_strip_for_prompt()` — strips tags + collapses whitespace + caps at 200 chars
6. **Response cleanup** via `bps_clean_ai_response()` — strips quotes, asterisks, "Focus keyword:" prefixes, takes first line
7. **Bulk-edit hook** (`bulk_edit_custom_box`) registered ONCE (not inside foreach over post types)
8. **REST `regenerate`** checks `current_user_can('edit_post', $post_id)` per-post, not just bps_manage_seo
9. **Conflict safeguard**: `BPS_Adapter_Factory::has_third_party_conflict()` returns true when `primary() instanceof BPS_Native_Adapter` AND third-party SEO plugin is active. `BPS_Native_Output::active()` and `BPS_Schema::should_emit()` BOTH check this and return false to suppress duplicate head tags. `should_emit()` was made public in v2.1 so `BPS_Schema_Extra` can gate its output.
10. **Settings export** writes raw JSON via direct headers + `echo` + `exit` (NOT `WP_REST_Response`, which would double-encode the body).

## Major features built
### v2.0.0 baseline
- Setup Wizard with Quick Setup (best defaults + 3 presets: Blog/WC/News)
- 1-Click "Optimize Everything" on Dashboard with live progress, pause/cancel, score-delta confetti
- Per-post SEO score column in admin list (cached in `_bps_post_score`)
- Bulk Edit "Hide from Google" toggle
- Classic meta box (focus kw, SEO title, snippet, canonical, noindex, SERP preview, regenerate buttons)
- Consolidated Content page (replaces v1.0.5 Posts/Pages/Products)
- XML sitemap (`/sitemap.xml`)
- Article + Breadcrumb + Organization JSON-LD schema
- OG/Twitter cards via `BPS_Native_Output`
- Canonical, noindex, robots.txt editor
- 301/302 redirect manager + 404 logger
- Audit log with auto-pruning (cap 5000 entries) + AI cost stats UI
- Vision support for image alt text (Gemini, OpenRouter, Pollinations, Ollama llava)
- Migration tool from Rank Math / Yoast / AIOSEO (source data never modified)
- Cost tracker + monthly budget cap
- Provider fallback chain
- Per-page CSS/JS enqueue
- Multisite-aware uninstall.php

### v2.1.0 additions
- **Schema expansion**: FAQ, HowTo, Product (auto on WooCommerce), Recipe, Event, Course, JobPosting, plus per-post Custom JSON-LD with live validation badge, plus structured multi-location LocalBusiness editor.
- **Term-level SEO** on every public taxonomy.
- **AI helpers** (per-post, manual): FAQ extraction, slug optimizer, readability scorer (Flesch + AI tips), internal-link suggestions. All run on user's own provider.
- **Orphan-page detector** with AI link suggestions; lazy-loaded dashboard tile.
- **`/llms.txt` generator** for LLM crawlers.
- **Search engine verification** meta tags (Google/Bing/Yandex/Pinterest/Baidu/Norton/Alexa) — paste-friendly, auto-strips `<meta>` wrapper.
- **Native GA4 snippet** with admin-exclude + IP-anon defaults on.
- **Smart capitalize titles**, strip `/category/` base, redirect attachments to parent (default ON), nofollow + new-tab external links, archive noindex (date/search ON by default).
- **Redirect engine extended**: 307/410/451 codes, regex sources (`~...~`) with `$1` back-reference support.
- **CSV redirect import** (drop-zone or paste).
- **Settings JSON export/import** (drop-zone) — API keys deliberately excluded.
- **Find & Replace** across post titles, content, excerpts, image alt/title/caption with dry-run + diff preview.
- **All Start actions are Stop-able mid-flight**: Media bulk alt, Content bulk-generate/apply, Migrator imports — server-side cancel transients + instant UI feedback.
- **Loading bars** are now self-animating (sliding gradient + diagonal stripes) so motion is guaranteed even when backend percentages stall. Skeleton-line and bouncing-dot loaders added.
- **Mobile UX overhaul**: feature cards collapse, tabs horizontal-scroll, forms 16px font (no iOS zoom), tap targets ≥40px.
- **Light-mode-only UI** — dark-mode CSS overrides removed (they caused unreadable text on systems with OS dark mode).

## Native meta keys (when in standalone mode)
`_bps_focus_kw`, `_bps_meta_desc`, `_bps_seo_title`, `_bps_canonical`, `_bps_noindex`, `_bps_post_score`, `_bps_post_score_at`, `_bps_faq_items`, `_bps_howto_steps`, `_bps_howto_name`, `_bps_product`, `_bps_recipe`, `_bps_event`, `_bps_course`, `_bps_jobposting`, `_bps_custom_jsonld`, `_bps_primary_category`. Term meta uses the same keys via `BPS_Term_SEO`.

**v2.2.6+ additions:**
- `_bps_orphan_suggestions` — array `[ts, mode, suggestions]` cached per orphan post; cleared on `save_post`/`deleted_post`.
- `_bps_proposed_image_alt` — shadow meta written by optimizer/cron when `bps_dry_run_mode='1'` instead of the live `_wp_attachment_image_alt`.

**v2.3.0+ additions:**
- `_bps_autolink_targets` — array of orphan post IDs that this source post should auto-link to. Read by `BPS_Orphan_Autolink::maybe_append_related` filter on `the_content`.

## Admin menu structure
BinaryPH SEO (top-level — sidebar label intentionally shorter than the official "BinaryPH AI SEO" plugin name)
├── Dashboard, Content, Media, Schema, Sitemap, Redirects, Audit Log, Tools, Settings
└── Setup Wizard (hidden)

Settings tabs: General | AI Providers | Schema | Tracking & Verification | Links & Archives | Automation | Advanced
Tools tabs: Import / Redirects CSV / Find & Replace / Orphan pages / llms.txt preview / Settings IO

## Defaults (Quick Setup writes all of these)
**On**: sitemap, robots.txt, OG, all 10 schema types, llms.txt, audit log, 404 logging, locale-aware prompts, daily automation, redirect_attachments, noindex_date, noindex_search, GA4 admin-exclude + anon-IP.
**Off (require user input or opinionated)**: nofollow_external, newtab_external, capitalize_titles, strip_category_base, noindex_author, noindex_paged. Verification codes / GA4 ID / LocalBusiness locations / per-post schema fields all need user input.

## Sweep status at last review
- 59/59 PHP files pass `php -l`
- 8/8 JS files pass `node --check`
- All v2.1 classes resolve via autoloader (BPS_Feature_Modules, BPS_Find_Replace, BPS_Settings_IO, BPS_Redirect_Import, BPS_Term_SEO, BPS_Schema_Extra, BPS_AI_Helpers, BPS_Orphan_Detector, BPS_LLMS_Txt)
- No raw `json_encode` (only `wp_json_encode`)
- No `text.pollinations.ai` references
- No "Pollinations no key required" claims
- WPCS-clean: every i18n call uses literal `'binaryph-ai-seo'` (not the constant — gettext can't resolve constants); all placeholder strings have `/* translators: */` comments; multi-placeholder strings use `%1$s/%2$s` ordered form; all output escaped (with documented `phpcs:ignore` for the markdown llms.txt body and JSON settings export); all $_POST/$_GET wrapped in `wp_unslash()` + appropriate `sanitize_*`; all $wpdb queries either prepared or annotated; direct DB queries on custom tables (bps_redirects, bps_404s, bps_audit, bps_ai_calls) annotated with documented `phpcs:ignore` (core caching APIs don't cover them).

## Known limitations / deferred to v2.2+
- AI optimizer is synchronous (Action Scheduler is v2.2)
- GSC integration is meta-tag-only; full OAuth + CTR data deferred
- Rank tracker / SERP API out of scope (paid SaaS)
- Email reports / agency tier off-strategy (audience is non-technical site owners)
- Watermarked social images out of scope (image pipeline)

## Migration workflow when switching from Rank Math
1. Install BinaryPH AI SEO 2.1.0 (auto-defers to Rank Math)
2. Tools → Import from Rank Math → Preview → Import (Stop button available)
3. Verify on Content page + front-end view-source
4. Settings → Advanced → Coexistence → Standalone (safeguard kicks in if Rank Math still active — suppresses our output, shows red warning)
5. Submit `/sitemap.xml` to GSC, remove `/sitemap_index.xml`
6. Deactivate/uninstall Rank Math
7. Original Rank Math data remains in postmeta as backup

---

## What changed in this conversation (v2.0.0 → v2.1.0)

### UI/UX overhaul
- **Killed dark mode** — `@media (prefers-color-scheme: dark)` and `body.is-dark-mode` overrides were flipping `--bps-text` to near-white on systems with OS dark mode, causing unreadable text on the still-white WP admin chrome. Removed both. Forced `color-scheme: light`. Result was a polished light-mode design (Stripe/Linear-tier) — premium not utilitarian.
- **Mobile coverage** at three breakpoints (≤960, ≤782, ≤480): page header stacks, tabs horizontal-scroll, forms get 16px font (prevents iOS zoom-on-focus), tap targets ≥40px, metabox/provider input+button rows wrap, code blocks `word-break: break-all`, score components reflow.
- **Loading bars** now self-animate via layered `::before` (sliding gradient) + `::after` (diagonal stripes) so motion is guaranteed even if JS-driven width updates stall at 0%. Added `.bps-loading-dots` and `.bps-skeleton` shimmer loaders.

### Stop buttons everywhere
User reported Media bulk alt-text generation had no stop button and continued in background after refresh. Fix:
- `BPS_Media_Alt::ajax_cancel` action + `bps_media_alt_cancel` transient checked at start of every step AND between every image in a batch.
- Stop click → `finish()` runs IMMEDIATELY (don't wait for in-flight request) + `AbortController` cancels in-flight fetch + cancel POST sets server transient. `finished` flag guards `finish()` to run exactly once.
- Same pattern applied to: Content page bulk-generate/apply (sequential with cancel flag, shared progress UI), Tools migrator imports (cancel transient checked at section boundaries + every 50 rows). Dashboard optimizer already had Pause + Cancel.
- `beforeunload` fires `sendCancel({ keepalive: true })` so refresh stops new batches.

### Bulk denominator (media)
User reported missing-alt count showed total images (966) instead of missing (155). Fix: `bps_media_alt_initial` transient locks the "missing-when-run-started" count for the entire run, used as the stable progress denominator.

### v2.1 features (per Rank Math feature analysis)
~25 new features in one batch — see "v2.1.0 additions" above. Quick Setup defaults explicitly enumerate every new option so fresh installs work on the spot.

### WPCS final-release sweep
Multiple lint passes fixed:
- 475 occurrences of `BPS_TEXT_DOMAIN` in i18n calls replaced with literal `'binaryph-ai-seo'` (gettext can't resolve constants). Constant kept defined for backward compat.
- All `wp_die(__())` → `wp_die(esc_html__())` across admin pages.
- Settings-export REST endpoint rewritten to bypass `WP_REST_Response` (was double-encoding JSON).
- `BPS_Schema::should_emit()` made public (was private — `BPS_Schema_Extra` would have fataled).
- `parse_url()` → `wp_parse_url()` everywhere.
- `strip_tags()` → `wp_strip_all_tags()`.
- All `$_POST`/`$_GET`/`$_SERVER` reads wrapped with `wp_unslash()` + appropriate `sanitize_*`.
- `LIKE 'image/%%'` replaced with `wpdb->esc_like('image/') . '%'` placeholder pattern.
- Find&Replace SQL: column whitelist + backtick-quoted identifiers + `prepare()` for values.
- `error_log()` calls wrapped in `if (defined('WP_DEBUG') && WP_DEBUG)`.
- Documented `phpcs:ignore` for unavoidable patterns: custom-table direct queries (no core caching API covers them), interpolated table names in `prepare()` ($table = $wpdb->prefix . 'fixed' is safe-by-construction), set_time_limit on long-running endpoints, raw markdown body of `/llms.txt`, raw JSON body of settings export.
- Translator comments added on every placeholder-bearing translation; multi-placeholder strings use ordered `%1$s/%2$s`.
- `Domain Path` plugin header removed (pointed to nonexistent dir); `load_plugin_textdomain()` removed (WP 4.6+ auto-loads from .org).

### Final state
- **v2.1.0** ready for release.
- 59 PHP + 8 JS files lint clean.
- All errors and recoverable warnings from the WPCS scanner addressed.
- Plugin name unchanged (`BinaryPH AI SEO`); only the menu label is the shorter `BinaryPH SEO`.

---

## v2.1.1 (this conversation)

### PHPCS / Plugin Check sweep
- Per-file `// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals` added to ~50 files (sniffer doesn't auto-detect `BPS_/bps_/binaryph_ai_seo` prefixes).
- Multi-line SQL annotations migrated from single-line `phpcs:ignore` to `phpcs:disable`/`phpcs:enable` blocks; `PluginCheck.Security.DirectDB.UnescapedDBParameter` added to all custom-table sites. File-level disables on migrator/redirects/audit-logger/aioseo-adapter/uninstall.
- BOM + line-ending hygiene: a Windows-PowerShell `Set-Content -Encoding UTF8` rewrite added BOM to 53 files (PS5.1 default writes UTF-8-with-BOM); stripped, normalised to CRLF.

### AI provider audit
- All 6 providers (Pollinations, Gemini, OpenRouter, Grok, Ollama, OpenWebUI) verified against current spec — endpoints, auth, request/response shapes all correct. No code change needed.

### v2.1.1 security/logic fixes (10)
1. Quick Setup no longer wipes user customisations on re-run (first run = full reset, later runs only fill missing keys).
2. Cron lock TOCTOU race fixed via atomic `add_option('bps_cron_lock', …, 'no')` with 5-min stale-lock cleanup.
3. Optimizer `process_item()` and `bulk_apply` REST endpoint now check `current_user_can('edit_post', $post_id)` per-post; bulk_apply also whitelists `$field` name.
4. Dashboard cancel no longer triggers confetti + reload — added `state.cancelled` branch in `dashboard.js`.
5. `bps_ai_model` scoped to primary provider only; fallback providers use their own defaults.
6. `JSON_UNESCAPED_SLASHES` removed from all 3 JSON-LD emit sites (default `/` escaping prevents `</script>` breakout via custom JSON-LD).
7. Settings JSON import now per-key type-coerces (bool/int/url/textarea/key/text); type-mismatched values skipped.
8. Crypto: when libsodium missing but ciphertext exists, set transient + show distinct red admin notice ("re-enter API keys") instead of silently returning `''`.
9. robots.txt: actual `Sitemap:` line now appended when no custom `bps_robots_txt` is set (previously a dead `if` block).
10. GA4 snippet: `wp_print_script_tag` + `wp_print_inline_script_tag` instead of raw `<script>` echo (the only Plugin Check ERROR).

### Migration improvements
- **Term-level SEO migration** added (`BPS_Migrator::run_term_meta()`): RM + Yoast term meta (focus_kw, snippet, title, canonical, noindex on every public taxonomy term) → our `_bps_*` term meta.
- **Primary category migration**: `rank_math_primary_category` / `_yoast_wpseo_primary_category` → `_bps_primary_category`. **Wired into `BPS_Schema::breadcrumb()`** for posts + WC products (defensive fallback to `$cats[0]` if primary not actually attached).
- **Schema-customization preview** (`scan_schema_customizations()`): import preview now lists per-post FAQ/HowTo/Recipe/Event/Course/JobPosting/Product (RM) and Article-type/Page-type overrides (Yoast) so user sees what will need manual re-entry. Schema data deliberately NOT auto-copied (RM's serialized field shapes vary by version — bad mapping = corrupted rich snippets).
- **Stale-data admin notice** (`BPS_Migrator::init` + `maybe_show_stale_data_notice`): when in standalone with unmigrated RM/Yoast/AIOSEO postmeta, dismissible info notice nudges to Tools → Import. 30-min transient cache, busted on import completion (`bps_last_import.source`).
- **Rewrite-flush on coexistence change**: defer→standalone now auto-flushes WP rewrite rules via `update_option_bps_coexistence_mode` hook setting `bps_flush_rewrites` transient (sitemap class picks it up next init).

### Bugs caught in final pre-release review
- `detect_stale_sources` AIOSEO branch was broken: `(int) $wpdb->get_var("SHOW TABLES LIKE …")` collapses non-digit-prefixed table names to 0, so detection never fired. Fixed by using existing `self::table_exists()` helper.
- Sitemap `maybe_serve()` set `Content-Type: application/xml` *before* validating the post-type slug; bad URLs `/sitemap-bogus-N.xml` then triggered `wp_die()` with HTML body under XML headers. Now validates type upstream.
- Readme: "Pollinations does not require an API key" outdated since v2.0; install-path typo `/wp-plugins/` → `/wp-content/plugins/`. Both fixed.

### Critical fixes that must not be re-introduced (in addition to the v2.0/v2.1 list above)
11. **Cron lock** is now `add_option('bps_cron_lock', time(), '', 'no')` (atomic) — do NOT revert to `get_transient`/`set_transient` (TOCTOU race).
12. **Optimizer `process_item()`** must check `current_user_can('edit_post', $post_id)` before any write.
13. **JSON-LD emission** must NOT use `JSON_UNESCAPED_SLASHES` (enables `</script>` breakout).
14. **`bps_ai_model`** must only apply when `$ai_platform === get_option('bps_default_ai')` — otherwise the fallback chain breaks.
15. **Crypto `decrypt()`** must set `bps_crypto_undecryptable` transient when ciphertext is present but libsodium isn't.

### Final state
- **v2.1.1** ready for release. Header `Version:`, `BINARYPH_AI_SEO_VERSION` constant, and readme `Stable tag:` all bumped.
- 59/59 PHP + 9/9 JS files lint-clean.
- No proprietary code introduced — all contributions are original work using public WP APIs, public REST APIs, and public schema/SEO specs. RM/Yoast/AIOSEO interop is read-only of public data structures.
- License: GPL2 (compatible with wp.org distribution).

---

## v2.1.2 → v2.2.3 (this conversation)

### Theme: heuristic-first, DB-friendly, finish-clean

The big shift: AI is **opt-in**, not the default, across every feature. New `bps_optimizer_ai_mode` setting (default `'heuristic'`) plus a per-feature `bps_media_alt_ai_mode`. Every AI surface now has a heuristic counterpart and auto-downgrades AI→heuristic when no provider is configured (so calls never hard-fail).

### v2.1.2 — optimizer DB-pressure fixes (Hostinger)
- `bps_optimize_queue` / `bps_optimize_state` now `autoload=no`; created via `delete_option`+`add_option(..., 'no')` in `start()`. Earlier `update_option` defaulted to autoload=yes, bloating `alloptions` with a 500-item queue on every front-end pageview.
- `build_queue()` skips items where `missing_fields()` returns empty (was burning empty REST round-trips because the SQL pre-filter checks 3 fields via aliases but `missing_fields()` checks 4 against the primary adapter only).

### v2.1.3 — heuristic mode for optimizer + cron + UX
- `BPS_Optimizer::heuristic_focus_kw($title)` (stopword-strip, take 1–3 meaningful words) and `heuristic_meta_desc($post_id)` (excerpt → first 155 chars of content).
- `process_item()` and `bps_run_automation()` (cron) read `bps_optimizer_ai_mode` from state and branch.
- Dashboard hero now shows a mode-picker modal: heuristic radio (default) / AI radio (disabled with "Add a provider →" if no key) + "Run gradually in background — N items/day" toggle that calls new `/optimize/schedule` REST.
- Orphan stat tooltip clarifies it's not auto-fixed by Optimize.
- Bumped `bps_max_api_calls_per_run` default 4 → 10 in `default_settings()`.

### v2.1.4 — configurable per-run caps
- New option `bps_optimizer_click_max` (default 50). Modal exposes "Limit this run to N posts" input pre-filled from setting; Settings → Automation has matching field.
- `BPS_Optimizer::start()` and `build_queue()` accept `$limit`; precedence is per-run > saved option > legacy `bps_optimizer_max_queue` filter (kept as escape hatch).
- `bpsCore` localized data exposes `has_provider`, `click_max`, `sched_max`, `opt_mode` to JS.

### v2.1.5 — wizard re-run UX
- Setup Wizard detects re-run via `bps_quick_setup_complete`, swaps heading and copy. Quick Setup button rendered as plain `.bps-btn` (not pulsing hero) on re-run since it's a fill-only operation. New "Reset to defaults" button calls `/quick-setup` with `{reset:true}`.
- Each preset card now lists what it overwrites (Blog → "Sets schedule to daily and turns on Article schema", etc.), with a "current" pill on `bps_active_preset`.
- Dashboard banner copy changes based on whether setup has run before.
- Wizard's vestigial "1 2 3" step indicator removed.

### v2.1.6 — CRITICAL: page-asset map broke when menu was renamed
- "BinaryPH SEO" → "BinaryPH AI SEO" everywhere user-facing (sidebar, page headers, metabox title, wizard, dashboard widget, conflict notice, HTML comments, readme).
- WordPress derives submenu screen-ID prefix from `sanitize_title($menu_title)`, so renaming the menu silently flipped screen IDs from `binaryph-seo_page_*` to `binaryph-ai-seo_page_*` and **broke the asset enqueue map**, leaving every plugin admin page without its page-specific JS/CSS (Test buttons dead, modals gone, etc.). Fixed by switching to URL-slug detection (`$_GET['page']`) in `BPS_Assets::enqueue_admin()` — slug-based detection is immune to future label changes.
- Other `strpos($screen->id, 'binaryph-seo')` substring checks survive the rename because submenu slugs still start with `binaryph-seo-`.

### v2.1.7 — provider detection
- `bps_has_any_provider_configured()` rewritten: now checks user's `bps_default_ai` first, then fallback chain, then every known provider with a stored key. Old version only iterated the chain, so a user with default=Grok but Grok not in chain returned false.
- Ollama branch: only counts as configured when `bps_default_ai === 'ollama'` (don't auto-claim local since it may not be running).
- `/optimize/schedule` REST validates provider when `mode === 'ai'` and returns 400 `no_provider` if missing.

### v2.1.8 — polling pacing + cleanup + upgrade migration
- Dashboard JS polling 200ms → 750ms; `step()` now batches up to 5 items per request (the JS sends `count: 5`). Same throughput, ~19× fewer requests, ~3× fewer options writes. Critical for shared-hosting (Hostinger ~25 max_user_connections).
- Media bulk alt-text polling also 200ms → 750ms.
- `BPS_Optimizer::cleanup()` REST endpoint deletes queue+state options after run terminates; JS calls it on finish/cancel/error so we don't leave per-site rows in `wp_options` forever.
- `BPS_Installer::DB_VERSION` bumped to `2.1.8`; `maybe_upgrade()` force-deletes legacy `bps_optimize_queue`/`bps_optimize_state` rows that earlier versions wrote with `autoload=yes`.

### v2.1.9 — heuristic for Media + Orphan
- Media bulk alt-text page now has heuristic/AI radio picker (default heuristic). Heuristic mode skips `bps_get_image_alt` (AI vision) entirely and uses `fallback_alt()` (cleaned filename + parent-post title) directly. New option `bps_media_alt_ai_mode`.
- `BPS_Media_Alt::ajax_step()` reads mode from `$_POST` (per-run) > saved option > heuristic; auto-downgrades AI→heuristic when no provider configured.
- Orphan tab: per-row "Find similar pages" button (heuristic) added alongside "AI suggestions". Backed by new `BPS_Orphan_Detector::similar_pages($id)` — extracts significant words from orphan's title (stopword + min-3-char filter), runs multi-LIKE scan against `wp_posts.post_content`, ranks by match count + recency, returns top 5.

### v2.2.0 — heuristic-first universal
- Every AI-capable endpoint now mode-aware: `/posts/{id}/regenerate`, `/optimize/start`, `/optimize/schedule`, `/ai/faq`, `/ai/slug`, `/ai/readability`, `/ai/internal-links`. Same precedence everywhere (per-request param > saved option > heuristic) and same auto-downgrade.
- Heuristic counterparts in `BPS_AI_Helpers`: `extract_faq_heuristic()` parses `<h*>?...</h*>` patterns + first paragraph; `optimize_slug_heuristic()` is title-via-`sanitize_title()` minus stopwords; `readability($id, $mode)` skips AI when heuristic and returns Flesch + heuristic_tips only.
- Internal-link suggestions in heuristic mode reuse `BPS_Orphan_Detector::similar_pages()`.
- Content page + metabox JS send `mode: bpsCore.opt_mode || 'heuristic'`. Orphan tab "AI suggestions" button explicitly forces `mode: 'ai'` (its label promises AI).
- `$ai_guard` split into `$resolve_mode` + `$access_guard` so heuristic paths don't get gated on provider.

### v2.2.1 — final-release audit fixes
- **HIGH**: cron cap was being bypassed when a post already had a keyword (only `$calls++` on keyword-fill path) — could iterate hundreds of posts per run with cap=10. Renamed to `$items_done` and increment once per post touched, regardless of mode/operation.
- **HIGH**: `BPS_Optimizer::step()` shifted items from queue, processed, then wrote — a fatal mid-process silently lost items. Now slices the batch off and writes the advanced queue **before** processing (at-most-once attempt, preferred over infinite-retry on a poison item).
- `heuristic_focus_kw('the a an')` returns `''` instead of `'the a an'`.
- `optimize_slug_heuristic()` falls back to `sanitize_title($source)` when every word is a stopword (instead of returning a stopword-only slug).
- `BPS_Orphan_Detector::similar_pages()` `usort` now has explicit recency tiebreaker (was relying on PHP's non-stable sort + accidental query order).

### v2.2.2 — defaults hardening (fresh-install zero-config)
- `save_int($k, $min, $max, $default)` clamps out-of-range values to default (was: `update_option($k, isset($_POST[$k]) ? absint(...) : 0)` → silently saved 0 on empty submit).
- Settings → Automation render now coerces stored value to default if outside range, so corrupted DB rows still show a workable input.
- `BPS_Installer::DB_VERSION` → `2.2.1`; `maybe_upgrade()` adds `heal_numeric_setting()` pass that repairs `bps_max_api_calls_per_run` and `bps_optimizer_click_max` to defaults if missing or out of range.
- `cron.php` fallback aligned to seeded default (`get_option('bps_max_api_calls_per_run', 10)` not `..., 4)`).
- Audit confirmed every `get_option('bps_*')` site's fallback default matches the seed in `BPS_Presets::default_settings()`.

### v2.2.3 — score cache + orphan bulk
- `BPS_Score::content_totals()` cache (5-min transient) busted only on `save_post`, but `update_post_meta` calls (media bulk alt-fill, optimizer, cron, regenerate) don't fire `save_post` — dashboard showed stale counts for up to 5 minutes after a run. Added `added_post_meta`/`updated_post_meta`/`deleted_post_meta` hooks that bust the cache when the changed key matches the tracked list (focus_kw aliases, meta_desc aliases, seo_title aliases, `_wp_attachment_image_alt`).
- Orphan tab gets bulk toolbar: "Find similar pages for all" (heuristic, 250ms throttle) and "AI suggestions for all" (with confirmation modal warning about per-orphan API cost). Reuses existing per-row result UI; "Stop" button cancels mid-run with `Processing N / total…` status counter.

### Critical fixes that must not be re-introduced (in addition to the v2.0/v2.1.1 list)
16. **Optimizer queue/state options must be `autoload=no`** — recreated via `delete_option`+`add_option(..., '', 'no')` in `start()`. Reverting to plain `update_option` will reintroduce the alloptions bloat.
17. **Asset enqueue map must be URL-slug-based**, not `$screen->id`-based — renaming the menu title flips the screen-id prefix.
18. **`bps_has_any_provider_configured()`** must consider the user's `bps_default_ai` (and known providers with keys), not just the fallback chain.
19. **Mode resolution precedence everywhere is**: per-request param > saved `bps_optimizer_ai_mode` (or feature-specific equivalent) > `'heuristic'`. AI mode auto-downgrades to heuristic when `!bps_has_any_provider_configured()`.
20. **`BPS_Optimizer::step()` advances the queue BEFORE processing** — at-most-once semantics. Don't revert to shift-then-process-then-write (loses items on fatal).
21. **Cron cap counts items, not just AI calls** — increment `$items_done` once per post touched in any mode.
22. **`BPS_Score` cache** must bust on `added_post_meta`/`updated_post_meta`/`deleted_post_meta` for tracked SEO meta keys (not just `save_post`).
23. **`save_int()` clamps to `[$min, $max]` with a `$default` fallback** — prevents silent zero-saves from blank form fields.

### Known regressions intentionally accepted
- Heuristic FAQ extraction is best-effort; quality varies. Users wanting good FAQs need AI.
- Heuristic alt text is markedly weaker than AI vision (filename ≠ image description). The mode picker explicitly recommends AI when a vision-capable provider exists.
- Cleanup of stale `bps_optimize_*` options on browser-crash mid-run isn't perfect, but rows are autoload=no and overwritten on next `start()` — bounded leak, not a real concern.

### Final state
- **v2.2.3** ready for release. Plugin name everywhere is "BinaryPH AI SEO" (sidebar, headers, metabox, wizard, comments, errors). Heuristic by default; AI is opt-in.
- All 5 fixes from the agent-driven release audit applied; 6 false-positive findings documented and skipped with reasoning.
- Lint clean across all touched PHP and JS.

---

## v2.2.4 (this conversation) — CSS hidden-attribute fix

### What happened
Orphan tab showed a "Stop" button visible even when no bulk run was active. The button is rendered as `<button class="bps-btn bps-btn-danger" hidden>` and the JS toggles `el.hidden = true|false`. But `.bps-btn` sets `display: inline-flex` with class specificity, which **beats the UA-stylesheet `[hidden] { display: none }`** — so the `hidden` attribute had no visual effect. Same problem affected `bps-orphan-bulk-status` (a `.bps-card-meta` span) and would affect any `.bps-pill` toggled via `[hidden]`.

### Fix
Single CSS rule in `assets/css/core.css`, just after `.bps-btn[disabled]`:
```css
.bps-btn[hidden], [hidden].bps-pill, [hidden].bps-card-meta { display: none !important; }
```
Cache-busts via `filemtime()`. Bumped to v2.2.4.

### Critical fixes that must not be re-introduced (extends the v2.0–v2.2.3 list)
24. **The `[hidden]` HTML attribute must be re-asserted as `display:none !important`** for any element whose class declares a non-`block` display (`inline-flex`, `grid`, `flex`). Don't rely on the UA stylesheet — class specificity wins.

### PHPCS noise also addressed (no version impact)
- `BPS_Orphan_Detector::similar_pages()` triggered two false-positive sniffer warnings:
  - `ReplacementsWrongNumber` — sniffer can't count placeholders behind `...$args` (variadic spread).
  - `InterpolatedNotPrepared` — sniffer flags the SQL line where `$where_clause` appears, which is past the next-line scope of single-line `phpcs:ignore`.
- Both suppressed via a `phpcs:disable`/`phpcs:enable` block around the multi-line `prepare()` call. No code change.

### Operational debugging episode (no code change)
- WordPress recovery-mode email named our plugin as the fatal source. Actual fatal in `wp-content/debug.log`:
  `Uncaught Error: Failed opening required '/.../binaryph-ai-seo/includes/utils.php' in /.../binaryph-ai-seo/binaryph-ai-seo.php:32`
  → `utils.php` was **missing from the server upload**, not a code bug. Re-uploading the file resolved the fatal. Lesson: when WP fatal-error attribution names a plugin, verify file presence on disk before searching for code regressions.
- The remaining log entries (Action Scheduler "called incorrectly" Notices, `wise-chat` plugin Warnings, PHP 8.4 `Deprecated: Passing null to strpos/str_replace/strip_tags` in `wp-includes/functions.php` and `wp-admin/admin-header.php:41`) are **all from other plugins/themes**, not us. Confirmed: we don't filter `admin_title`, our `document_title_parts` and `pre_get_document_title` filters never return `null`. Nothing actionable on our end.

### Band-aid that was reverted
- Briefly added try/catch wrappers around `BPS_Score::maybe_bust_cache_for_meta`, `BPS_Optimizer::process_item`, and `BPS_Media_Alt::ajax_step` (v2.2.4 attempt #1). User correctly called this out as code bloat that hides the bug, not fixes it. Reverted; version stayed at 2.2.3 until the genuine `[hidden]` CSS fix shipped as 2.2.4.
- Lesson: defensive try/catch is appropriate around legitimately external calls (HTTP, third-party APIs) — NOT around our own code, where it just masks bugs and makes debugging harder.

### Final state
- **v2.2.4** ready for release. The visible-Stop-button regression fixed by one targeted CSS rule.
- 59/59 PHP + 9/9 JS files lint clean (PHPCS suppressions documented inline).
- The plugin's filters and hooks are confirmed not implicated in the PHP 8.4 deprecation noise that appears in shared-hosting debug logs.

---

## v2.2.5 (this conversation) — Orphan count + bulk + cron status panel

### What happened
1. **Orphan count discrepancy**: dashboard showed `200` first-load (lazy-loaded `find()` slice cap) then `773` on reload (cached full list). Two paths counted different things.
2. **"Find similar pages for all"** technically worked but per-row results were buried in a 1500-row scattered table; users saw "nothing happened".
3. **Automation tab**: cron wired correctly but no UI to confirm scheduled state — users couldn't tell if it would actually run.

### Fixes
- `BPS_Orphan_Detector` split: `find_all()` (private, returns full list, primes cache), `find($limit)` (sliced for tables), `count()` (returns total from cache or computes once). REST `/orphans` now returns `{ orphans, total }`; dashboard reads `total`.
- New REST `/orphans/similar/bulk` (max 25 ids/request) + `BPS_Orphan_Detector::similar_pages_bulk(array $ids)`. Tools page heuristic bulk uses it (one HTTP per 25 orphans instead of one per 1); AI bulk still per-row (third-party API throughput).
- Tools orphan tab: sticky progress bar with animated fill + `with suggestions / no matches` counters + always-visible Stop. Per-row pill ("N suggested" / "no matches"). "Expand all" / "Collapse all" toggles. Auto-scroll to current row in AI mode. Toast summary at end.
- Settings → Automation gets `automation_status_panel()`: shows enabled flag, **next scheduled run** (with human time-until), frequency, **WP-Cron status** (warns if `DISABLE_WP_CRON`), last run details. The "Limited to 4 AI calls per run" copy was stale (now reads `bps_max_api_calls_per_run`).

### Final state
- **v2.2.5** released. 10 PHP + 3 JS files touched, all lint-clean.

---

## v2.2.6 (this conversation) — Per-feature Optimize picker + real scheduling

### Theme
The "Optimize my whole site" hero now offers a **per-feature mode picker**: 4 features (`focus_kw`, `meta_desc`, `image_alt`, `orphans`) × 3 modes (`heuristic`, `ai`, `off`). Heuristic features run inline in-browser; AI features queue for the **daily cron** plus an immediate `bps_optimize_first_run` one-shot ~60s out so the user sees AI work begin within a minute. This makes scheduling real: AI is no longer interactive (slow + tokens).

### New / changed
- New option `bps_opt_features` — assoc array `['focus_kw'=>'heuristic'|'ai'|'off', 'meta_desc'=>…, 'image_alt'=>…, 'orphans'=>…]`. Defaults all-heuristic in `BPS_Presets::default_settings()`.
- `BPS_Optimizer::FEATURES` constant lists the 4 feature keys.
- `BPS_Optimizer::resolve_features($override = null)` returns a normalised map (every key present, every value valid).
- `BPS_Optimizer::start($scope, $mode, $limit, $features = null)` — features map wins over legacy `$mode`. Inline run only queues heuristic features; AI features routed to cron via `schedule_features()`.
- `BPS_Optimizer::schedule_features($features)` — saves map, ensures daily cron registered (`ensure_daily_cron()`), schedules `bps_optimize_first_run` one-shot (`trigger_first_ai_run()`). Idempotent.
- Queue items now have `type: 'post'|'orphan'`, `modes: [field => 'heuristic'|'ai']` for per-field mode resolution. New `process_orphan_item($item)` writes `_bps_orphan_suggestions` post meta `[ts, mode, suggestions]` (cleared on `save_post` / `deleted_post`).
- Cron worker (`bps_run_automation`) reads `bps_opt_features`; AI cap enforced strictly via `$calls`, heuristic cap separate at 200/run. Image-alt path honors `bps_dry_run_mode` (writes `_bps_proposed_image_alt`).
- Action `bps_optimize_first_run` registered in `cron.php` as `bps_cron_first_run_handler` — same worker as recurring event.
- REST `/optimize/start` and `/optimize/schedule` accept `features` param. Legacy `mode` still accepted.
- Dashboard modal: 4 feature rows with count pills + 3-way picker (Heuristic / AI / Skip). Feature with count=0 auto-defaults to "Skip". AI radios disabled with provider link if no key. Inline-run cap + AI items-per-day inputs at the bottom.

### Final state
- **v2.2.6** released; 9 PHP + 2 JS files touched, all lint-clean.

---

## v2.2.7 (this conversation) — Pre-release audit fixes (5 HIGH + 22 MEDIUM + 15 LOW)

### Theme
Four-agent parallel review uncovered 40+ findings across the codebase. Fixed all non-harmless ones in one go. Two follow-up review passes caught 4 more ship-blockers; those fixed too. Final state is genuinely release-ready.

### HIGH (5)
1. **Native output emitted home canonical+OG on every non-singular page** — archives, categories, tags, search, 404, date pages all advertised themselves as `home_url('/')`. Now: `is_front_page()` → `output_homepage()`; `is_home() && !is_front_page()` (separate blog page) → `output_homepage(get_permalink(page_for_posts))`; everything else stays silent. Static-front-page case also routes through `output_homepage()` so `og:type` is `website` not `article`.
2. **Term-SEO didn't wire custom taxonomies** — `init()` ran on `plugins_loaded`, but custom taxonomies (incl. WC `product_cat`) register on `init`. Fixed: `add_action('init', 'wire_taxonomy_hooks', 99)`.
3. **Metabox primary-category dropdown invisible for products** — used `wp_get_post_categories()` which is hard-coded to the `category` taxonomy. Now branches by post type, uses `wp_get_object_terms(..., 'product_cat')` for products.
4. **Settings checkboxes default off in UI but on at runtime** — `checkbox_row()` was `get_option($k, '0')` while runtime gates default to `'1'`. Fresh installs saw on-by-default features unchecked; first Save silently disabled them. Fixed: new `BPS_Presets::checkbox_default($key)` reads from `default_settings()`.
5. **AI monthly budget cap silently bypassed by audit-log toggle** — `bps_log_ai_call` short-circuited when audit log was off, so `bps_ai_calls` table never received cost rows, so `bps_within_budget()` always summed to 0. Decoupled cost-accounting from audit-log toggle.

### MEDIUM batch
- Pollinations price now non-zero (was hard-coded 0; budget cap couldn't trip on Pollinations).
- Provider fallback chain "no API key" notice only fires when the missing key is the **primary** provider — no more spam from chain providers the user never configured.
- Image-alt path in optimizer + cron honors `bps_dry_run_mode`: writes `_bps_proposed_image_alt` shadow meta in dry-run.
- Cron AI cap enforced strictly on `$calls`; heuristic touches separately bounded at 200/run. Earlier `$items_done` cap eaten by free heuristic touches blocked AI work for the rest of the run.
- Cron orphan AI budget no longer overshoots cap by 1 (`max(1, …)` → `max(0, …)`).
- Dashboard modal lazy-resolves orphan count via REST before opening when current is 0 — fresh-page click no longer disables orphan radios.
- Settings IO: `bps_opt_features`, `bps_optimizer_*`, `bps_media_alt_ai_mode` added to export whitelist; empty-string values preserved on round-trip via `__bps_unset` sentinel; new sanitiser for `bps_opt_features` validates each feature → `heuristic|ai|off`.
- LocalBusiness JSON-LD now respects `BPS_Schema::should_emit()` — no duplicate output with third-party SEO active.
- Sitemap `count()` now uses the same `WP_Query` as `render_feed` (filters noindex + `has_password=false`); index page count matches feed contents. `render_feed` overrides `'fields' => 'all'` so `$p->post_modified_gmt` works (was returning IDs after `base_query_args` set `fields=ids`).
- 404 logger uses atomic `INSERT … ON DUPLICATE KEY UPDATE` — no more lost concurrent hits via the SELECT-then-INSERT TOCTOU.
- Redirect engine has self-target loop check (parses target path, compares to incoming path post-normalisation).
- Audit prune capped at 1000 rows per call (filterable via `bps_audit_prune_batch`); same for `ai_call_prune`. Prevents table-locking on huge backlogs from re-enabled-after-long-pause sites.
- RankMath `run_rankmath_404s` import: `GREATEST(hits, VALUES(hits))` (was `hits + VALUES(hits)`, doubled on every re-run).
- Cancelled migrations don't write `bps_last_import` — stale-data nudge stays visible for partially-imported users.
- Yoast term-SEO migration reads `wpseo_taxonomy_meta` option (correct location), not `wp_termmeta`. Both scan and run paths fixed.
- AIOSEO scan: keyword count JSON-decodes `keyphrases` to count only real keyphrases (was over-reporting because AIOSEO writes `{"focus":{"keyphrase":""}}` even when blank). Redirect scan adds `enabled = 1 OR NULL` and non-empty URL filters.
- AIOSEO adapter `update()` insert path supplies sane defaults for required NOT-NULL columns (`title, description, keyphrases, canonical_url, robots_default=1, robots_noindex=0, robots_nofollow=0, created, updated`); falls back to writing native postmeta if AIOSEO's table still rejects.
- `_bps_orphan_suggestions` invalidated on `save_post` / `deleted_post` — manual link additions clear the stale suggestion.
- Orphan detector cap raised to 5000 (filterable via `bps_orphan_scan_cap`); empty-result path now caches.
- Tools page Stop button immediately disables + shows "Stopping…" — no frozen-feeling click during 30s+ in-flight AI request.
- AI bulk error path now sets per-row "error" pill; `scrollIntoView` only fires when the row is offscreen (not on every iteration).
- Content.js `regenerate` now sees `r.downgraded` flag from REST and prompts user once per session instead of silently substituting heuristic for AI (the `r.error === 'no_provider'` branch was dead code — REST never returned it).
- Settings → Automation legacy "How to fill missing fields" dropdown now broadcasts the chosen mode into all 4 entries of `bps_opt_features` so the dropdown still works as users expect (cron reads features map exclusively since v2.2.6).
- One-time upgrade migration in `BPS_Installer::maybe_upgrade()` (DB_VERSION → 2.2.7): if `bps_opt_features` row missing but `bps_optimizer_ai_mode` is set, derive features map from legacy mode so existing AI cron users keep AI on cron.

### LOW batch (still applied because cheap)
- Migrator stale-data notice no longer rendered as `is-dismissible` (the WP × button didn't actually persist dismissal).
- Find & Replace pre-validates regex with `@preg_match($pattern, '')` and surfaces `error` field on invalid PCRE — no more silent "0 matches" with malformed pattern.
- RankMath `set_noindex` is no-op when value already matches (no spurious robots-array rewrites; no `index` token spam).
- llms.txt rewrite-flush hooked on `update_option_bps_llms_txt_enabled`.
- `BPS_Optimizer::status()` 3-second score cache via transient — no `COUNT(*)` storm during 500-poll run.
- `BPS_Optimizer::trigger_first_ai_run()` dead `if` block removed.

### Critical fixes that must not be re-introduced (extends v2.0–v2.2.4 list)
25. **Per-feature mode map (`bps_opt_features`)** is the source of truth for the cron worker since v2.2.6. Reading legacy `bps_optimizer_ai_mode` directly will silently break for users who interacted with the dashboard picker. Use `BPS_Optimizer::resolve_features()` (always returns a normalised map).
26. **Native output `output()` must branch the homepage cases BEFORE the `is_singular()` check** — a static front Page is `is_singular()===true`, so without the early `is_front_page()` short-circuit it would emit `og:type=article` for the homepage.
27. **Native output non-homepage non-singular pages must stay silent** — archives/search/404/term/date pages must NOT call `output_homepage()`. WordPress core emits canonical for them; term-SEO emits term archive meta.
28. **Sitemap `render_feed` MUST override `'fields' => 'all'`** when calling `array_merge(self::base_query_args($type), …)` — base_query_args sets `fields=ids` for the count path, and using IDs in the feed loop produces empty `<lastmod>` + PHP warnings.
29. **404 logger uses atomic `INSERT … ON DUPLICATE KEY UPDATE`** — do NOT revert to SELECT-then-INSERT/UPDATE (TOCTOU race; concurrent 404s for the same URL silently drop rows on UNIQUE conflict).
30. **AIOSEO adapter insert path must supply NOT-NULL column defaults** + native-postmeta fallback. Bare `$wpdb->insert(['post_id', $field, 'created', 'updated'])` returns false on most AIOSEO schemas.
31. **`bps_log_ai_call` MUST run regardless of `bps_audit_log_enabled`** — the `bps_ai_calls` table is the budget-enforcement source. Audit log toggle is for the separate `bps_audit` table.
32. **Audit prune is batch-capped at 1000 rows/call** — single `DELETE … LIMIT $count` on a 50k+ backlog locks the table. `bps_audit_prune_batch` / `bps_ai_calls_prune_batch` filters cap per-call work.
33. **Migrator must not write `bps_last_import` on cancelled imports** — keys off `bps_last_import.source` to silence the stale-data nudge; cancelled runs would silence it forever.
34. **Yoast term SEO is in the option `wpseo_taxonomy_meta`** (per-tax → per-term → field map), NOT in `wp_termmeta`. Both scan and run paths must look there.
35. **Term-SEO hooks must wire on `init` priority 99** (not `plugins_loaded`) — custom taxonomies register on `init`.
36. **Settings checkbox defaults must come from `BPS_Presets::checkbox_default()`** — hard-coding `'0'` produces fresh-install Save handlers that disable on-by-default features.
37. **Settings → Automation save broadcasts `bps_optimizer_ai_mode` to `bps_opt_features`** — keeps the legacy dropdown meaningful for users who don't use the dashboard picker.
38. **`BPS_Installer::maybe_upgrade()` derives `bps_opt_features` from legacy `bps_optimizer_ai_mode`** for v2.2.7 upgrade — without this, every existing AI-mode cron user silently downgrades to heuristic on upgrade.

### Final state
- **v2.2.7** ready for release. 25/25 PHP + 4/4 JS files lint-clean (`php -l` + `node --check`).
- Two parallel reviewers + self-review confirm no remaining HIGH/MEDIUM logic bugs in scope.
- Plugin header `Version:`, `BINARYPH_AI_SEO_VERSION` constant, `BPS_Installer::DB_VERSION`, and readme `Stable tag` all bumped to **2.2.7**.
- The full v2.2.6 per-feature picker delivers the user's spec: heuristic = inline + free + instant; AI = real scheduled background runs that don't burn tokens or block the user. Cron is genuinely scheduled (one-shot first-run + daily recurring), feature-aware, AI-cap-strict, and dry-run-honoring across all 4 feature paths.

---

## v2.2.8 (this conversation) — Modal compaction + Quick Setup re-run polish

### What happened
User screenshot of the Optimize picker showed it overflowing the viewport (~800px tall on a typical laptop). Each feature row was ~120px because radios stacked vertically. Quick Setup re-run button was a plain bland full-width `.bps-btn` lacking the "this is the recommended action" cue.

### Fixes
- `.bps-feature-row`: radios now horizontal (single line) instead of stacked. Padding tightened. Each row drops from ~120px to ~64px (~50% shorter modal).
- `.bps-modal`: added `max-height: calc(100vh - 32px)` + `overflow-y: auto` so even a tall modal scrolls within the viewport — no more zoom-out workaround.
- Wizard re-run state: Quick Setup button rebuilt as `.bps-quick-setup-rerun` card-style element — circular reload icon + stacked title/sub-description + chevron that nudges right on hover. Subtle gradient + soft border tinted with primary color, hover lift, focus ring. Conveys "this is recommended" without the loud pulsing of the first-run hero.

### Final state
- **v2.2.8** released. CSS-only + minor markup change. Lint clean.

---

## v2.3.0 (this conversation) — Orphan auto-link + SEO title as first-class feature

### Theme
User pushed back on the orphan flow: "you mean all this process, but it won't actually change anything?" Correct — pre-v2.3.0 orphans only cached suggestions for manual review. v2.3.0 adds **real auto-fix**: a small "Related:" link block rendered at the bottom of selected source posts via the `the_content` filter. Search engines see the link → orphan status removed. The DB post_content is never modified — toggle off and the block disappears.

Also: SEO title was a score component (10% weight) and was tracked by `missing_fields()`, but the v2.2.6 picker only exposed 4 features. Promoted to a first-class 5th feature.

### New / changed
- New class `BPS_Orphan_Autolink` (`includes/class-bps-orphan-autolink.php`):
  - Stores `_bps_autolink_targets` post meta on chosen source posts.
  - Filters `the_content` priority 99 on singular pages → appends `<aside class="bps-related-posts"><span class="bps-related-label">Related:</span> <a>Title 1</a> · <a>Title 2</a></aside>`.
  - Inlines ~250 bytes of CSS via `wp_head` ONLY when this rendering post has targets — no extra HTTP request.
  - Validates each target on render (skips non-published / deleted posts).
  - Caps each source post at 5 outbound autolinks (`MAX_PER_POST`) so a popular source doesn't become a link farm.
- `BPS_Optimizer::process_orphan_item()` now writes the reverse meta when `bps_orphan_autolink_enabled='1'`. Uses top-N candidates (`bps_orphan_autolink_count` = 1 or 3). Status message changes from "5 suggestions" to "auto-linked from N posts" — honest reporting.
- `BPS_Optimizer::FEATURES` now `['focus_kw','meta_desc','seo_title','image_alt','orphans']` (5 keys).
- `BPS_Optimizer::heuristic_seo_title($post_id, $title='')` — appends ` | Site Name` when there's room, smart-trims to 60 chars at word boundary when too long, no-ops when title already includes brand.
- Cron worker handles `seo_title` mode the same way as kw/desc/alt (heuristic / ai / off, AI cap respected, dry-run respected).
- Settings → Schema gets new "Orphan auto-link" section: switch + select (1 or 3 source posts per orphan).
- Dashboard "Optimize" picker now shows 5 rows. Missing-SEO-title count comes from existing `BPS_Score::content_totals()['with_title']`. Button gains `data-missing-title` attribute. `$needing` recomputed to include title.
- Settings IO: `bps_orphan_autolink_enabled` (bool) and `bps_orphan_autolink_count` (constrained-int, only 1 or 3) added to whitelist + sanitisers.
- `BPS_Plugin::boot()` calls `BPS_Orphan_Autolink::init()`.

### Deliberate decisions
- DB post_content NEVER modified — render-time injection only. Toggle off → block disappears with zero cleanup, no Gutenberg/Classic-Editor re-save conflicts, no revision pollution.
- v2.3.0 upgrade does NOT need a migration: existing 4-key `bps_opt_features` rows get `seo_title` filled to `'heuristic'` automatically by `resolve_features()` on first read (it normalises the map every call).

### Final state
- **v2.3.0** released. New class + 11 PHP / 1 JS files touched. Lint clean.

---

## v2.3.1 (this conversation) — "Low 404 rate" score actionable

### Theme
User asked: "what can we do also for Low 404 rate". Score component used raw `bps_404s` row count from last 30 days, with no UI affordance to actually move the number. Now there are five.

### Fixes
1. **Bot-scan noise filtered at ingest** — `BPS_Redirects::is_noise_url()` skips known attack/probe paths before insert: `/.env`, `/.git/`, `/wp-config.bak`, `/wp-config-sample`, `/phpinfo.php`, `.bak/.swp/.sql/.zip` extensions, `/xmlrpc.php`, `/wp-admin/install.php`, common PHP shell names (`shell.php`, `c99`, etc.), `?author=N` author-enum, `/cgi-bin/`, `/vendor/phpunit/`. Filter list extensible via `bps_404_noise_patterns`. Toggle: Settings → Advanced → "Filter bot-scan noise from the 404 log" (on by default, option `bps_404_filter_noise`).
2. **Auto-cleanup of resolved 404s when redirect is added** — `BPS_Redirects::add()` deletes any matching `bps_404s` row after a successful insert (regex sources skip this — they could resolve many URLs at once).
3. **Score counts only unresolved 404s** — `BPS_Score::err404_health()` now uses `NOT EXISTS (SELECT 1 FROM bps_redirects r WHERE r.source = l.url)` so already-redirected URLs don't keep penalising the score for 30 days.
4. **Auto-suggested redirect target** — new `BPS_Redirects::suggest_target($url)` extracts the last path segment, runs `similar_text()` against published-post slugs, returns best match if ≥60% similarity. Each 404 row in Redirects page now shows "Suggested: /best-guess/" and a button "Redirect to suggestion" that pre-fills both source AND target.
5. **Per-row "Dismiss" + bulk "Clear all"** for 404s — `BPS_Redirects::delete_404($id)` and `clear_404s()` plus matching POST handlers in `BPS_Page_Redirects::handle_post()` (separate nonces: `bps_redirect_delete_404`, `bps_redirect_clear404`).

### Final state
- **v2.3.1** released. Real-world expected impact: a typical site with 200 logged 404s (mostly bot scans) drops to ~10-30 real ones after the noise filter; suggested-target buttons clean the rest in minutes. Score component jumps from 0.2 (50+ 404s) to 0.9-1.0 within an hour of work. 7/7 PHP lint clean.

---

## v2.3.2 (this conversation) — Dashboard AI mode toggle

### Theme
User asked for an AI mode toggle directly on the dashboard with a token-burn warning that recommends using the Optimize button (since it schedules) for sites with many missing items.

### What's on the dashboard now
A pill-style toggle below the Optimize CTA showing the current AI state (derived from `bps_opt_features`):
- **Off** — every feature uses heuristic (free, instant)
- **On** — every feature uses AI on the daily cron
- **Mixed** — some AI, some heuristic (set via the per-feature picker; track shows half-fill so the user sees they have a non-uniform config)

### Click behavior
- **Off → On**:
  - No provider configured → modal "Add an AI provider first" with deep-link to AI Providers tab.
  - Otherwise → cost warning modal listing every count that contributes (kw, snippet, title, alt, orphans) with rough API-call estimates per category. Keyword posts also need a snippet → counted as 2 calls each. Orange `.bps-ai-cost-warn` callout: "AI calls cost tokens against your account. Larger sites can burn through a free-tier quota in one batch." Recommended path: "Use Optimize my whole site instead — it schedules AI work in batches across daily cron runs."
  - Three buttons: **[Open Optimize picker]** (primary, opens per-feature picker), **[Just enable AI for cron]** (secondary, applies all-AI map and toasts), **[Cancel]** (reverts toggle).
- **On/Mixed → Off**: just applies. No warning — switching to heuristic is always safe.

### Wiring
- State derived server-side in `BPS_Page_Dashboard::render()` so "mixed" can be detected without JS.
- Click → calls existing `/optimize/schedule` REST with the all-AI or all-heuristic features map. That endpoint already validates the provider, saves the option, registers the daily cron, and triggers the one-shot first-run.
- All counts come from the existing button data attributes — no extra requests.

### Final state
- **v2.3.2** released. Dashboard PHP + core CSS + dashboard JS touched. Lint clean.

---

## v2.3.3 → v2.4.3 (this conversation) — Orphan + 404 feature audit, hardening, and bulk actions

### Theme
Multi-pass audit of the orphan auto-link and 404 features after the 2.3.2 release surfaced shipping bugs (silent eviction, the dashboard count not dropping after Optimize, AI-mode orphans not getting auto-linked at all). Fixed end-to-end. Then the same treatment for the 404 score component — added bulk auto-fix, noise cleanup, and cron pruning. Plus a handful of cron-scheduling fixes that were biting AI-mode users.

### v2.3.3 — orphan + cron logic fixes
1. **Cron orphan loop didn't write `_bps_autolink_targets`** — only the inline `BPS_Optimizer::process_orphan_item` did. AI-mode orphans (which always go through cron) silently failed even with the toggle on. Fixed by reusing the new `BPS_Orphan_Autolink::ensure_targets()` helper from both paths.
2. **`is_noise_url` author-enum pattern was dead code** — `current_path()` strips the query string before passing to `is_noise_url`, so the `?author=N` pattern could never match. Fixed by also testing patterns against `$_SERVER['QUERY_STRING']` separately. Pattern generalised to `(?:^|&|\?)author=\d+`.
3. **`build_queue` skipped fresh-cached orphans even when autolink was just enabled** — added autolink-aware short-circuit so orphans whose suggestions are cached but whose source posts haven't been wired yet stay in the queue.
4. **Dashboard `$ai_state` mis-classified `heuristic + off` as plain "Off"** — now any non-uniform map is "Mixed" with a clearer label "features set individually (use the Optimize picker)".
5. Removed dead `$had_non_stopword` variable in `heuristic_focus_kw`.
6. **SEO title pre-filter bug** — `binaryph_ai_seo_get_posts_without_keywords($strict_all_seo=true)` checked missing kw / desc / alt but NOT seo_title. Posts that already had kw + desc + alt but lacked only seo_title were silently excluded from the optimizer's candidate list, so picking heuristic seo_title produced an empty queue. Added the seo_title NOT EXISTS clause to match `BPS_Optimizer::missing_fields()`.

### v2.3.4 — orphan auto-link toggle on dashboard
- New REST `/optimize/autolink` POST `{enabled, count}` writes `bps_orphan_autolink_enabled` + `bps_orphan_autolink_count`. Busts the orphan-list cache when the on/off state actually changes.
- Dashboard renders a second toggle below the AI mode toggle: "Orphan auto-link [On/Off]" + a 1/3 source-count picker (only visible when on). Drives the same options Settings → Schema writes.
- `bpsCore.autolink = { enabled, count }` exposed for JS.

### v2.3.5 — Related: block CSS polish
- Inline CSS in `BPS_Orphan_Autolink::maybe_print_styles`: font-size `.9em` → `.8em`, `text-align: left` added (some themes center the surrounding container and were inheriting onto the block), line-height tightened.

### v2.3.6 — orphan feature consistency pass
- `BPS_Orphan_Autolink::source_post_types()` filterable, defaults to `['post']` only — static pages no longer host Related: blocks pointing at random orphans by default. Filterable via `bps_orphan_autolink_source_post_types` for sites that want pages back in.
- `BPS_Orphan_Autolink::per_source_cap()` introduced and tied to the picker setting (1 or 3) initially — later decoupled in v2.4.0 (see below). At this point: picking 3 capped each source post at 3 inbound autolinks.
- Render-time guards in `maybe_append_related` + `maybe_print_styles`: skip when post type isn't in `source_post_types()`. Existing meta on now-excluded post types stays in the DB (no destructive cleanup), just doesn't render.
- Orphan-detector autolink merge now restricts to `source_post_types()` and clamps target lists to `per_source_cap()` so detector and render agree.
- `similar_pages` and `internal_link_suggestions` queries now limited to `source_post_types()`.
- `BPS_Orphan_Detector::find_all` merges `_bps_autolink_targets` into the linked set so auto-linked orphans actually drop out of the dashboard count. Cache busts wired on add_target / remove_target / autolink toggle.

### v2.3.7 — final-release audit findings (cron + UI)
1. **`/optimize/schedule` clobbered `bps_max_api_calls_per_run` to 10 every call** — JS quick-toggle calls this endpoint without `items_per_day`, and `(int) null ?: 10` was silently overwriting users' customised cap. Now only updates when the param is explicitly provided.
2. **Noindex respect at render** — `maybe_append_related` skips targets whose adapter returns `get_noindex(...)` truthy. Avoids wasting link equity on pages the user has explicitly hidden.
3. **Cron AI budget reservation for orphan AI** — when orphans=AI, cron now reserves `max(2, ⌈cap × 0.25⌉, ≤ ⌊cap/2⌋)` calls before the post-features loop. Without this, all AI calls were consumed by post features and orphan AI never ran.
4. **Picker labels relabelled** — "Heuristic cap" / "AI calls / day" with hover tooltips clarifying that AI cap is calls per cron run, not items per day (one post can use multiple calls).

### v2.3.8 — Tools-page bulk wire-up
- New `BPS_Orphan_Detector::wire_step($offset, $size)` server-side batch processor. Operates on the FULL orphan list (not just the 500 visible in the table).
- New REST `/orphans/wire-step` POST.
- New `BPS_Orphan_Autolink::suspend_cache_bust($flag)` static flag — bulk callers suspend per-add cache invalidation so a 25-orphan run doesn't trigger 25 full orphan-list rebuilds. Bust ONCE at end of bulk.
- Tools → Orphans tab gets "Wire up all N orphans" primary button (visible only when autolink is on). JS polls until done, shows progress bar + counters (`with suggestions / no matches / wired`).
- Per-row `/orphans/similar/{id}` and `/ai/internal-links/{id}` endpoints also write autolinks now (single-row triage drops the orphan count too).
- `/orphans/similar/bulk` REST endpoint refactored to also cache suggestions and write autolinks.
- Inline `BPS_Optimizer::step()` and cron orphan loop also flip `suspend_cache_bust` for the duration of their batch.

### v2.3.9 — orphan wire-up coverage fix (reject-when-full)
- **`BPS_Orphan_Autolink::add_target` now returns `bool` and rejects when the source is at cap** instead of evicting via `array_slice(-N)`. The eviction approach silently dropped earlier orphans as later ones came in (popular sources hit cap=3 fast, then every new add evicted whoever was added first). After the fix, a source post stays full at cap and the caller falls through to the next-best candidate.
- `ensure_targets` now iterates the FULL suggestions list (not just top-N), counting successful adds until N wired or list exhausted.
- Heuristic candidate pool bumped from 5 → 25 across all autolink callers (`process_orphan_item`, cron orphan loop, wire_step, single + bulk REST endpoints) so there's enough fallback room when popular sources fill up.

### v2.4.0 — orphan autolink coverage math: decoupled cap (BREAKING for picker semantics)
- **`per_source_cap()` decoupled from `targets_per_orphan()`** — now returns `MAX_PER_POST` (5) regardless of the picker setting. The two control different axes:
  - Picker (1 or 3) = how many source posts each orphan distributes to.
  - Cap (5 fixed) = anti-spam ceiling on how many orphans a source post can list.
- Why: with cap == picker (the v2.3.6 design), the math ceiling was `unique_sources ≥ orphans × picker / cap = orphans`. On topic-clustered sites where the heuristic surfaces ~100-200 unique source posts across 1000+ orphans, only ~100-200 orphans would ever wire. Decoupled, math becomes `unique_sources × 5 ≥ orphans × picker`, which actually allows full coverage with picker=1.
- Candidate pool further bumped: 25 → 50 per orphan; `similar_pages` `scan_limit` ceiling 50 → 200.
- Picker tooltips on dashboard updated to spell out the dual-axis behavior.

### v2.4.1 — bulk 404 maintenance
- New `BPS_Redirects::auto_fix_step($offset, $size)` — server-side batch. Walks 404 log ordered by hits DESC, calls `suggest_target` on each URL, creates 301 if similarity ≥ 60%. Snapshots queue in a 30-min transient on `offset === 0`. Caps total queue at 5000.
- New `BPS_Redirects::clean_noise()` — sweeps existing 404 log against `is_noise_url` patterns, deletes matches in one DELETE. Bounded at 5000 scan rows.
- New `BPS_Redirects::bulk_404_summary()` — totals + noise count for the page-render labels.
- New REST endpoints `/redirects/auto-fix-step` POST + `/redirects/clean-noise` POST.
- Redirects page: new bulk-action row above the 404 list with "Auto-fix all N 404s" (primary) + "Clear N noise entries" (visible only when noise count > 0). Inline status updates during run; toast + reload on completion.
- Picker label rewording to "AI calls / day" (already in v2.3.7).

### v2.4.2 — bulk 404 hardening
1. **Self-redirect guard in `auto_fix_step`** — `suggest_target('/foo/')` returns `/foo/` when a published post with slug `foo` is intermittently 404ing (theme bug, permalink flush needed). That'd create a 301 from `/foo/` to `/foo/` — runtime catches the loop silently but the row pollutes the table and the user thinks "fixed" while page stays 404. Added `same_path()` helper that normalizes both sides (case-insensitive, slash-forced); skips when equal. Counted as `no_match`.
2. **`bulk_404_summary` cached** with a 5-min transient + busts on operations. Was running ~14 patterns × up to 5000 URLs = ~70k regex ops on every redirects-page load.
3. **Defensive empty-URL skip** in `auto_fix_step` — a malformed log row with empty url would normalize to `/` inside `add()` and create a phantom homepage redirect.
4. **`add()` hardened** — was reading `$wpdb->insert_id` without checking `$wpdb->insert` return value. On insert failure (race), insert_id retained its previous value, so `add()` returned a stale truthy ID, the caller counted "fixed", AND the matching 404 row was deleted. Now reads the insert return and short-circuits with `false` on failure. Defensive across ALL callers (admin form, CSV import, migrator), not just bulk.

### v2.4.3 — final 404 release pass
1. **`BPS_Redirects::prune_old_404s()`** — new method, called from `bps_run_automation` after the audit prunes. Deletes rows with `last_hit > 90 days` (filterable via `bps_404_prune_days`), batch-capped at 5000 (filterable via `bps_404_prune_batch`). Without this the 404 log grew unbounded — score component already only counted last-30-day rows, so anything older was just storage bloat.
2. **Summary cache busts wired up everywhere a 404 row gets mutated**:
   - `add()` when redirect creation deletes a matching 404 row.
   - `delete_404($id)` per-row Dismiss.
   - `clear_404s()` "Clear all" + busts score cache too.
   - `prune_old_404s()` cron prune.
   - `auto_fix_step()` and `clean_noise()` already busted.

### Critical fixes that must not be re-introduced (extends v2.0–v2.3.2 list)
48. **Cron `bps_run_automation` orphan loop must call `BPS_Orphan_Autolink::ensure_targets()`** — without this, AI-mode orphans (which always route through cron) silently fail even with `bps_orphan_autolink_enabled='1'`. Reverting to "cache suggestions, return" reintroduces the v2.3.0–v2.3.2 silent-failure bug.
49. **`is_noise_url` must test patterns against `$_SERVER['QUERY_STRING']`** in addition to the path — `current_path()` strips the query, so query-only patterns like `?author=N` are dead code without this.
50. **`binaryph_ai_seo_get_posts_without_keywords($strict_all_seo=true)` must include the seo_title NOT EXISTS clause** — `BPS_Optimizer::missing_fields()` checks 4 fields; the SQL pre-filter must check the same set or posts missing only seo_title get silently excluded.
51. **`BPS_Orphan_Autolink::add_target` MUST reject when source at cap, NOT evict** — eviction silently dropped earlier orphans on popular sources. Reverting to `array_slice(-cap)` reintroduces the silent-orphan-loss bug.
52. **`BPS_Orphan_Autolink::ensure_targets` MUST iterate the FULL suggestions list**, not slice top-N — without fallthrough, every orphan gets capped at the same popular sources and most never wire on topic-clustered sites.
53. **`BPS_Orphan_Autolink::per_source_cap()` is decoupled from picker since v2.4.0** — returns `MAX_PER_POST` (5). Tying it back to `targets_per_orphan()` makes math impossible (`unique_sources >= orphans` requirement that no real site satisfies).
54. **`BPS_Orphan_Autolink::source_post_types()` defaults to `['post']`** since v2.3.6 — pages don't host Related: blocks unless `bps_orphan_autolink_source_post_types` filter explicitly opts them in.
55. **`BPS_Orphan_Autolink::suspend_cache_bust(true)` MUST be flipped during bulk runs** (`wire_step`, cron orphan loop, `BPS_Optimizer::step()`, `/orphans/similar/bulk`) — without it, each `add_target` triggers a full orphan-list rebuild, turning a 25-orphan batch into 25× full 5000-post scans. Bust ONCE at end of batch.
56. **`/optimize/schedule` MUST only update `bps_max_api_calls_per_run` when the request explicitly provides `items_per_day`** — the dashboard AI quick-toggle calls this without the param, and the previous `(int) null ?: 10` clobbered users' customised caps to 10 on every flip.
57. **Cron must reserve a slice of the AI budget for orphans when orphans=AI** — without the reservation (`max(2, ⌈cap × 0.25⌉, capped at ⌊cap/2⌋)`), post features consume the full cap and orphan AI starves indefinitely.
58. **`BPS_Redirects::add()` MUST check `$wpdb->insert` return value before reading `$wpdb->insert_id`** — failed inserts leave insert_id at the previous successful value, which causes `add()` to falsely report success AND falsely delete the matching 404 row.
59. **`BPS_Redirects::auto_fix_step` MUST skip when `same_path($url, $suggested)`** — `suggest_target` can return the source URL when an existing post's slug matches the 404 needle (post intermittently 404ing). Self-redirect rows pollute the table.
60. **`BPS_Redirects::prune_old_404s` MUST run from cron** to bound the 404 log size — score component only counts last-30-day rows, but the table grows forever without pruning. Default 90-day window via `bps_404_prune_days` filter.
61. **Every `bps_404s` mutation must `delete_transient('bps_404_summary')`** — `add()` (when 404 row deleted), `delete_404()`, `clear_404s()`, `prune_old_404s()`, `auto_fix_step()` (when bulk done), `clean_noise()`. Without consistent invalidation, the redirects page's bulk-button labels lie about counts for up to 5 minutes.

### Final state
- **v2.4.3** ready for release. Plugin name everywhere is "BinaryPH AI SEO".
- 60 PHP + 9 JS files. All lint-clean (`php -l` + `node --check`).
- Three big feature batches: orphan auto-link is now genuinely effective (decoupled cap + reject-when-full + larger candidate pool covers the math); 404 maintenance is bulk-actionable and bounded (auto-fix-all + clean-noise + cron pruning); cron scheduling no longer silently clobbers user caps and reserves orphan AI budget.

---

## v2.5.0 (this conversation) — Reverted bulk 404 auto-fix to match Rank Math safety pattern

### Why
User raised a legitimate concern about the bulk 404 auto-fix (introduced v2.4.1, hardened v2.4.2): even with the 60% similarity threshold + same_path self-redirect guard + add() insert check, a wrong match would create a 301 cached for ~1 year by browsers / crawlers — hard to roll back at scale. Rank Math, Yoast, and the Redirection plugin all use the same pattern: log 404s, let user manually review and confirm each redirect with the source pre-filled. Visible suggestions (with similarity scores) are an upgrade over their workflow, but auto-creating without per-row confirmation is a bridge too far for shared-hosting users who can't easily diagnose redirect-related traffic anomalies.

### What was removed
- `BPS_Redirects::auto_fix_step()` method.
- `BPS_Redirects::same_path()` private helper (only used by auto_fix_step).
- REST endpoint `/redirects/auto-fix-step`.
- "Auto-fix all N 404s" primary button on Redirects page + its JS handler + the `bps_404_autofix_queue` transient handling.

### What was kept (deliberately)
- **Per-row "Redirect to suggestion" button** — pre-fills source AND suggested target in the manual form, user reviews/edits and clicks Add. Safest workflow: every redirect is an explicit decision.
- **Per-row "Dismiss"** — delete a single 404 row.
- **"Clear all" form button** — truncate the entire 404 log (no redirects created, just deletes).
- **"Clear N noise entries" bulk button** — sweeps `is_noise_url` patterns and deletes matches. Only deletes; never creates redirects, so zero risk of misrouting legit traffic.
- **Cron prune** (`prune_old_404s`, default 90 days) — table-size bound.
- **`BPS_Redirects::add()` $wpdb->insert defensive check** — pre-existing-style fix that hardens all callers (admin form, CSV import, migrator), not specific to auto-fix.
- **Summary cache busts on every mutation** — same as v2.4.3.
- **Suggestion display** in per-row UI — `BPS_Redirects::suggest_target()` still computes ≥60% similar_text matches and surfaces them as "Suggested: /path/" with a one-click pre-fill button.

### Critical fixes that must not be re-introduced (revisions)
- **#59** (auto_fix_step `same_path` skip) — REMOVED. The function it referenced no longer exists.
- All other rules through #61 stand.

### Final state
- **v2.5.0** ready for release. The 404 feature is now: noise-filtered ingest → atomic upsert → bounded growth via cron prune → score component → display with optional suggestions → per-row review-and-confirm workflow → noise-only bulk cleanup. No bulk auto-creation of redirects anywhere.
- 3 PHP files touched (`class-bps-redirects.php`, `class-bps-rest.php`, `class-bps-page-redirects.php`). Lint clean.

---

## v2.5.1 (this conversation) — "Review redirects" preview-and-confirm modal

### Why
v2.5.0 left the per-row "Redirect to suggestion" workflow as the only path, which is safe but tedious for 50+ 404s (50 click-review-confirm cycles across separate page sections). Adding back a bulk path WITHOUT auto-creation: server computes suggestions, modal lists them all with checkboxes, user reviews + selects, only the checked rows are applied. Same safety guarantee as per-row (every redirect is an explicit user decision), much better ergonomics at scale.

### New methods
- `BPS_Redirects::suggest_target_with_score($url)` — same matching logic as `suggest_target` but returns `['target', 'score']` so the modal can sort by confidence. `suggest_target` now wraps this for back-compat.
- `BPS_Redirects::same_path($a, $b)` — public helper (was private and removed in v2.5.0). Normalizes both paths (case-insensitive, slash-forced) and detects self-redirects. Used by both the preview filter and the apply-selected defensive re-check.
- `BPS_Redirects::preview_suggestions()` — iterates the entire 404 log (capped at 200, filterable via `bps_redirect_preview_cap`), excludes URLs already redirected (NOT EXISTS join), excludes self-redirect candidates, returns `[{id,url,target,similarity,hits}]` sorted by similarity DESC, hits DESC.
- `BPS_Redirects::apply_selected_redirects($items, $code)` — server-side validation (non-empty source/target, no self-redirect, code in ALLOWED_CODES), then per-item `add()` with create/skipped/error tally. Busts summary + score caches when at least one redirect was created.

### REST endpoints
- `GET /redirects/preview-suggestions` — returns the preview list.
- `POST /redirects/apply-selected` — accepts `{items: [{source, target}, ...], code: 301|302}`.

### UI
Redirects page → "Recent 404s" card → primary button **"Review redirects"** (visible whenever there are any 404s). Click opens a modal:

- **Sticky header** with Select all toggle, sort radio (Confidence / Hits), 301/302 type radio.
- **Legend line** explaining the pre-check rule: ≥85% pre-checked, 60–74% needs explicit review.
- **Scrollable list** of suggestion rows:
  - Checkbox (auto-checked when similarity ≥ 75%).
  - Source URL → Target path (monospace, ellipsis-truncated).
  - Confidence pill: green (≥85%, "✓"), yellow (75–84%, "·"), red (60–74%, "⚠").
  - Hit count if > 1.
- **Footer** with running selected-count and Cancel / Apply N redirects buttons. Apply button label updates live with the count and shows "Apply" when 0 selected.
- **Sort toggle** re-renders the list while preserving currently-checked row indices (so clicking sort doesn't lose your selections).
- **Mobile layout**: rows collapse to 2 stacked lines (source above target) at ≤600px width.

CSS-wise: new `.bps-review-modal` block in core.css with sticky header, scrollable list (max-height 50vh), grid-based rows for column alignment, color-coded confidence pills, mobile breakpoint.

### Safety
- Every redirect is opt-in via the checkbox — no implicit creation.
- Server-side re-validation: source/target non-empty, `same_path` self-redirect check, code restricted to ALLOWED_CODES.
- 302 option exposed in the UI (default 301) so cautious users can roll out as easy-to-rollback temporary redirects, then upgrade specific rows to 301 after verification.
- Preview excludes URLs that already have a redirect, so re-running the bulk doesn't create duplicates (and `add()` would catch them anyway via UNIQUE source).
- `apply_selected_redirects` returns `{created, skipped, errors}` so the toast can report partial-failure honestly.

### Critical fixes that must not be re-introduced (extends v2.4.x list)
62. **`BPS_Redirects::apply_selected_redirects` MUST re-validate `same_path` server-side** — JS preview already filters self-redirects, but a hand-crafted POST could submit them. Defensive check belongs in the server method, not just the modal.
63. **The review modal MUST default-uncheck rows below 75% similarity** — between 60% and 75% is "shown but unchecked, opt-in only". Pre-checking everything at the 60% display threshold would put us back in v2.4.1 auto-fix-all territory.
64. **`preview_suggestions` MUST filter URLs that already have redirects** (`NOT EXISTS` against `bps_redirects.source`) — without this, re-running the bulk shows already-fixed URLs and `add()` rejects them as skipped, polluting the modal with no-ops.

### Final state
- **v2.5.1** ready for release. 4 files touched: `class-bps-redirects.php`, `class-bps-rest.php`, `class-bps-page-redirects.php`, `assets/css/core.css`. Lint clean.
- 60 PHP + 9 JS files. Version bumped (CSS asset changed → cache-bust required).

---

## Default option additions (v2.3.0+ seeded by `BPS_Presets::default_settings()`)
- `bps_opt_features` → `['focus_kw'=>'heuristic','meta_desc'=>'heuristic','seo_title'=>'heuristic','image_alt'=>'heuristic','orphans'=>'heuristic']`
- `bps_orphan_autolink_enabled` → `'0'` (opt-in)
- `bps_orphan_autolink_count` → `1`
- `bps_404_filter_noise` → `'1'`

## Filterable thresholds added in v2.3.3+
- `bps_404_noise_patterns` — regex patterns applied to path AND query string for ingest filtering.
- `bps_404_prune_days` (default 90) — age cutoff for cron-driven 404 pruning.
- `bps_404_prune_batch` (default 5000) — per-call DELETE limit so a backlog can't lock the table.
- `bps_orphan_autolink_source_post_types` (default `['post']`) — which post types host Related: blocks.
- `bps_orphan_autolink_label` — the "Related:" prefix string.
- `bps_orphan_scan_cap` (default 5000) — orphan-detector candidate pool size.

## File count at last review
- 60 PHP files
- 9 JS files
- All lint-clean as of v2.4.3 release
