<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Cli;

use Numinix\SeekmodoWordPress\Bootstrap;
use Numinix\SeekmodoWordPress\Commerce\Indexer as CommerceIndexer;
use Numinix\SeekmodoWordPress\Config\DomainLock;
use Numinix\SeekmodoWordPress\Config\Options;
use Numinix\SeekmodoWordPress\Config\RemoteConfig;
use Numinix\SeekmodoWordPress\Content\Indexer as ContentIndexer;
use Numinix\SeekmodoWordPress\Gateway\CircuitBreaker;
use Numinix\SeekmodoWordPress\Mode\ModeFsm;
use Numinix\SeekmodoWordPress\Mode\ShadowDiffLogger;

/**
 * `wp seekmodo <subcommand>` family.
 *
 * Subcommands shipped per sprint:
 *   - connect              (Sprint 1)
 *   - index                (Sprint 1)
 *   - health               (Sprint 1)
 *   - shadow-report        (Sprint 2)
 *   - refresh-config       (Sprint 2)
 *   - cutover-readiness    (Sprint 5)
 *   - diagnose             (Sprint 5)
 *
 * The class is registered as a single command namespace via
 * `WP_CLI::add_command('seekmodo', self::class)` — every public
 * non-static method becomes `wp seekmodo <method>`.
 */
final class Commands
{
    public static function register(): void
    {
        if (!class_exists('WP_CLI', false)) {
            return;
        }
        \WP_CLI::add_command('seekmodo', self::class);
    }

    // -------------------------------------------------------------------
    // v0.5.3 — `__invoke` removed. WP-CLI treats a class with `__invoke`
    // as an atomic single-handler command and ignores the public-method
    // subcommand convention, which silently broke `wp seekmodo <sub>`
    // for every subcommand below — `wp seekmodo health` invoked
    // `__invoke` (printing the version stub) instead of the `health()`
    // method, and `wp help seekmodo` listed no subcommands at all.
    // Confirmed via reflection on POCO prod after the v0.5.3 push: the
    // class definitely had every method, the wp-cli surface just
    // wasn't exposing them. Without `__invoke`, `wp seekmodo` (no args)
    // now prints the standard WP-CLI usage banner that auto-lists
    // every public method as a subcommand — exactly what we wanted.
    // -------------------------------------------------------------------

    /**
     * Pair this WordPress install with a Seekmodo tenant.
     *
     * Headless / CI flavour of the browser-based "Connect to
     * Seekmodo" flow. Writes the pair directly to wp_options. The
     * browser flow ({@see \Numinix\SeekmodoWordPress\Admin\ConnectFlow})
     * is the right path for hand-pairing a single install -- it
     * walks the operator through tenant selection on
     * seekmodo.com/connect and posts a signed pairing JWT back. The
     * CLI is for cases where:
     *
     *   - The host doesn't have a browser-reachable WP admin
     *     (headless deploy, behind a VPN, etc.)
     *   - You're scripting a fleet pair from a config-management
     *     system that already knows the tenant + secret.
     *
     * v0.3.2 dropped the `--token=<claim_token>` flavour. It relied
     * on a `/v1/connect/verify` round-trip the gateway never
     * implemented; users were silently failing every CLI pair that
     * went through that path. Use `--tenant=` `--secret=` directly,
     * or run the browser flow.
     *
     * Idempotent -- re-running rotates the credentials in place.
     *
     * ## OPTIONS
     *
     * --tenant=<tenant_id>
     * : The tenant ID issued by seekmodo.com.
     *
     * --secret=<shared_secret>
     * : The HMAC-SHA256 shared secret for the tenant.
     *
     * ## EXAMPLES
     *
     *     wp seekmodo connect --tenant=trek_ca --secret=hex...32bytes
     *
     * @param array<int,string>    $args
     * @param array<string,string> $assoc
     */
    public function connect(array $args, array $assoc): void
    {
        $tenant = isset($assoc['tenant']) ? (string) $assoc['tenant'] : '';
        $secret = isset($assoc['secret']) ? (string) $assoc['secret'] : '';

        if ($tenant === '' || $secret === '') {
            \WP_CLI::error('Both --tenant=<...> and --secret=<...> are required. For interactive pairing, use the "Connect to Seekmodo" button under Settings > Seekmodo Search instead.');
        }

        Options::persistPair($tenant, $secret);
        \WP_CLI::success(sprintf('Paired tenant `%s` (secret: ••••%s) on host `%s`.', $tenant, substr($secret, -4), DomainLock::currentHost()));
    }

