<?php

namespace WPSN\Modules\ActivityLog;

use WPSN\Core\AbstractModule;
use WPSN\Modules\ActivityLog\Admin\ActivityLogAdmin;

defined('ABSPATH') || exit;

final class ActivityLogModule extends AbstractModule
{
    public function id(): string
    {
        return 'activity_log';
    }

    

public function name(): string
{
    return __('Activity Log', 'wp-seo-next');
}

public function description(): string
{
    return __('Logs important WP SEO Next actions (redirects, bulk apply, config changes, SaaS sync) for troubleshooting and trust.', 'wp-seo-next');
}public function defaultEnabled(): bool
    {
        return true;
    }

    public function register(): void
    {
        // DB table.
        add_action('init', [ActivityLogInstaller::class, 'maybeInstall'], 1);

        // Hooks we want to track.
        add_action('seo/meta/updated', [$this, 'onMetaUpdated'], 10, 2);
        add_action('seo/redirect/created', [$this, 'onRedirectCreated'], 10, 2);
        add_action('seo/redirect/updated', [$this, 'onRedirectUpdated'], 10, 2);
        add_action('wpsn/internal_links/applied', [$this, 'onInternalLinkApplied'], 10, 2);

        // Search Console: Content gap drafts created.
        add_action('wpsn/gsc/gap_draft_created', [$this, 'onGscGapDraftCreated'], 10, 3);
        add_action('wpsn/gsc/cannibalization_applied', [$this, 'onGscCannibalizationApplied'], 10, 3);

        add_action('wpsn/settings/saved', [$this, 'onSettingsSaved'], 10, 2);
        add_action('wpsn/config_sync/applied', [$this, 'onConfigSyncApplied'], 10, 2);

        // IndexNow.
        add_action('wpsn/indexnow/submit_queued', [$this, 'onIndexNowSubmitQueued'], 10, 3);
        add_action('wpsn/indexnow/ping_queued', [$this, 'onIndexNowPingQueued'], 10, 3);

        // Network profile rollout (multisite).
        add_action('wpsn/profile_rollout/started', [$this, 'onProfileRolloutStarted'], 10, 4);
        add_action('wpsn/profile_rollout/finished', [$this, 'onProfileRolloutFinished'], 10, 6);
        add_action('wpsn/profile_rollout/cancelled', [$this, 'onProfileRolloutCancelled'], 10, 1);

        // Import/Export.
        add_action('wpsn/import_export/settings_exported', [$this, 'onSettingsExported'], 10, 2);
        add_action('wpsn/import_export/settings_imported', [$this, 'onSettingsImported'], 10, 2);
        add_action('wpsn/import_export/migration_started', [$this, 'onMigrationStarted'], 10, 3);
        add_action('wpsn/import_export/migration_completed', [$this, 'onMigrationCompleted'], 10, 3);
        add_action('wpsn/import_export/migration_cancelled', [$this, 'onMigrationCancelled'], 10, 2);

        // Bulk.
        add_action('wpsn/bulk/apply_queued', [$this, 'onBulkApplyQueued'], 10, 2);
        add_action('wpsn/bulk/apply_all_finished', [$this, 'onBulkApplyAllFinished'], 10, 3);

        // Schema.
        add_action('wpsn/schema/fix_completed', [$this, 'onSchemaFixCompleted'], 10, 2);

        // AI permissions.
        add_action('wpsn/ai/roles_updated', [$this, 'onAiRolesUpdated'], 10, 2);
        add_action('wpsn/ai/user_override_updated', [$this, 'onAiUserOverrideUpdated'], 10, 5);
        add_action('wpsn/ai/overrides_cleared_all', [$this, 'onAiOverridesClearedAll'], 10, 2);

        // Licensing.
        add_action('wpsn/license/verified', [$this, 'onLicenseVerified'], 10, 3);
        add_action('wpsn/license/verify_failed', [$this, 'onLicenseVerifyFailed'], 10, 2);
        add_action('wpsn/license/settings_saved', [$this, 'onLicenseSettingsSaved'], 10, 2);
        add_action('wpsn/license/cleared', [$this, 'onLicenseCleared'], 10, 1);

        // SERP Gap.
        add_action('wpsn/serp_gap/run_completed', [$this, 'onSerpGapRunCompleted'], 10, 3);
        add_action('wpsn/serp_gap/run_failed', [$this, 'onSerpGapRunFailed'], 10, 3);

        // Retention purge.
        add_action('init', [$this, 'ensurePurgeSchedule'], 5);
        add_action('wpsn_activity_log_purge', [$this, 'purgeOld']);

        // Admin UI.
        if (is_admin()) {
            (new ActivityLogAdmin($this->container))->register();
        }
    }

