<?php

declare (strict_types=1);
namespace Slash\Booking;

final class Plugin
{
    public const VERSION = '1.2.0';
    public const TEXT_DOMAIN = 'slashbooking';
    public const DB_VERSION = 2;
    public const REST_NAMESPACE = 'slashbooking/v1';
    private static ?self $instance = null;
    /** @var array<string, object> */
    private array $services = [];
    private string $pluginFile;
    private function __construct(string $pluginFile)
    {
        $this->pluginFile = $pluginFile;
    }
    public static function boot(string $pluginFile): self
    {
        if (self::$instance === null) {
            self::$instance = new self($pluginFile);
            self::$instance->register();
        }
        return self::$instance;
    }
    public static function instance(): self
    {
        if (self::$instance === null) {
            throw new \RuntimeException('Plugin not booted');
        }
        return self::$instance;
    }
    public static function version(): string
    {
        return self::VERSION;
    }
    public function pluginFile(): string
    {
        return $this->pluginFile;
    }
    public function pluginDir(): string
    {
        return \dirname($this->pluginFile);
    }
    /**
     * @template T of object
     * @param class-string<T> $id
     * @param T $instance
     */
    public function set(string $id, object $instance): void
    {
        $this->services[$id] = $instance;
    }
    /**
     * @template T of object
     * @param class-string<T> $id
     * @return T
     */
    public function get(string $id): object
    {
        if (!isset($this->services[$id])) {
            throw new \RuntimeException("Service not registered: {$id}");
        }
        /** @var T */
        return $this->services[$id];
    }
    private function register(): void
    {
        // Plugin::register() runs at plugin sandbox scrape, BEFORE the
        // register_activation_hook fires. Without this idempotent seed, a fresh
        // install fatals on `new DecisionTokenSigner('')` further down.
        \Slash\Booking\Activator::ensureDecisionSecret();
        // No is_admin() guard: PUC's filters need to be registered in any
        // context that may refresh the update_plugins transient — including
        // WP-Cron (DOING_CRON=true, is_admin()=false). Skipping bootstrap on
        // those contexts left the transient stale and made updates undetectable.
        \Slash\Booking\Updates\UpdateChecker::bootstrap($this->pluginFile);
        // Propagate capability changes to existing installs that were activated
        // under an older revision (e.g. before editor role got plugin access).
        \Slash\Booking\Admin\Capabilities::syncOnUpgrade();
        $router = new \Slash\Booking\Http\RestRouter();
        $router->register();
        $this->set(\Slash\Booking\Http\RestRouter::class, $router);
        global $wpdb;
        // Run pending schema migrations on every boot, not just on activation:
        // plugin UPDATES (the 1-click updater) do NOT fire the activation hook,
        // so without this a client who updates never gets new columns added in a
        // newer DB_VERSION (e.g. reconnect_required). Migrator self-gates on the
        // sb_db_version option, so this is a cheap no-op once the schema is current.
        (new \Slash\Booking\Persistence\Migrator($wpdb))->migrate();
        $services = new \Slash\Booking\Persistence\ServiceRepository($wpdb);
        $shortcode = new \Slash\Booking\PublicFront\Shortcode($services);
        $shortcode->register();
        $bookings = new \Slash\Booking\Persistence\BookingRepository($wpdb);
        $reminder = new \Slash\Booking\Notifications\ReminderScheduler($bookings);
        $reminder->register();
        // Plan 5: WP privacy data exporters / erasers
        $privacyExporter = new \Slash\Booking\Privacy\BookingExporter(findByEmail: fn(string $email) => $bookings->findByCustomerEmail($email));
        add_filter('wp_privacy_personal_data_exporters', static function (array $exporters) use ($privacyExporter): array {
            $exporters['slashbooking'] = ['exporter_friendly_name' => __('SlashBooking', 'slashbooking'), 'callback' => static fn(string $email, int $page = 1) => $privacyExporter->export($email, $page)];
            return $exporters;
        });
        $privacyEraser = new \Slash\Booking\Privacy\BookingEraser(anonymizeByEmail: fn(string $email) => $bookings->anonymizeByEmail($email));
        add_filter('wp_privacy_personal_data_erasers', static function (array $erasers) use ($privacyEraser): array {
            $erasers['slashbooking'] = ['eraser_friendly_name' => __('SlashBooking', 'slashbooking'), 'callback' => static fn(string $email, int $page = 1) => $privacyEraser->erase($email, $page)];
            return $erasers;
        });
        // Cron interval must also be present at runtime, not just at activation.
        add_filter('cron_schedules', static function (array $s): array {
            if (!isset($s['sb_monthly'])) {
                $s['sb_monthly'] = ['interval' => 2592000, 'display' => 'Once every 30 days (SlashBooking)'];
            }
            return $s;
        });
        \Slash\Booking\Privacy\BookingRetentionPurger::register();
        $signer = new \Slash\Booking\Booking\DecisionTokenSigner((string) get_option('sb_decision_secret'));
        // Lazy URL resolver — rest_url() requires $wp_rewrite which is not yet
        // initialized at plugin file load time. The closure fires later, when
        // BookingNotifier callbacks actually need to build a URL.
        $urls = new \Slash\Booking\Http\UrlBuilder($signer, fn(): string => rest_url(self::REST_NAMESPACE));
        $dispatcher = new \Slash\Booking\Notifications\MailDispatcher(new \Slash\Booking\Persistence\MailTemplateRepository($wpdb), new \Slash\Booking\Notifications\TemplateRenderer(new \Slash\Booking\Notifications\TagRegistry()), new \Slash\Booking\Notifications\TextBodyGenerator(), new \Slash\Booking\Notifications\IcsBuilder());
        $this->set(\Slash\Booking\Notifications\MailDispatcher::class, $dispatcher);
        (new \Slash\Booking\Notifications\BookingNotifier($services, $bookings, $dispatcher, $urls))->register();
        // ----- Google sync (Plan 3) -----
        $accounts = new \Slash\Booking\Persistence\GoogleAccountRepository($wpdb);
        (new \Slash\Booking\Migration\BrokerMigration($accounts))->run();
        add_action('admin_notices', static function () use ($accounts): void {
            if (!current_user_can('manage_options')) {
                return;
            }
            $account = $accounts->findSingle();
            if ($account === null || !$account->reconnectRequired()) {
                return;
            }
            $url = admin_url('admin.php?page=slashbooking#/google');
            printf('<div class="notice notice-warning"><p><strong>SlashBooking :</strong> %s <a href="%s">%s</a></p></div>', esc_html__('La connexion Google Calendar doit être renouvelée (1 clic).', 'slashbooking'), esc_url($url), esc_html__('Reconnecter maintenant', 'slashbooking'));
        });
        $syncLogRepo = new \Slash\Booking\Persistence\SyncLogRepository($wpdb);
        $keyResolver = new \Slash\Booking\Google\EncryptionKeyResolver();
        $encryption = new \Slash\Booking\Google\Encryption($keyResolver->resolve());
        $pendingColor = (string) get_option('sb_gcal_color_pending', '6');
        $confirmedColor = (string) get_option('sb_gcal_color_confirmed', '10');
        $formatter = new \Slash\Booking\Google\EventFormatter($pendingColor, $confirmedColor);
        (new \Slash\Booking\Google\PushScheduler())->register();
        (new \Slash\Booking\Google\SyncLogPurger(purge: fn(\DateTimeImmutable $cutoff): int => $syncLogRepo->purgeOlderThan($cutoff)))->register();
        $broker = new \Slash\Booking\Google\BrokerClient(baseUrl: \Slash\Booking\Config::brokerUrl(), license: (string) get_option('sb_license_key', ''));
        $clientBuilder = new \Slash\Booking\Google\GoogleClientBuilder($encryption, $accounts, $broker);
        // Register Action Scheduler handler.
        add_action(\Slash\Booking\Google\PushScheduler::HOOK, function (int $bookingId, string $action) use ($bookings, $services, $accounts, $syncLogRepo, $formatter, $clientBuilder): void {
            // Paid feature: pause outbound WP->Google push when the license is not
            // valid (downgrade). Data is preserved; push resumes on re-validation.
            if (!\Slash\Booking\Config::isPaid()) {
                return;
            }
            $booking = $bookings->findById($bookingId);
            if ($booking === null) {
                return;
            }
            $service = $services->findById($booking->serviceId());
            if ($service === null) {
                return;
            }
            $account = $accounts->findSingle();
            if ($account === null) {
                $syncLogRepo->append(level: 'warn', direction: 'wp_to_g', entity: 'booking', entityId: $bookingId, googleEventId: null, action: $action, status: 'failed', payload: [], errorMessage: 'No Google account connected');
                return;
            }
            try {
                $gateway = $clientBuilder->buildGateway($account);
            } catch (\Throwable $e) {
                $syncLogRepo->append(level: 'error', direction: 'wp_to_g', entity: 'booking', entityId: $bookingId, googleEventId: $booking->googleEventId(), action: $action, status: 'failed', payload: [], errorMessage: 'Token refresh failed: ' . $e->getMessage());
                return;
            }
            $job = new \Slash\Booking\Google\PushEventJob(findBooking: fn() => $booking, findAccount: fn() => $account, persistBooking: fn(\Slash\Booking\Domain\Booking $b) => $bookings->save($b), gateway: $gateway, formatter: $formatter, service: $service, log: function (array $entry) use ($syncLogRepo): void {
                $syncLogRepo->append(level: (string) $entry['level'], direction: (string) $entry['direction'], entity: (string) $entry['entity'], entityId: (int) $entry['entity_id'], googleEventId: $entry['google_event_id'] !== null ? (string) $entry['google_event_id'] : null, action: (string) $entry['action'], status: (string) $entry['status'], payload: [], errorMessage: $entry['error_message'] !== null ? (string) $entry['error_message'] : null);
            });
            $job->handle($bookingId, $action);
        }, 10, 2);
        // ----- Google sync inbound (Plan 4) -----
        $busyRepo = new \Slash\Booking\Persistence\BusyBlockRepository($wpdb);
        $watchMgr = new \Slash\Booking\Google\WatchChannelManager(persist: fn(\Slash\Booking\Domain\GoogleAccount $a) => $accounts->save($a), ttlSeconds: 604800);
        // Custom 15-minute cron interval. Filter must be present at every boot
        // (cron itself fires from WP-Cron which calls these filters at runtime).
        add_filter('cron_schedules', static function (array $s): array {
            if (!isset($s['sb_fifteen_minutes'])) {
                $s['sb_fifteen_minutes'] = ['interval' => 900, 'display' => 'Every 15 minutes (SlashBooking)'];
            }
            return $s;
        });
        // SyncEngine factory closure (each pull builds a new instance with fresh closures).
        $buildSyncEngine = static function () use ($bookings, $busyRepo, $accounts, $syncLogRepo): \Slash\Booking\Google\SyncEngine {
            return new \Slash\Booking\Google\SyncEngine(findBookingByEventId: function (string $eventId) use ($bookings): ?int {
                $b = $bookings->findByGoogleEventId($eventId);
                return $b?->id();
            }, upsertBusyBlock: fn(\Slash\Booking\Domain\BusyBlock $bb) => $busyRepo->upsertFromGoogle($bb), deleteBusyBlock: fn(int $accountId, string $sourceId) => $busyRepo->deleteBySourceId($accountId, $sourceId), persistAccount: fn(\Slash\Booking\Domain\GoogleAccount $a) => $accounts->save($a), log: function (array $entry) use ($syncLogRepo): void {
                $syncLogRepo->append(level: (string) $entry['level'], direction: (string) $entry['direction'], entity: (string) $entry['entity'], entityId: $entry['entity_id'] !== null ? (int) $entry['entity_id'] : null, googleEventId: $entry['google_event_id'] !== null ? (string) $entry['google_event_id'] : null, action: (string) $entry['action'], status: (string) $entry['status'], payload: is_array($entry['payload'] ?? null) ? $entry['payload'] : [], errorMessage: $entry['error_message'] !== null ? (string) $entry['error_message'] : null);
            });
        };
        // Action Scheduler handler for inbound pulls.
        // Producers (webhook controller, admin "pull now", cron fallback) call:
        //   as_schedule_single_action(time() + 5, 'sb/google_pull', [$accountId], 'slashbooking')
        // RestRouter constructs its own enqueuePull closure with the same hook name — keeps the
        // producer-side wiring close to the controllers without dragging Plugin.php into them.
        add_action('sb/google_pull', static function (int $accountId) use ($accounts, $clientBuilder, $buildSyncEngine, $syncLogRepo): void {
            // Paid feature: pause all Google sync when the license is not valid
            // (downgrade). Data is preserved; sync resumes on re-validation.
            if (!\Slash\Booking\Config::isPaid()) {
                return;
            }
            $job = new \Slash\Booking\Google\PullEventJob(findAccount: fn(int $id) => $accounts->findById($id), buildGateway: fn(\Slash\Booking\Domain\GoogleAccount $a) => $clientBuilder->buildGateway($a), pull: fn(\Slash\Booking\Domain\GoogleAccount $a, \Slash\Booking\Google\CalendarGateway $g) => $buildSyncEngine()->pull($a, $g), log: function (array $entry) use ($syncLogRepo): void {
                $syncLogRepo->append(level: (string) $entry['level'], direction: (string) $entry['direction'], entity: (string) $entry['entity'], entityId: $entry['entity_id'] !== null ? (int) $entry['entity_id'] : null, googleEventId: $entry['google_event_id'] !== null ? (string) $entry['google_event_id'] : null, action: (string) $entry['action'], status: (string) $entry['status'], payload: is_array($entry['payload'] ?? null) ? $entry['payload'] : [], errorMessage: $entry['error_message'] !== null ? (string) $entry['error_message'] : null);
            });
            $job->handle($accountId);
        }, 10, 1);
        // Daily watch renewal check: renew if expiration < now+1 day.
        add_action('sb/watch_renew_check', static function () use ($accounts, $clientBuilder, $watchMgr, $syncLogRepo): void {
            $account = $accounts->findSingle();
            if ($account === null) {
                return;
            }
            $expiresAt = $account->watchExpiresAt();
            $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
            $threshold = $now->modify('+1 day');
            if ($expiresAt !== null && $expiresAt > $threshold) {
                return;
                // Still > 24h before expiration.
            }
            try {
                $gateway = $clientBuilder->buildGateway($account);
                $webhookUrl = rest_url(\Slash\Booking\Plugin::REST_NAMESPACE . '/google/webhook');
                $watchMgr->renew($account, $gateway, $webhookUrl);
                $syncLogRepo->append(level: 'info', direction: 'internal', entity: 'watch', entityId: $account->id(), googleEventId: null, action: 'watch_renewed', status: 'ok', payload: ['channelId' => $account->watchChannelId()], errorMessage: null);
            } catch (\Throwable $e) {
                $syncLogRepo->append(level: 'error', direction: 'internal', entity: 'watch', entityId: $account->id(), googleEventId: null, action: 'watch_renew', status: 'failed', payload: [], errorMessage: $e->getMessage());
            }
        });
        // 15-min cron fallback: enqueue one pull per account (V1: a single account).
        add_action('sb/google_pull_all', static function () use ($accounts): void {
            $account = $accounts->findSingle();
            if ($account === null) {
                return;
            }
            if (function_exists('as_schedule_single_action')) {
                as_schedule_single_action(time() + 5, 'sb/google_pull', [(int) $account->id()], 'slashbooking');
                return;
            }
            do_action('sb/google_pull', (int) $account->id());
        });
        // Admin notice if encryption key falls back to the database option.
        // Escalated to notice-error: while the fallback works, the Google tokens
        // are NOT protected at rest against a DB dump until the constant is set.
        if ($keyResolver->usingFallback()) {
            add_action('admin_notices', function (): void {
                if (!current_user_can('manage_options')) {
                    return;
                }
                echo '<div class="notice notice-error"><p><strong>SlashBooking — ' . esc_html__('sécurité', 'slashbooking') . ' :</strong> ' . esc_html__('la clé de chiffrement est stockée dans la base de données. Les tokens Google ne sont donc PAS protégés en cas de fuite de la base. Définissez la constante', 'slashbooking') . ' <code>SLASHBOOKING_ENC_KEY</code> ' . esc_html__('dans', 'slashbooking') . ' <code>wp-config.php</code> ' . esc_html__('pour utiliser une clé hors base.', 'slashbooking') . '</p></div>';
            });
        }
        (new \Slash\Booking\Admin\TurnstileNotice())->register();
        (new \Slash\Booking\Admin\AdminMenu())->register();
        (new \Slash\Booking\Admin\Assets($this))->register();
        (new \Slash\Booking\Admin\DashboardWidget($bookings, $services))->register();
        if (defined('WP_CLI') && \WP_CLI) {
            $pullNow = static function (\Slash\Booking\Domain\GoogleAccount $account) use ($clientBuilder, $buildSyncEngine): \Slash\Booking\Google\PullResult {
                if (!\Slash\Booking\Config::isPaid()) {
                    return new \Slash\Booking\Google\PullResult();
                }
                $gateway = $clientBuilder->buildGateway($account);
                return $buildSyncEngine()->pull($account, $gateway);
            };
            /** @phpstan-ignore-next-line Class.NotFound (WP_CLI conditionally available) */
            \WP_CLI::add_command('slashbooking doctor', new \Slash\Booking\Cli\DoctorCommand($accounts, $clientBuilder, $pullNow));
        }
        add_action('init', [$this, 'loadTextDomain']);
    }
    public function loadTextDomain(): void
    {
        load_plugin_textdomain(self::TEXT_DOMAIN, \false, dirname(plugin_basename($this->pluginFile)) . '/languages');
    }
}