    /**
     * Run the indexer for one (or every enabled) vertical.
     *
     * Pre-v0.5.3 this command only invoked the *content* vertical
     * indexer — operators wanting a commerce reindex had to drop
     * into `wp eval '\Numinix\SeekmodoWordPress\Commerce\Indexer::reindexFull();'`.
     * v0.5.3 wires up the commerce path and adds an explicit
     * `--vertical=` flag for surgical reindexes.
     *
     * Default vertical is `all`, which runs every gateway-enabled
     * vertical for this tenant (`Options::enabledVerticals()`).
     * Verticals the gateway hasn't enabled — or whose runtime
     * dependency is missing (e.g. WooCommerce inactive) — are
     * skipped silently with an info message; the command still
     * exits with a success code as long as at least one vertical
     * ran or no error condition was hit.
     *
     * ## OPTIONS
     *
     * [--full]
     * : Reindex everything from scratch.
     *
     * [--delta]
     * : Pick up only items marked dirty since the last tick.
     *
     * [--vertical=<vertical>]
     * : Restrict to a single vertical: `content`, `commerce`, or `all`.
     * ---
     * default: all
     * options:
     *   - content
     *   - commerce
     *   - all
     * ---
     *
     * [--status]
     * : Print the indexer cursor + last-tick timestamp and exit.
     *
     * ## EXAMPLES
     *
     *     wp seekmodo index --full                       # All enabled verticals
     *     wp seekmodo index --full --vertical=commerce   # Just products
     *     wp seekmodo index --delta --vertical=content   # Just dirty posts/pages
     *     wp seekmodo index --status
     *
     * @param array<int,string>    $args
     * @param array<string,string> $assoc
     */
    public function index(array $args, array $assoc): void
    {
        if (isset($assoc['status'])) {
            $cursor = get_option(Options::OPT_INDEXER_CURSOR, null);
            \WP_CLI::log('cursor: ' . wp_json_encode($cursor));

            return;
        }

        $vertical = strtolower(trim((string) ($assoc['vertical'] ?? 'all')));
        if (!in_array($vertical, ['all', 'content', 'commerce'], true)) {
            \WP_CLI::error(sprintf(
                "Unknown --vertical=%s. Valid values: content, commerce, all.",
                $vertical
            ));
        }
        $full = isset($assoc['full']);

        // Write-side host-lock gate. Don't waste time walking 3,000+
        // products only to drop every batch at the bulk-upsert
        // helper's host-lock check. CLI-only, since the cron tick
        // gates itself in `Indexer::tick()`. Read-side override
        // filters apply transparently via DomainLock::canIndex().
        if (function_exists('numinix_seekmodo_can_index') && !numinix_seekmodo_can_index()) {
            \WP_CLI::error(sprintf(
                "Refusing to index from host `%s`: write-side host-lock gate failed (locked=`%s`, looksLikeNonProd=%s, paired=%s, mode=%s). " .
                "If this install is INTENTIONALLY paired to a non-prod tenant, opt back in via:\n" .
                "    add_filter('seekmodo_allow_nonprod_indexing', '__return_true');\n" .
                "in a mu-plugin and re-run.",
                DomainLock::currentHost(),
                DomainLock::lockedHost() ?: '(unset)',
                DomainLock::looksLikeNonProd() ? 'yes' : 'no',
                Options::isPaired() ? 'yes' : 'no',
                ModeFsm::current()
            ));
        }

        // Resolve which verticals to run. `Options::enabledVerticals()`
        // is the per-tenant gateway-driven set (commerce-only tenants
        // shouldn't push content docs); the WC active probe is the
        // local runtime dep. Both must agree, mirroring the
        // Bootstrap-time `Bootstrap::boot()` registration logic so
        // CLI behavior matches what auto-cron does on the same site.
        $enabled  = Options::enabledVerticals();
        $targets  = ($vertical === 'all') ? ['content', 'commerce'] : [$vertical];

        $totals      = ['indexed' => 0, 'failed' => 0];
        $ran         = [];
        $skipped     = [];

        foreach ($targets as $v) {
            if ($v === 'content') {
                if (!isset($enabled['content'])) {
                    $skipped[] = "content (gateway hasn't enabled this vertical for the tenant)";
                    continue;
                }
                $r = $full ? ContentIndexer::reindexFull() : ContentIndexer::reindexDelta();
                $totals['indexed'] += (int) ($r['indexed'] ?? 0);
                $totals['failed']  += (int) ($r['failed'] ?? 0);
                $ran[]              = sprintf('content[indexed=%d failed=%d]', (int) ($r['indexed'] ?? 0), (int) ($r['failed'] ?? 0));
                continue;
            }
            if ($v === 'commerce') {
                if (!isset($enabled['commerce'])) {
                    $skipped[] = "commerce (gateway hasn't enabled this vertical for the tenant)";
                    continue;
                }
                if (!Bootstrap::wooCommerceActive()) {
                    $skipped[] = 'commerce (WooCommerce is not active on this site)';
                    continue;
                }
                $r = $full ? CommerceIndexer::reindexFull() : CommerceIndexer::reindexDelta();
                $totals['indexed'] += (int) ($r['indexed'] ?? 0);
                $totals['failed']  += (int) ($r['failed'] ?? 0);
                $ran[]              = sprintf('commerce[indexed=%d failed=%d]', (int) ($r['indexed'] ?? 0), (int) ($r['failed'] ?? 0));
                continue;
            }
        }

        foreach ($skipped as $msg) {
            \WP_CLI::log("skipped: {$msg}");
        }

        if (empty($ran)) {
            // No vertical ran. If the operator specifically asked for
            // a single vertical that got skipped, surface it as a
            // warning so a CI gate notices. `--vertical=all` on a
            // tenant where neither vertical is enabled is unlikely
            // (every tenant has at least one) but still warrants the
            // same warning.
            \WP_CLI::warning('No verticals were eligible to run. See `skipped:` lines above.');

            return;
        }

        \WP_CLI::success(sprintf(
            'indexed=%d failed=%d  (%s)',
            $totals['indexed'],
            $totals['failed'],
            implode(' + ', $ran)
        ));
    }