    public function boot(): void
    {
        // no-op
    }

    public function ensurePurgeSchedule(): void
    {
        $days = (int) $this->settings->get('activity_log.retention_days', 90);
        if ($days <= 0) {
            if (function_exists('wp_clear_scheduled_hook')) {
                wp_clear_scheduled_hook('wpsn_activity_log_purge');
            }
            return;
        }

        if (!wp_next_scheduled('wpsn_activity_log_purge')) {
            wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', 'wpsn_activity_log_purge');
        }
    }

    public function purgeOld(): void
    {
        $days = (int) $this->settings->get('activity_log.retention_days', 90);
        if ($days <= 0) {
            return;
        }

        try {
            $repo = new ActivityLogRepository();
            $repo->purgeOlderThan($days);
        } catch (\Throwable $e) {
            $this->logger->warning('Activity log purge failed: ' . $e->getMessage());
        }
    }

    /**
     * @param mixed $type
     */
    public function onMetaUpdated($id, $type = 'post'): void
    {
        $id = (int) $id;
        $type = is_string($type) ? sanitize_key($type) : 'post';
        if ($id <= 0) {
            return;
        }

        $repo = new ActivityLogRepository();

        if ($type === 'term') {
            $term = get_term($id);
            $name = ($term && !is_wp_error($term)) ? (string) ($term->name ?? '') : '';
            $repo->insert('meta_updated', sprintf('SEO meta updated for term #%d %s', $id, $name !== '' ? '(' . $name . ')' : ''), [
                'context' => 'meta',
                'object_type' => 'term',
                'object_id' => $id,
                'object_key' => $name,
            ]);
            return;
        }

        $title = get_the_title($id);
        $permalink = get_permalink($id);
        $repo->insert('meta_updated', sprintf('SEO meta updated for post #%d %s', $id, $title ? '(' . $title . ')' : ''), [
            'context' => 'meta',
            'object_type' => 'post',
            'object_id' => $id,
            'object_key' => is_string($permalink) ? $permalink : '',
        ]);
    }

    /**
     * @param array<string,mixed> $data
     */
    public function onRedirectCreated(int $id, array $data = []): void
    {
        $repo = new ActivityLogRepository();
        $repo->insert('redirect_created', sprintf('Redirect created #%d', $id), [
            'context' => 'redirects',
            'object_type' => 'redirect',
            'object_id' => $id,
            'meta' => [
                'from' => (string) ($data['from'] ?? ''),
                'to' => (string) ($data['to'] ?? ''),
                'type' => (int) ($data['type'] ?? 301),
            ],
        ]);
    }

    /**
     * @param array<string,mixed> $data
     */
    public function onRedirectUpdated(int $id, array $data = []): void
    {
        $repo = new ActivityLogRepository();
        $repo->insert('redirect_updated', sprintf('Redirect updated #%d', $id), [
            'context' => 'redirects',
            'object_type' => 'redirect',
            'object_id' => $id,
            'meta' => [
                'from' => (string) ($data['from'] ?? ''),
                'to' => (string) ($data['to'] ?? ''),
                'type' => (int) ($data['type'] ?? 301),
            ],
        ]);
    }

    public function onInternalLinkApplied(int $postId, int $suggestionId): void
    {
        $repo = new ActivityLogRepository();
        $repo->insert('internal_link_applied', sprintf('Internal link applied on post #%d', $postId), [
            'context' => 'internal_links',
            'object_type' => 'post',
            'object_id' => $postId,
            'meta' => [
                'suggestion_id' => $suggestionId,
            ],
        ]);
    }

    /**
     * @param array<string,mixed> $meta
     */
    public function onGscGapDraftCreated(int $postId, string $query, array $meta = []): void
    {
        $postId = (int) $postId;
        $query = sanitize_text_field($query);

        if ($postId <= 0 || $query === '') {
            return;
        }

        $title = get_the_title($postId);
        $repo = new ActivityLogRepository();

        $repo->insert('gsc_gap_draft_created', sprintf('Content gap draft created for "%s"', $this->short($query, 80)), [
            'context' => 'gsc',
            'object_type' => 'post',
            'object_id' => $postId,
            'object_key' => is_string(get_permalink($postId)) ? (string) get_permalink($postId) : '',
            'meta' => [
                'query' => $query,
                'title' => is_string($title) ? $title : '',
                'source_url' => (string) ($meta['url'] ?? ''),
                'impressions' => (float) ($meta['impressions'] ?? 0),
                'clicks' => (float) ($meta['clicks'] ?? 0),
                'ctr' => (float) ($meta['ctr'] ?? 0),
                'position' => (float) ($meta['position'] ?? 0),
                'post_type' => (string) ($meta['post_type'] ?? ''),
            ],
        ]);
    }

    /**
     * @param array<string,mixed> $meta
     */
    public function onGscCannibalizationApplied(string $query, string $primaryUrl, array $meta = []): void
    {
        $query = sanitize_text_field($query);
        $primaryUrl = esc_url_raw($primaryUrl);

        if ($query === '' || $primaryUrl === '') {
            return;
        }

        $repo = new ActivityLogRepository();

        $canon = (int) ($meta['canonical_applied'] ?? 0);
        $focus = (int) ($meta['focus_applied'] ?? 0);
        $changed = isset($meta['posts_changed']) && is_array($meta['posts_changed']) ? array_map('intval', $meta['posts_changed']) : [];

        $summary = sprintf('Cannibalization workout applied for "%s" (canonical: %d, focus: %d)', $this->short($query, 80), $canon, $focus);

        $repo->insert('gsc_cannibalization_applied', $summary, [
            'context' => 'gsc',
            'object_type' => 'query',
            'object_key' => $query,
            'meta' => [
                'query' => $query,
                'primary_url' => $primaryUrl,
                'canonical_applied' => $canon,
                'focus_applied' => $focus,
                'posts_changed' => $changed,
            ],
        ]);
    }

    public function onSettingsSaved(int $userId, bool $locked): void
    {
        $repo = new ActivityLogRepository();
        $repo->insert('settings_saved', $locked ? 'Settings save attempted (locked by Config Sync)' : 'Settings saved', [
            'context' => 'settings',
            'user_id' => $userId,
            'object_type' => 'settings',
            'meta' => [
                'locked' => $locked,
            ],
        ]);
    }

    /**
     * @param array<string,mixed> $stats
     */
    public function onConfigSyncApplied(string $summary, array $stats = []): void
    {
        $repo = new ActivityLogRepository();
        $repo->insert('config_sync_applied', 'Config Sync applied: ' . $summary, [
            'context' => 'config_sync',
            'object_type' => 'config',
            'meta' => $stats,
        ]);
    }

    /**
     * @param array<string,mixed> $meta
     */
    public function onIndexNowSubmitQueued(int $count, string $reason, array $meta = []): void
    {
        $count = max(0, (int) $count);
        $reason = sanitize_key($reason);
        if ($count < 1) {
            return;
        }

        $source = isset($meta['source']) ? sanitize_key((string) $meta['source']) : '';
        $via = $source === 'cli' ? 'WP-CLI' : 'Tools';

        $msg = '';

        if ($reason === 'manual') {
            $url = isset($meta['url']) ? (string) $meta['url'] : '';
            if ($url !== '') {
                $msg = sprintf('IndexNow queued: 1 URL (%s) — %s', $via, $this->short($url, 120));
            } else {
                $msg = sprintf('IndexNow queued: 1 URL (%s)', $via);
            }
        } elseif ($reason === 'bulk_sitemap') {
            $sitemap = isset($meta['sitemap_url']) ? (string) $meta['sitemap_url'] : '';
            $msg = $sitemap !== ''
                ? sprintf('IndexNow bulk queued: %d URL(s) from sitemap (%s) — %s', $count, $via, $this->short($sitemap, 140))
                : sprintf('IndexNow bulk queued: %d URL(s) from sitemap (%s)', $count, $via);
        } elseif ($reason === 'bulk_recent') {
            $days = isset($meta['days']) ? (int) $meta['days'] : 0;
            $msg = $days > 0
                ? sprintf('IndexNow bulk queued: %d recent URL(s) (last %d day(s), %s)', $count, $days, $via)
                : sprintf('IndexNow bulk queued: %d recent URL(s) (%s)', $count, $via);
        } else {
            $msg = sprintf('IndexNow queued: %d URL(s) (%s, reason: %s)', $count, $via, $reason !== '' ? $reason : 'n/a');
        }

        $repo = new ActivityLogRepository();
        $repo->insert('indexnow_submit_queued', $msg, [
            'context' => 'indexnow',
            'object_type' => 'indexnow',
            'object_key' => $reason,
            'meta' => array_merge($meta, [
                'count' => $count,
                'reason' => $reason,
            ]),
        ]);
    }