    /**
     * Print health + connection diagnostics.
     *
     * ## EXAMPLES
     *
     *     wp seekmodo health
     */
    public function health(array $args, array $assoc): void
    {
        \WP_CLI::log('paired:             ' . (Options::isPaired() ? 'yes' : 'no'));
        \WP_CLI::log('tenant_id:          ' . (Options::tenantId() ?: '(none)'));
        \WP_CLI::log('mode:               ' . ModeFsm::current());
        \WP_CLI::log('breaker:            ' . CircuitBreaker::state());
        \WP_CLI::log('current_host:       ' . DomainLock::currentHost());
        \WP_CLI::log('locked_host:        ' . (DomainLock::lockedHost() ?: '(unset)'));
        \WP_CLI::log('host_lock_match:    ' . (DomainLock::matches() ? 'yes' : 'no'));
        // v0.5.3 — surface the stricter write-side gate so an
        // operator can see at a glance whether THIS install will be
        // pushing docs to the gateway, vs. just reading from it.
        // Diverges from `host_lock_match` only on non-prod hosts
        // with a self-referential lock — the exact regression PM-303
        // chased — and on installs that flipped the new
        // `seekmodo_allow_nonprod_indexing` opt-in.
        \WP_CLI::log('can_index:          ' . (DomainLock::canIndex() ? 'yes' : 'no'));
        \WP_CLI::log('looks_like_nonprod: ' . (DomainLock::looksLikeNonProd() ? 'yes' : 'no'));
        \WP_CLI::log('gateway_url:        ' . Options::gatewayUrl());
        \WP_CLI::log('schema_version:     ' . (string) get_option(Options::OPT_SCHEMA_VERSION, ''));
    }

    /**
     * Force a fresh tenant.snapshot pull instead of waiting for the
     * 5-min cron tick.
     *
     * ## EXAMPLES
     *
     *     wp seekmodo refresh-config
     */
    public function refresh_config(array $args, array $assoc): void
    {
        RemoteConfig::pull();
        \WP_CLI::success('tenant.snapshot pulled.');
    }