    /**
     * @param array<string,mixed> $meta
     */
    public function onIndexNowPingQueued(int $count, string $kind, array $meta = []): void
    {
        $count = max(0, (int) $count);
        $kind = sanitize_key($kind);
        if ($count < 1) {
            return;
        }

        $source = isset($meta['source']) ? sanitize_key((string) $meta['source']) : '';
        $via = $source === 'cli' ? 'WP-CLI' : 'Tools';

        $providers = [];
        if (isset($meta['providers']) && is_array($meta['providers'])) {
            foreach ($meta['providers'] as $p) {
                $p = sanitize_key((string) $p);
                if ($p !== '') {
                    $providers[] = $p;
                }
            }
        }

        $provStr = $providers ? implode(', ', $providers) : 'bing';
        $sitemap = isset($meta['sitemap_url']) ? (string) $meta['sitemap_url'] : '';

        $msg = $sitemap !== ''
            ? sprintf('Sitemap ping queued: %s (%s) x%d (%s) — %s', $kind !== '' ? $kind : 'index', $provStr, $count, $via, $this->short($sitemap, 140))
            : sprintf('Sitemap ping queued: %s (%s) x%d (%s)', $kind !== '' ? $kind : 'index', $provStr, $count, $via);

        $repo = new ActivityLogRepository();
        $repo->insert('indexnow_ping_queued', $msg, [
            'context' => 'indexnow',
            'object_type' => 'indexnow',
            'object_key' => $kind,
            'meta' => array_merge($meta, [
                'count' => $count,
                'kind' => $kind,
                'providers' => $providers,
            ]),
        ]);
    }

    /**
     * @param array<int,int> $siteIds
     * @param array<string,mixed> $meta
     */
    public function onProfileRolloutStarted(string $profileId, string $mode, array $siteIds, array $meta = []): void
    {
        $profileId = sanitize_key($profileId);
        $mode = sanitize_key($mode);
        $total = is_array($siteIds) ? count($siteIds) : 0;
        if ($profileId === '') {
            return;
        }

        $applyRedirects = !empty($meta['apply_redirects']);
        $applyIndexNow = !empty($meta['apply_indexnow']);

        $msg = sprintf(
            'Profile rollout started: %s → %d site(s) (mode: %s, redirects: %s, indexnow: %s)',
            $profileId,
            (int) $total,
            $mode !== '' ? $mode : 'settings',
            $applyRedirects ? 'yes' : 'no',
            $applyIndexNow ? 'yes' : 'no'
        );

        $repo = new ActivityLogRepository();
        $repo->insert('profile_rollout_started', $msg, [
            'context' => 'profiles',
            'object_type' => 'profile',
            'object_key' => $profileId,
            'meta' => array_merge($meta, [
                'mode' => $mode,
                'total' => $total,
            ]),
        ]);
    }

    /**
     * @param array<string,mixed> $meta
     */
    public function onProfileRolloutFinished(string $profileId, string $mode, int $total, int $ok, int $failed, array $meta = []): void
    {
        $profileId = sanitize_key($profileId);
        $mode = sanitize_key($mode);
        if ($profileId === '') {
            return;
        }

        $applyRedirects = !empty($meta['apply_redirects']);
        $applyIndexNow = !empty($meta['apply_indexnow']);

        $msg = sprintf(
            'Profile rollout finished: %s — %d/%d OK, %d failed (mode: %s, redirects: %s, indexnow: %s)',
            $profileId,
            (int) $ok,
            (int) $total,
            (int) $failed,
            $mode !== '' ? $mode : 'settings',
            $applyRedirects ? 'yes' : 'no',
            $applyIndexNow ? 'yes' : 'no'
        );

        // Keep failure details small.
        if (isset($meta['failures']) && is_array($meta['failures']) && count($meta['failures']) > 10) {
            $meta['failures'] = array_slice($meta['failures'], 0, 10);
            $meta['failures_truncated'] = 1;
        }

        $repo = new ActivityLogRepository();
        $repo->insert('profile_rollout_finished', $msg, [
            'context' => 'profiles',
            'object_type' => 'profile',
            'object_key' => $profileId,
            'meta' => array_merge($meta, [
                'mode' => $mode,
                'total' => $total,
                'ok' => $ok,
                'failed' => $failed,
            ]),
        ]);
    }

    public function onProfileRolloutCancelled(string $profileId): void
    {
        $profileId = sanitize_key($profileId);
        if ($profileId === '') {
            return;
        }
        $repo = new ActivityLogRepository();
        $repo->insert('profile_rollout_cancelled', sprintf('Profile rollout cancelled: %s', $profileId), [
            'context' => 'profiles',
            'object_type' => 'profile',
            'object_key' => $profileId,
        ]);
    }

    /**
     * @param array<string,mixed> $meta
     */
    public function onSettingsExported(int $userId, array $meta = []): void
    {
        $repo = new ActivityLogRepository();
        $repo->insert('settings_exported', 'Settings exported (JSON)', [
            'context' => 'import_export',
            'user_id' => $userId,
            'object_type' => 'settings',
            'meta' => $meta,
        ]);
    }

    /**
     * @param array<string,mixed> $meta
     */
    public function onSettingsImported(int $userId, array $meta = []): void
    {
        $msg = 'Settings imported (JSON)';
        $ver = isset($meta['imported_version']) ? (string) $meta['imported_version'] : '';
        if ($ver !== '') {
            $msg .= ' — from v' . $this->short($ver, 40);
        }
        $repo = new ActivityLogRepository();
        $repo->insert('settings_imported', $msg, [
            'context' => 'import_export',
            'user_id' => $userId,
            'object_type' => 'settings',
            'meta' => $meta,
        ]);
    }

    /**
     * @param array<int,string> $postTypes
     * @param array<string,mixed> $meta
     */
    public function onMigrationStarted(string $source, array $postTypes, array $meta = []): void
    {
        $source = sanitize_key($source);
        $pt = [];
        foreach ($postTypes as $p) {
            $p = sanitize_key((string) $p);
            if ($p !== '') {
                $pt[] = $p;
            }
        }
        $pt = array_values(array_unique($pt));

        $msg = sprintf(
            'Migration started: %s → post types: %s (overwrite: %s, terms: %s, social: %s, schema: %s)',
            $source !== '' ? $source : 'unknown',
            $pt ? implode(', ', $pt) : 'n/a',
            !empty($meta['overwrite']) ? 'yes' : 'no',
            !empty($meta['include_terms']) ? 'yes' : 'no',
            !empty($meta['include_social']) ? 'yes' : 'no',
            !empty($meta['include_schema']) ? 'yes' : 'no'
        );

        $jobId = isset($meta['job_id']) ? sanitize_text_field((string) $meta['job_id']) : '';

        $repo = new ActivityLogRepository();
        $repo->insert('migration_started', $msg, [
            'context' => 'import_export',
            'object_type' => 'migration',
            'object_key' => $jobId,
            'meta' => array_merge($meta, [
                'source' => $source,
                'post_types' => $pt,
            ]),
        ]);
    }

    /**
     * @param array<string,mixed> $summary
     */
    public function onMigrationCompleted(string $jobId, string $source, array $summary = []): void
    {
        $jobId = sanitize_text_field($jobId);
        $source = sanitize_key($source);

        $msg = sprintf(
            'Migration completed: %s — %d post(s) updated, %d term(s) updated (processed %d/%d)',
            $source !== '' ? $source : 'unknown',
            (int) ($summary['posts_updated'] ?? 0),
            (int) ($summary['terms_updated'] ?? 0),
            (int) ($summary['posts_processed'] ?? 0),
            (int) ($summary['posts_total'] ?? 0)
        );

        $repo = new ActivityLogRepository();
        $repo->insert('migration_completed', $msg, [
            'context' => 'import_export',
            'object_type' => 'migration',
            'object_key' => $jobId,
            'meta' => array_merge($summary, [
                'job_id' => $jobId,
                'source' => $source,
            ]),
        ]);
    }

    public function onMigrationCancelled(string $jobId, string $source): void
    {
        $jobId = sanitize_text_field($jobId);
        $source = sanitize_key($source);

        $repo = new ActivityLogRepository();
        $repo->insert('migration_cancelled', sprintf('Migration cancelled: %s (job %s)', $source !== '' ? $source : 'unknown', $jobId !== '' ? $jobId : 'n/a'), [
            'context' => 'import_export',
            'object_type' => 'migration',
            'object_key' => $jobId,
            'meta' => [
                'job_id' => $jobId,
                'source' => $source,
            ],
        ]);
    }