    /**
     * Print the most recent shadow-mode divergences.
     *
     * Sprint 2 fills in the rolling 7d aggregator; Sprint 1 stub
     * simply tails the structured log file.
     *
     * ## OPTIONS
     *
     * [--window=<duration>]
     * : Aggregation window (e.g. 24h, 7d). Defaults to 24h.
     *
     * [--tail=<n>]
     * : Print only the last N lines from the shadow log.
     *
     * ## EXAMPLES
     *
     *     wp seekmodo shadow-report --window=7d
     *     wp seekmodo shadow-report --tail=20
     *
     * @param array<int,string>    $args
     * @param array<string,string> $assoc
     */
    public function shadow_report(array $args, array $assoc): void
    {
        if (isset($assoc['tail'])) {
            $tail = max(1, (int) $assoc['tail']);
            $log  = ShadowDiffLogger::logPath();
            if (!is_file($log)) {
                \WP_CLI::log('(no shadow log yet — set MODE=shadow and let the auto-promoter collect samples.)');

                return;
            }
            $lines = @file($log) ?: [];
            foreach (array_slice($lines, -$tail) as $line) {
                \WP_CLI::log(rtrim($line, "\r\n"));
            }

            return;
        }

        $window_sec = self::parseDuration($assoc['window'] ?? '24h', 86400);
        $sum        = ShadowDiffLogger::summarize($window_sec);
        \WP_CLI::log(sprintf(
            'window=%ds  lines=%d  jaccard_mean=%.3f  top1=%.1f%%  top3=%.1f%%  p95_ms=%d',
            $window_sec,
            (int) $sum['lines'],
            (float) $sum['jaccard_mean'],
            ((float) $sum['top1_match_rate']) * 100,
            ((float) $sum['top3_match_rate']) * 100,
            (int) $sum['p95_ms']
        ));
    }

    /**
     * Parse human durations like "24h", "7d", "30m", "600s".
     */
    private static function parseDuration(string $s, int $default): int
    {
        if (!preg_match('/^(\d+)([smhd])$/', strtolower(trim($s)), $m)) {
            return $default;
        }
        $n   = (int) $m[1];
        $mult = ['s' => 1, 'm' => 60, 'h' => 3600, 'd' => 86400][$m[2]] ?? 1;

        return $n * $mult;
    }

    /**
     * Cutover-readiness gate. Prints PASS/FAIL on the rolling 5xx /
     * p95 / fallback thresholds. Sprint 5 wires this to the live
     * AutoPromoter; Sprint 1 stub just reports current state.
     *
     * ## EXAMPLES
     *
     *     wp seekmodo cutover-readiness
     */
    public function cutover_readiness(array $args, array $assoc): void
    {
        $checks   = [];
        $window   = self::parseDuration($assoc['window'] ?? '7d', 7 * 86400);

        // 1. Are we paired?
        $paired = Options::isPaired();
        $checks[] = ['paired',          $paired,    'Run `wp seekmodo connect` to pair with a tenant first.'];

        // 2. Domain lock matches?
        $domain_ok = \Numinix\SeekmodoWordPress\Config\DomainLock::matches();
        $checks[] = ['domain_lock',     $domain_ok, 'Storefront host does not match the tenant lock. Fix on admin.seekmodo.com.'];

        // 3. Breaker closed?
        $breaker  = CircuitBreaker::state();
        $checks[] = ['breaker_closed',  $breaker === CircuitBreaker::STATE_CLOSED, "Breaker is `{$breaker}`. Wait for it to close before cutting over."];

        // 4. Auto-promoter gauges within promote thresholds. Pull the
        // *effective* thresholds (post-filter) so admins overriding via
        // `seekmodo_autopromote_thresholds` see a consistent gate.
        $m       = \Numinix\SeekmodoWordPress\Mode\AutoPromoter::metrics();
        $promote = \Numinix\SeekmodoWordPress\Mode\AutoPromoter::thresholds();
        $p95_ok  = ((int) ($m['p95_ms'] ?? 9999)) <= (int) $promote['promote_p95_ms'];
        $r5xx_ok = ((float) ($m['r5xx'] ?? 1.0))  <= (float) $promote['promote_5xx_rate'];
        $rfb_ok  = ((float) ($m['rfb']  ?? 1.0))  <= (float) $promote['promote_fallback'];
        $checks[] = ['p95_ok',          $p95_ok,  sprintf('p95 latency %d ms > 300 ms threshold', (int) $m['p95_ms'])];
        $checks[] = ['5xx_rate_ok',     $r5xx_ok, sprintf('5xx rate %.1f%% > 1%% threshold', ((float) $m['r5xx']) * 100)];
        $checks[] = ['fallback_rate_ok',$rfb_ok,  sprintf('fallback rate %.1f%% > 2%% threshold', ((float) $m['rfb']) * 100)];
        $checks[] = ['min_samples',     (int) $m['samples'] >= 20, 'Fewer than 20 observations in the last 5 min. Let traffic accumulate first.'];

        // 5. Shadow-diff signal: jaccard mean and top-1 match rate over the window.
        $diff    = ShadowDiffLogger::summarize($window);
        $diff_ok = ((float) $diff['jaccard_mean']) >= 0.4 || (int) $diff['lines'] === 0;
        $checks[] = ['shadow_diff_ok',  $diff_ok, sprintf('Shadow jaccard mean %.2f below 0.40 threshold across %d obs.', (float) $diff['jaccard_mean'], (int) $diff['lines'])];

        // Render.
        $fails = 0;
        foreach ($checks as $c) {
            [$name, $ok, $msg] = $c;
            $status = $ok ? '✓ pass' : '✗ FAIL';
            \WP_CLI::log(sprintf('  %-22s %s', $name, $status));
            if (!$ok) {
                \WP_CLI::log('       ' . $msg);
                $fails++;
            }
        }

        if ($fails === 0) {
            \WP_CLI::success(sprintf('All %d gates pass. Safe to flip mode -> active from admin.seekmodo.com.', count($checks)));

            return;
        }
        \WP_CLI::warning(sprintf('%d of %d gates failed. Hold cutover.', $fails, count($checks)));
    }