    /**
     * @param array<string,mixed> $meta
     */
    public function onBulkApplyQueued(int $queued, array $meta = []): void
    {
        $queued = max(0, (int) $queued);
        if ($queued < 1) {
            return;
        }

        $applyOnlyChanged = !empty($meta['apply_only_changed']);
        $source = isset($meta['source']) ? sanitize_key((string) $meta['source']) : '';

        $msg = sprintf(
            'Bulk apply queued: %d post(s) (apply_only_changed: %s, source: %s)',
            $queued,
            $applyOnlyChanged ? 'yes' : 'no',
            $source !== '' ? $source : 'queue'
        );

        $repo = new ActivityLogRepository();
        $repo->insert('bulk_apply_queued', $msg, [
            'context' => 'bulk',
            'object_type' => 'bulk',
            'meta' => $meta,
        ]);
    }

    /**
     * @param array<string,mixed> $meta
     */
    public function onBulkApplyAllFinished(int $processed, int $applied, array $meta = []): void
    {
        $processed = max(0, (int) $processed);
        $applied = max(0, (int) $applied);

        $applyOnlyChanged = !empty($meta['apply_only_changed']);

        $filters = isset($meta['filters']) && is_array($meta['filters']) ? $meta['filters'] : [];
        $pt = isset($filters['pt']) ? sanitize_key((string) $filters['pt']) : '';
        $src = isset($filters['source']) ? sanitize_key((string) $filters['source']) : '';
        $postId = isset($filters['post_id']) ? (int) $filters['post_id'] : 0;

        $scope = [];
        if ($postId > 0) {
            $scope[] = 'post #' . $postId;
        }
        if ($pt !== '' && $pt !== 'any') {
            $scope[] = 'pt:' . $pt;
        }
        if ($src !== '') {
            $scope[] = 'source:' . $src;
        }
        $scopeStr = $scope ? ' (' . implode(', ', $scope) . ')' : '';

        $msg = sprintf(
            'Bulk apply finished: %d/%d post(s) applied%s (apply_only_changed: %s)',
            $applied,
            $processed,
            $scopeStr,
            $applyOnlyChanged ? 'yes' : 'no'
        );

        $repo = new ActivityLogRepository();
        $repo->insert('bulk_apply_finished', $msg, [
            'context' => 'bulk',
            'object_type' => 'bulk',
            'meta' => $meta,
        ]);
    }

    /**
     * @param array<string,mixed> $changed
     */


    /**
     * @param array<string,mixed> $result
     */
    public function onSchemaFixCompleted(int $scanId, array $result = []): void
    {
        try {
            $fixedOrgLogo = false;
            $fixedOrgName = false;
            $fixedUsersImage = 0;
            $fixedUsersSameAs = 0;

            if (isset($result['fixed']) && is_array($result['fixed'])) {
                foreach ($result['fixed'] as $f) {
                    if (!is_array($f) || empty($f['type'])) {
                        continue;
                    }
                    if ((string) $f['type'] === 'org_logo') {
                        $fixedOrgLogo = true;
                    }
                    if ((string) $f['type'] === 'org_name') {
                        $fixedOrgName = true;
                    }
                    if ((string) $f['type'] === 'author_image') {
                        $fixedUsersImage = (int) ($f['count'] ?? 0);
                    }
                    if ((string) $f['type'] === 'author_sameas') {
                        $fixedUsersSameAs = (int) ($f['count'] ?? 0);
                    }
                }
            }

            if (isset($result['counts']['fixed_author_image'])) {
                $fixedUsersImage = max($fixedUsersImage, (int) $result['counts']['fixed_author_image']);
            }
            if (isset($result['counts']['fixed_author_sameas'])) {
                $fixedUsersSameAs = max($fixedUsersSameAs, (int) $result['counts']['fixed_author_sameas']);
            }

            $parts = [];
            if ($fixedOrgLogo) {
                $parts[] = 'org_logo';
            }
            if ($fixedOrgName) {
                $parts[] = 'org_name';
            }
            if ($fixedUsersImage > 0) {
                $parts[] = 'author_image(' . $fixedUsersImage . ')';
            }
            if ($fixedUsersSameAs > 0) {
                $parts[] = 'author_sameas(' . $fixedUsersSameAs . ')';
            }

            $msg = sprintf(
                'Schema safe fixes completed for scan #%d%s',
                $scanId,
                $parts ? ' • ' . implode(', ', $parts) : ''
            );

            $repo = new ActivityLogRepository();
            $repo->insert('schema_fix_completed', $msg, [
                'context' => 'schema',
                'object_type' => 'schema_scan',
                'object_id' => $scanId,
                'meta' => $result,
            ]);
        } catch (\Throwable $e) {
            $this->logger->warning('Activity log schema fix failed: ' . $e->getMessage());
        }
    }


    public function onAiRolesUpdated(int $userId, array $changed = []): void
    {
        $parseList = static function ($v): array {
            $out = [];
            if (!is_array($v)) {
                return $out;
            }
            foreach ($v as $r) {
                $r = sanitize_key((string) $r);
                if ($r !== '') {
                    $out[] = $r;
                }
            }
            return array_values(array_unique($out));
        };

        // Back-compat: older versions used just granted/revoked for wpsn_use_ai.
        $grantedUse = $parseList($changed['granted'] ?? []);
        $revokedUse = $parseList($changed['revoked'] ?? []);

        // Newer versions also send manage cap changes.
        $grantedManage = $parseList($changed['granted_manage'] ?? []);
        $revokedManage = $parseList($changed['revoked_manage'] ?? []);

        $msg = 'AI role permissions updated';
        $parts = [];
        if ($grantedUse) {
            $parts[] = 'use granted: ' . implode(', ', $grantedUse);
        }
        if ($revokedUse) {
            $parts[] = 'use revoked: ' . implode(', ', $revokedUse);
        }
        if ($grantedManage) {
            $parts[] = 'manage granted: ' . implode(', ', $grantedManage);
        }
        if ($revokedManage) {
            $parts[] = 'manage revoked: ' . implode(', ', $revokedManage);
        }
        if ($parts) {
            $msg .= ' (' . implode('; ', $parts) . ')';
        }

        $repo = new ActivityLogRepository();
        $repo->insert('ai_roles_updated', $msg, [
            'context' => 'ai',
            'user_id' => $userId,
            'object_type' => 'capability',
            'object_key' => 'ai_caps',
            'meta' => [
                'use' => [
                    'granted' => $grantedUse,
                    'revoked' => $revokedUse,
                ],
                'manage' => [
                    'granted' => $grantedManage,
                    'revoked' => $revokedManage,
                ],
            ],
        ]);
    }

    public function onAiUserOverrideUpdated(int $actorUserId, int $targetUserId, string $oldValue, string $newValue, string $scope = 'use'): void
    {
        $actorUserId = (int) $actorUserId;
        $targetUserId = (int) $targetUserId;

        $oldValue = sanitize_key((string) $oldValue);
        $newValue = sanitize_key((string) $newValue);
        if ($oldValue === '') {
            $oldValue = 'inherit';
        }
        if ($newValue === '') {
            $newValue = 'inherit';
        }

        $scope = sanitize_key((string) $scope);
        if (!in_array($scope, ['use', 'manage'], true)) {
            $scope = 'use';
        }

        $u = get_user_by('id', $targetUserId);
        $label = '';
        if ($u instanceof \WP_User) {
            $label = (string) ($u->user_login ?? '');
        }

        $labelScope = ($scope === 'manage') ? 'AI admin' : 'OpenAI';

        $msg = sprintf(
            '%s user override updated for user #%d%s: %s → %s',
            $labelScope,
            $targetUserId,
            $label !== '' ? ' (' . $label . ')' : '',
            $oldValue,
            $newValue
        );

        $repo = new ActivityLogRepository();
        $repo->insert('ai_user_override_updated', $msg, [
            'context' => 'ai',
            'user_id' => $actorUserId,
            'object_type' => 'user',
            'object_id' => $targetUserId,
            'object_key' => $label,
            'meta' => [
                'scope' => $scope,
                'old' => $oldValue,
                'new' => $newValue,
            ],
        ]);
    }

    /**
     * @param array<string,mixed> $stats
     */
    public function onAiOverridesClearedAll(int $actorUserId, array $stats = []): void
    {
        $actorUserId = (int) $actorUserId;
        $users = isset($stats['users_affected']) ? (int) $stats['users_affected'] : 0;
        $use = isset($stats['cleared_use']) ? (int) $stats['cleared_use'] : 0;
        $manage = isset($stats['cleared_manage']) ? (int) $stats['cleared_manage'] : 0;
        $which = isset($stats['which']) ? sanitize_key((string) $stats['which']) : 'both';
        if (!in_array($which, ['use', 'manage', 'both'], true)) {
            $which = 'both';
        }

        $msg = sprintf(
            'AI user overrides cleared for all users: %d user(s) affected (use: %d, manage: %d; mode: %s)',
            max(0, $users),
            max(0, $use),
            max(0, $manage),
            $which
        );

        $repo = new ActivityLogRepository();
        $repo->insert('ai_overrides_cleared_all', $msg, [
            'context' => 'ai',
            'user_id' => $actorUserId,
            'object_type' => 'capability',
            'object_key' => 'ai_user_overrides',
            'meta' => [
                'users_affected' => $users,
                'cleared_use' => $use,
                'cleared_manage' => $manage,
                'which' => $which,
            ],
        ]);
    }