    /**
     * Dump a diagnostic bundle for support.
     *
     * ## EXAMPLES
     *
     *     wp seekmodo diagnose
     */
    public function diagnose(array $args, array $assoc): void
    {
        $bundle = [
            'version'             => SEEKMODO_WP_VERSION,
            'paired'              => Options::isPaired(),
            'tenant_id'           => Options::tenantId(),
            'mode'                => ModeFsm::current(),
            'breaker_state'       => CircuitBreaker::state(),
            'current_host'        => DomainLock::currentHost(),
            'locked_host'         => DomainLock::lockedHost(),
            'host_lock_match'     => DomainLock::matches(),
            'can_index'           => DomainLock::canIndex(),
            'looks_like_nonprod'  => DomainLock::looksLikeNonProd(),
            'gateway_url'         => Options::gatewayUrl(),
            'schema_version'      => get_option(Options::OPT_SCHEMA_VERSION),
            'indexer_cursor'      => get_option(Options::OPT_INDEXER_CURSOR),
            'health_metrics'      => get_option(Options::OPT_HEALTH_METRICS),
        ];
        \WP_CLI::log(wp_json_encode($bundle, JSON_PRETTY_PRINT));
    }

    /**
     * Local inverted-index commands.
     *
     * ## SUBCOMMANDS
     *
     * build   Full rebuild. Optional --vertical=content|commerce|all
     * status  Show index stats
     * doctor  Health check
     *
     * ## EXAMPLES
     *
     *     wp seekmodo local_index build
     *     wp seekmodo local_index build --vertical=commerce
     *     wp seekmodo local_index status
     */
    public function local_index(array $args, array $assoc): void
    {
        $sub = $args[0] ?? 'status';
        switch ($sub) {
            case 'build':
                $vertical = isset($assoc['vertical']) ? (string) $assoc['vertical'] : 'all';
                if (!in_array($vertical, ['content', 'commerce', 'all'], true)) {
                    \WP_CLI::error('Invalid --vertical. Use content, commerce, or all.');
                }
                $v = $vertical === 'all' ? null : $vertical;
                $result = \Numinix\SeekmodoWordPress\LocalIndex\Indexer::reindexFull($v);
                \WP_CLI::success(sprintf('Local index built: %d documents indexed.', (int) $result['indexed']));
                break;
            case 'doctor':
                if (\Numinix\SeekmodoWordPress\LocalIndex\Schema::needsInstall()) {
                    \WP_CLI::warning('Schema not installed. Running install...');
                    \Numinix\SeekmodoWordPress\LocalIndex\Schema::install();
                }
                $docStatus = \Numinix\SeekmodoWordPress\LocalIndex\Indexer::status();
                if ((int) $docStatus['docs'] === 0) {
                    \WP_CLI::warning('Index is empty. Run: wp seekmodo local_index build');
                } else {
                    \WP_CLI::success('Local index looks healthy.');
                }
                break;
            case 'status':
            default:
                $status = \Numinix\SeekmodoWordPress\LocalIndex\Indexer::status();
                \WP_CLI::log(sprintf('schema_version: %d', (int) $status['schema_version']));
                \WP_CLI::log(sprintf('docs: %d', (int) $status['docs']));
                \WP_CLI::log(sprintf('rows: %d', (int) $status['rows']));
                \WP_CLI::log(sprintf('content_enabled: %s', \Numinix\SeekmodoWordPress\LocalIndex\VerticalConfig::contentIndexEnabled() ? 'yes' : 'no'));
                \WP_CLI::log(sprintf('commerce_enabled: %s', \Numinix\SeekmodoWordPress\LocalIndex\VerticalConfig::commerceIndexEnabled() ? 'yes' : 'no'));
                break;
        }
    }
}