    /**
     * @param array<string,mixed> $license
     */
    public function onLicenseVerified(string $status, array $license = [], bool $trusted = false): void
    {
        $status = sanitize_key($status);
        $plan = isset($license['plan']) ? (string) $license['plan'] : '';
        $exp = isset($license['expires_at']) ? (string) $license['expires_at'] : '';

        $msg = sprintf('License verified: %s%s%s', $status, $plan !== '' ? ' (plan: ' . $plan . ')' : '', $trusted ? ' [signed]' : ' [untrusted]');
        if ($exp !== '') {
            $msg .= ' expires_at=' . $exp;
        }

        $repo = new ActivityLogRepository();
        $repo->insert('license_verified', $msg, [
            'context' => 'licensing',
            'object_type' => 'license',
            'object_key' => $plan,
            'meta' => [
                'status' => $status,
                'plan' => $plan,
                'expires_at' => $exp,
                'trusted' => $trusted,
            ],
        ]);
    }

    public function onLicenseVerifyFailed(string $error, int $retryAfter = 0): void
    {
        $error = $this->short((string) $error, 200);

        $msg = 'License verification failed: ' . $error;
        if ($retryAfter > 0) {
            $msg .= ' (retry-after: ' . $retryAfter . 's)';
        }

        $repo = new ActivityLogRepository();
        $repo->insert('license_verify_failed', $msg, [
            'context' => 'licensing',
            'object_type' => 'license',
            'object_key' => '',
            'meta' => [
                'error' => $error,
                'retry_after' => $retryAfter,
            ],
        ]);
    }

    /**
     * @param array<string,mixed> $data
     */
    public function onLicenseSettingsSaved(int $actorUserId, array $data = []): void
    {
        $actorUserId = (int) $actorUserId;
        $enforce = !empty($data['enforce']);
        $server = isset($data['server_url']) ? (string) $data['server_url'] : '';

        $msg = sprintf('License settings saved (enforce: %s)', $enforce ? 'yes' : 'no');
        if ($server !== '') {
            $msg .= ' server=' . $server;
        }

        $repo = new ActivityLogRepository();
        $repo->insert('license_settings_saved', $msg, [
            'context' => 'licensing',
            'user_id' => $actorUserId,
            'object_type' => 'settings',
            'object_key' => 'licensing',
            'meta' => [
                'enforce' => $enforce,
                'server_url' => $server,
            ],
        ]);
    }

    public function onLicenseCleared(int $actorUserId): void
    {
        $actorUserId = (int) $actorUserId;
        $repo = new ActivityLogRepository();
        $repo->insert('license_cleared', 'License key cleared', [
            'context' => 'licensing',
            'user_id' => $actorUserId,
            'object_type' => 'license',
            'object_key' => '',
        ]);
    }

    public function onSerpGapRunCompleted(int $runId, string $query, int $postId): void
    {
        $runId = (int) $runId;
        $postId = (int) $postId;
        $query = $this->short($query, 180);

        $msg = sprintf('SERP gap run #%d completed for query "%s"', $runId, $query);
        $repo = new ActivityLogRepository();
        $repo->insert('serp_gap_completed', $msg, [
            'context' => 'serp_gap',
            'object_type' => 'serp_gap_run',
            'object_id' => $runId,
            'meta' => [
                'query' => $query,
                'post_id' => $postId,
            ],
        ]);
    }

    public function onSerpGapRunFailed(int $runId, string $query, string $error): void
    {
        $runId = (int) $runId;
        $query = $this->short($query, 180);
        $error = $this->short($error, 200);

        $msg = sprintf('SERP gap run #%d failed for query "%s": %s', $runId, $query, $error);
        $repo = new ActivityLogRepository();
        $repo->insert('serp_gap_failed', $msg, [
            'context' => 'serp_gap',
            'object_type' => 'serp_gap_run',
            'object_id' => $runId,
            'meta' => [
                'query' => $query,
                'error' => $error,
            ],
        ]);
    }

    private function short(string $s, int $max = 120): string
    {
        $s = trim($s);
        if ($s === '') {
            return '';
        }
        if (strlen($s) <= $max) {
            return $s;
        }
        return substr($s, 0, max(0, $max - 3)) . '...';
    }
}
