"""
AliothPress Plugin System.

Discovers, license-gates, and loads plugins from the `plugins/` directory
next to app.py. Design goals:

  * Plugins install only on LICENSED installations of the core CMS.
    (The free build works, but plugin loading requires a valid core license.)
  * A plugin may itself require its OWN license key (sold separately,
    e.g. on Gumroad as a separate product). Validation goes through the
    same update server as the core license, with a `product_id` so the
    server knows which marketplace product to check the key against.
  * Third-party / self-written plugins are explicitly allowed: a plugin
    whose manifest has  "license_required": false  loads without any
    plugin key (core license is still required).
  * Python cannot safely unload imported code, so enable/disable/install/
    delete take effect after an app restart. The CMS schedules this
    restart automatically (reusing the updater's restart mechanism); a
    one-click restart button appears as a fallback only where the host
    can't restart on its own (e.g. a local dev server).

Plugin layout:

    plugins/
      my_plugin/
        plugin.json        <- manifest (required)
        __init__.py        <- must expose  register(ctx)  (required)
        i18n/              <- optional: en.json, de.json, ... + public.json
        templates/         <- optional (register via your Blueprint)
        static/            <- optional (register via your Blueprint)

Manifest (plugin.json):

    {
      "id": "my_plugin",            // ^[a-z0-9_]{2,50}$, must equal folder name
      "name": "My Plugin",
      "version": "1.0.0",
      "description": "What it does",
      "author": "Jane Doe",
      "author_url": "https://example.com",
      "license_required": false,    // true => needs its own license key
      "product_id": "",             // sent to the update server when validating
      "min_cms_version": "2.0.0"
    }

Entry point (__init__.py):

    def register(ctx):
        # ctx.app, ctx.db, ctx.plugin_dir, ctx.settings_get/, ctx.settings_set
        bp = Blueprint('my_plugin', __name__,
                       url_prefix='/admin/my-plugin',
                       template_folder=os.path.join(ctx.plugin_dir, 'templates'),
                       static_folder=os.path.join(ctx.plugin_dir, 'static'),
                       static_url_path='/plugin-static/my_plugin')
        ...
        ctx.register_blueprint(bp)
        ctx.register_admin_nav('my_plugin.nav_label', 'my_plugin.index', icon='🧩')
        ctx.register_i18n_dir(os.path.join(ctx.plugin_dir, 'i18n'))

    def migrate(ctx):   # optional, runs at every startup before register()
        ...             # keep it idempotent, like core migrations

Security note: installing a plugin means running its Python code with the
same privileges as the CMS itself. That is the same trust model as
WordPress plugins. Installation is therefore restricted to the OWNER role,
uploads are zip-slip-checked, and the admin UI shows an explicit warning
for third-party code.
"""

import hashlib
import hmac
import importlib
import importlib.util
import json
import logging
import os
import re
import shutil
import sys
import zipfile

from flask import (
    Blueprint, abort, render_template, request, redirect, url_for, flash
)
from flask_login import current_user

logger = logging.getLogger('aliothpress.plugins')

PLUGIN_ID_RE = re.compile(r'^[a-z0-9_]{2,50}$')
MAX_PLUGIN_ZIP_BYTES = 50 * 1024 * 1024          # 50 MB compressed

# Setting-key suffixes the core itself stores under plugin_<id>_... —
# a plugin writing these would corrupt its own enable state or license
# data (e.g. plugin_<id>_enabled holds 'true'/'false' for the loader).
RESERVED_SETTING_SUFFIXES = frozenset((
    'enabled', 'license_key_encrypted', 'license_last_checked',
    'license_plan', 'license_sig', 'license_valid',
    'trial_started', 'trial_days',
))


class PluginError(ValueError):
    """Engine-raised plugin error carrying an i18n key + params.

    The admin UI translates it at display time (current session language);
    str() renders English for the server log / tracebacks.
    """

    def __init__(self, key, **params):
        self.key = key
        self.params = params
        super().__init__(key)

    def __str__(self):
        try:
            from i18n import t_for
            return t_for(self.key, 'en', **self.params)
        except Exception:
            return self.key


def _display_plugin_error(err):
    """load_errors may hold a PluginError (engine-origin, i18n key) or a
    plain string (an arbitrary third-party exception, shown as-is)."""
    if err is None:
        return None
    if isinstance(err, PluginError):
        from i18n import t
        return t(err.key, **err.params)
    return err
MAX_PLUGIN_UNPACKED_BYTES = 200 * 1024 * 1024    # 200 MB unpacked
_PLUGIN_LICENSE_SALT = b'AP_PLUGIN_LIC_integrity_v1_7d21'

# Module-level state (mirrors how i18n.py keeps its caches)
_state = {
    'app': None,
    'db': None,
    'deps': None,            # dict of callables injected from app.py
    'plugins_dir': None,
    'discovered': {},        # id -> manifest dict (+ '_dir', '_error')
    'loaded': {},            # id -> manifest of successfully loaded plugins
    'load_errors': {},       # id -> error string (register() raised, etc.)
    'nav_items': [],         # [{'label_key','endpoint','icon','plugin_id'}]
    'restart_pending': False,
    'core_licensed_at_boot': False,
}


# ── License helpers (per-plugin, same integrity scheme as the core) ─────────

def _plugin_sig(plugin_id, encrypted_key, last_checked):
    """HMAC signature binding a plugin's license_valid flag to its key,
    timestamp, plugin id, and this installation's SECRET_KEY. Prevents
    flipping plugin_<id>_license_valid to 'true' by editing the DB."""
    app = _state['app']
    payload = f'{plugin_id}|{encrypted_key}|{last_checked}'.encode()
    return hmac.new(
        app.config['SECRET_KEY'].encode() + _PLUGIN_LICENSE_SALT,
        payload, hashlib.sha256
    ).hexdigest()


def plugin_license_valid(plugin_id):
    """True if this plugin has a stored, untampered, valid license."""
    sget = _state['deps']['settings_get']
    if sget(f'plugin_{plugin_id}_license_valid', 'false') != 'true':
        return False
    enc = sget(f'plugin_{plugin_id}_license_key_encrypted', '') or ''
    if not enc:
        return False
    stored = sget(f'_plugin_{plugin_id}_license_sig', '') or ''
    last = sget(f'plugin_{plugin_id}_license_last_checked', '') or ''
    if not stored:
        return False
    return hmac.compare_digest(stored, _plugin_sig(plugin_id, enc, last))


def _save_plugin_license_result(plugin_id, license_key, result):
    """Persist a validation result (valid or invalid) with integrity sig."""
    deps = _state['deps']
    from datetime import datetime, timezone
    now = datetime.now(timezone.utc).isoformat()
    if result.get('valid'):
        enc = deps['encrypt'](license_key)
        deps['settings_set_many']({
            f'plugin_{plugin_id}_license_key_encrypted': enc,
            f'plugin_{plugin_id}_license_valid': 'true',
            f'plugin_{plugin_id}_license_last_checked': now,
            f'plugin_{plugin_id}_license_plan': result.get('plan', 'standard'),
            f'_plugin_{plugin_id}_license_sig': _plugin_sig(plugin_id, enc, now),
        })
    else:
        deps['settings_set_many']({
            f'plugin_{plugin_id}_license_valid': 'false',
            f'plugin_{plugin_id}_license_last_checked': now,
            f'_plugin_{plugin_id}_license_sig': '',
        })


# ── Trial (paid plugins: free period, counted from installation) ────────────

def _trial_settings(plugin_id):
    """(started_iso, days) from settings; ('', 0) when no trial recorded."""
    sget = _state['deps']['settings_get']
    started = sget(f'plugin_{plugin_id}_trial_started', '') or ''
    try:
        days = int(sget(f'plugin_{plugin_id}_trial_days', '0') or '0')
    except (TypeError, ValueError):
        days = 0
    return started, days


def start_trial_if_needed(plugin_id, manifest, catalog_entry=None):
    """Record the trial start at INSTALL time for a paid plugin.

    Runs once per site: if a start date is already stored, nothing changes —
    re-installing or updating the plugin never restarts the trial.
    trial_days comes from the update-server catalog entry when available
    (the number the owner sets in the server admin), with the plugin
    manifest's optional "trial_days" as fallback for manual ZIP installs.
    0 / absent => no trial (paid plugin needs a key from day one).
    """
    if not manifest.get('license_required'):
        return
    if plugin_license_valid(plugin_id):
        return
    # Trial days must not burn while the plugin sits installed-but-disabled:
    # the countdown starts the first time the plugin is ENABLED.
    if _state['deps']['settings_get'](
            f'plugin_{plugin_id}_enabled', 'false') != 'true':
        return
    started, _days = _trial_settings(plugin_id)
    if started:
        return
    days = 0
    if isinstance(catalog_entry, dict):
        try:
            days = int(catalog_entry.get('trial_days') or 0)
        except (TypeError, ValueError):
            days = 0
    if days <= 0:
        try:
            days = int(manifest.get('trial_days') or 0)
        except (TypeError, ValueError):
            days = 0
    if days <= 0:
        return
    from datetime import datetime, timezone
    _state['deps']['settings_set_many']({
        f'plugin_{plugin_id}_trial_started':
            datetime.now(timezone.utc).isoformat(),
        f'plugin_{plugin_id}_trial_days': str(days),
    })
    logger.info(f'Plugin {plugin_id}: trial started ({days} days)')


def plugin_trial_days_left(plugin_id):
    """Whole days of trial remaining; 0 = expired / never started."""
    started, days = _trial_settings(plugin_id)
    if not started or days <= 0:
        return 0
    from datetime import datetime, timezone
    try:
        start_dt = datetime.fromisoformat(started)
    except ValueError:
        return 0
    if start_dt.tzinfo is None:
        start_dt = start_dt.replace(tzinfo=timezone.utc)
    left = days - (datetime.now(timezone.utc) - start_dt).days
    return max(0, left)


def plugin_trial_active(plugin_id):
    return plugin_trial_days_left(plugin_id) > 0


def _plugin_unlocked(plugin_id, manifest):
    """The license gate: free plugin, valid key, or active trial."""
    if not manifest.get('license_required'):
        return True
    return plugin_license_valid(plugin_id) or plugin_trial_active(plugin_id)


# ── Plugin updates (check against the update server) ────────────────────────
#
# Check results are cached in the settings table (key: _plugin_updates_cache)
# rather than in _state: with several gunicorn workers the POST that ran the
# check and the GET that renders the page may land on different processes,
# and the cache must also survive the restart that applies an update.

_UPDATES_CACHE_KEY = '_plugin_updates_cache'

# ── "Restart in flight" marker ───────────────────────────────────────────────
# When a graceful restart has been SCHEDULED (systemd/gunicorn, a few seconds
# of delay), the very next page render happens BEFORE the restart fires. In
# that window the plugin badges truthfully say "restart required" — which
# reads as an instruction to the user, who then reloads the page, hits the
# server mid-restart, sees a connection error, and panics. The marker lives
# in the settings table (visible to every worker) and lets the page show a
# calm "restarting, this page will refresh itself" banner with a poller
# instead. It is cleared on startup (a fresh process means the restart
# happened) and expires on its own as a safety net in case the scheduled
# restart silently never fires — after which the derived restart_needed()
# banner with the manual button takes over.

_RESTART_SCHEDULED_KEY = '_plugin_restart_scheduled_at'
_RESTART_WINDOW_SECONDS = 60


def _mark_restart_scheduled():
    from datetime import datetime, timezone
    try:
        _state['deps']['settings_set'](
            _RESTART_SCHEDULED_KEY, datetime.now(timezone.utc).isoformat())
    except Exception:
        logger.exception('Could not persist restart-scheduled marker')


def restart_in_progress():
    """True if a graceful restart was scheduled within the last minute."""
    from datetime import datetime, timezone
    raw = _state['deps']['settings_get'](_RESTART_SCHEDULED_KEY, '') or ''
    if not raw:
        return False
    try:
        scheduled = datetime.fromisoformat(raw)
    except ValueError:
        return False
    if scheduled.tzinfo is None:
        scheduled = scheduled.replace(tzinfo=timezone.utc)
    age = (datetime.now(timezone.utc) - scheduled).total_seconds()
    return 0 <= age <= _RESTART_WINDOW_SECONDS


def _get_updates_cache():
    """{'checked_at': iso-str, 'updates': {id: info}} or empty equivalent."""
    raw = _state['deps']['settings_get'](_UPDATES_CACHE_KEY, '') or ''
    try:
        data = json.loads(raw)
        if isinstance(data, dict) and isinstance(data.get('updates'), dict):
            return data
    except (ValueError, TypeError):
        pass
    return {'checked_at': '', 'updates': {}}


def _set_updates_cache(updates):
    from datetime import datetime, timezone
    _state['deps']['settings_set'](_UPDATES_CACHE_KEY, json.dumps({
        'checked_at': datetime.now(timezone.utc).isoformat(),
        'updates': updates,
    }))


def _clear_update_entry(plugin_id):
    """Drop one plugin from the cache (after a successful update/delete)."""
    cache = _get_updates_cache()
    if plugin_id in cache['updates']:
        del cache['updates'][plugin_id]
        _state['deps']['settings_set'](_UPDATES_CACHE_KEY, json.dumps(cache))


def _core_license_key():
    """Decrypted core CMS license key, or ''."""
    enc = _state['deps']['settings_get']('license_key_encrypted', '') or ''
    if not enc:
        return ''
    try:
        return _state['deps']['decrypt'](enc)
    except Exception:
        return ''


def _plugin_license_key(plugin_id):
    """Decrypted per-plugin license key, or ''."""
    enc = _state['deps']['settings_get'](
        f'plugin_{plugin_id}_license_key_encrypted', '') or ''
    if not enc:
        return ''
    try:
        return _state['deps']['decrypt'](enc)
    except Exception:
        return ''


_AUTO_CHECK_ATTEMPT_KEY = '_plugin_updates_last_attempt'
_AUTO_CHECK_INTERVAL_SECONDS = 24 * 3600


_CATALOG_CACHE_KEY = 'plugin_catalog_cache'


def _get_catalog_cache():
    """{'fetched_at': iso-str, 'plugins': [entry, ...]} or empty equivalent."""
    raw = _state['deps']['settings_get'](_CATALOG_CACHE_KEY, '') or ''
    try:
        data = json.loads(raw)
        if isinstance(data, dict) and isinstance(data.get('plugins'), list):
            return data
    except (ValueError, TypeError):
        pass
    return {'fetched_at': '', 'plugins': []}


def _set_catalog_cache(plugins):
    from datetime import datetime, timezone
    _state['deps']['settings_set'](_CATALOG_CACHE_KEY, json.dumps({
        'fetched_at': datetime.now(timezone.utc).isoformat(),
        'plugins': plugins,
    }))


def _fetch_catalog(timeout=15):
    """Fetch the plugin catalog from the update server into the cache.

    Returns the error string ('' on success). The previous cache is kept
    on failure, exactly like the update check.
    """
    try:
        from updater import PluginCatalogFetcher
        fetcher = PluginCatalogFetcher(
            _state['app'].config.get('UPDATE_SERVER_URL', ''))
        fetcher.timeout = timeout
        result = fetcher.fetch(license_key=_core_license_key())
        if result.get('error'):
            return result['error']
        _set_catalog_cache(result.get('plugins', []))
        return ''
    except Exception:
        logger.exception('Plugin catalog fetch failed')
        return 'Plugin catalog fetch failed.'


def _maybe_auto_check_updates():
    """Silent daily update check when the Plugins page is opened.

    Mirrors the core's "checks update server daily (or on demand)" model:
    the button stays for on-demand checks (e.g. right after publishing a
    release), but the owner no longer has to remember to press it.

    Deliberately quiet and cheap:
      * at most one attempt per 24h — gated on a LAST-ATTEMPT timestamp in
        the settings table, so an unreachable update server delays one page
        load per day, not every page load until it recovers;
      * short timeout (5s instead of the interactive 15s);
      * failures keep the previous cache and produce no flash — the manual
        button is the loud path, this is the background one.
    """
    from datetime import datetime, timezone
    if not _state['deps']['is_core_licensed']():
        return
    sget = _state['deps']['settings_get']
    raw = sget(_AUTO_CHECK_ATTEMPT_KEY, '') or ''
    if raw:
        try:
            last = datetime.fromisoformat(raw)
            if last.tzinfo is None:
                last = last.replace(tzinfo=timezone.utc)
            age = (datetime.now(timezone.utc) - last).total_seconds()
            if 0 <= age < _AUTO_CHECK_INTERVAL_SECONDS:
                return
        except ValueError:
            pass
    installed = [
        {'id': pid, 'version': m.get('version', '0.0.0')}
        for pid, m in _state['discovered'].items() if not m.get('_error')
    ]
    _state['deps']['settings_set'](
        _AUTO_CHECK_ATTEMPT_KEY, datetime.now(timezone.utc).isoformat())
    try:
        _fetch_catalog(timeout=5)   # same silent daily rhythm
    except Exception:
        logger.exception('Silent plugin catalog fetch failed')
    if not installed:
        return
    try:
        from updater import PluginUpdateChecker
        checker = PluginUpdateChecker(
            _state['app'].config.get('UPDATE_SERVER_URL', ''))
        checker.timeout = 5
        result = checker.check(installed, license_key=_core_license_key())
        if not result.get('error'):
            _set_updates_cache(result.get('updates', {}))
    except Exception:
        logger.exception('Silent plugin update check failed')


def _pending_update_for(plugin_id):
    """Update info for a plugin if the cached latest_version is still newer
    than what is installed right now (the cache may predate an update)."""
    info = _get_updates_cache()['updates'].get(plugin_id)
    if not info:
        return None
    m = _state['discovered'].get(plugin_id) or {}
    installed = m.get('version', '0.0.0')
    latest = info.get('latest_version', '0.0.0')
    if _version_tuple(latest) <= _version_tuple(installed):
        return None
    return info


# ── Discovery ────────────────────────────────────────────────────────────────

def _version_tuple(v):
    """'2.3.0' -> (2, 3, 0). Non-numeric parts become 0; tolerant of junk."""
    parts = []
    for chunk in str(v).split('.'):
        num = ''.join(ch for ch in chunk if ch.isdigit())
        parts.append(int(num) if num else 0)
    return tuple(parts) if parts else (0,)


def _read_manifest(plugin_dir):
    manifest_path = os.path.join(plugin_dir, 'plugin.json')
    with open(manifest_path, 'r', encoding='utf-8') as f:
        m = json.load(f)
    pid = m.get('id', '')
    folder = os.path.basename(plugin_dir)
    if not PLUGIN_ID_RE.match(pid):
        raise PluginError('plugins.err_invalid_id', pid=repr(pid))
    if pid != folder:
        raise PluginError('plugins.err_id_mismatch',
                          pid=repr(pid), folder=repr(folder))
    if not os.path.exists(os.path.join(plugin_dir, '__init__.py')):
        raise PluginError('plugins.err_missing_init')
    # Enforce min_cms_version: a plugin built for a newer CMS shows up as
    # an error with a clear message instead of failing cryptically later.
    min_ver = m.get('min_cms_version', '')
    if min_ver:
        try:
            from updater import get_current_version
            current = get_current_version()
            if current and _version_tuple(current) < _version_tuple(min_ver):
                raise PluginError('plugins.err_needs_cms',
                                  min_ver=min_ver, current=current)
        except ImportError:
            pass  # updater unavailable (tests) — skip the check
    m.setdefault('name', pid)
    m.setdefault('version', '0.0.0')
    m.setdefault('description', '')
    m.setdefault('author', '')
    m.setdefault('author_url', '')
    m.setdefault('license_required', False)
    m.setdefault('product_id', '')
    m['_dir'] = plugin_dir
    return m


def discover_plugins():
    """Scan the plugins directory. Populates _state['discovered']."""
    _state['discovered'] = {}
    pdir = _state['plugins_dir']
    if not os.path.isdir(pdir):
        return
    for entry in sorted(os.listdir(pdir)):
        full = os.path.join(pdir, entry)
        if not os.path.isdir(full):
            continue
        if not os.path.exists(os.path.join(full, 'plugin.json')):
            continue
        try:
            m = _read_manifest(full)
            _state['discovered'][m['id']] = m
        except Exception as e:
            _state['discovered'][entry] = {
                'id': entry, 'name': entry, 'version': '', 'description': '',
                'author': '', 'author_url': '', 'license_required': False,
                'product_id': '', '_dir': full,
                '_error': e if isinstance(e, PluginError) else str(e),
            }


def plugin_status(plugin_id):
    """Compute display status for a plugin.

    Returns one of: 'error', 'needs_core_license', 'disabled',
                    'needs_license', 'pending_restart', 'active'
    """
    m = _state['discovered'].get(plugin_id) or {}
    if m.get('_error'):
        return 'error'
    if not _state['deps']['is_core_licensed']():
        return 'needs_core_license'
    sget = _state['deps']['settings_get']
    if sget(f'plugin_{plugin_id}_enabled', 'false') != 'true':
        return 'disabled'
    if not _plugin_unlocked(plugin_id, m):
        return 'needs_license'
    if plugin_id in _state['load_errors']:
        return 'error'
    if plugin_id not in _state['loaded']:
        # Enabled + licensed, but not loaded in this process => needs restart
        return 'pending_restart'
    return 'active'


def restart_needed():
    """True when the running process no longer matches disk + settings.

    IMPORTANT: this is DERIVED state, computed fresh on every call from
    durable facts (settings table, plugin folders on disk, what this worker
    actually has in memory). It deliberately does NOT rely on the
    per-process _state['restart_pending'] flag: with several gunicorn
    workers the POST that discovers "restart required" and the GET that
    renders the page routinely land on different processes, so a flag set
    in one worker is invisible to its siblings — and the manual-restart
    fallback button silently never appears. Deriving the answer instead
    makes the restart banner show up in EVERY worker whenever any of the
    following is true, and disappear on its own after a successful restart:

      * a plugin is enabled (and licensed, if required) but not loaded here
      * a plugin is still loaded here although it was disabled or deleted
      * a loaded plugin's files on disk are a different version than the
        code running in memory (i.e. it was just updated)
    """
    sget = _state['deps']['settings_get']
    core_licensed = _state['deps']['is_core_licensed']()
    for pid, m in _state['discovered'].items():
        if m.get('_error'):
            continue
        enabled = sget(f'plugin_{pid}_enabled', 'false') == 'true'
        loaded = pid in _state['loaded']
        if enabled and not loaded:
            if not core_licensed:
                continue  # blocked on the core license, not on a restart
            if not _plugin_unlocked(pid, m):
                continue  # blocked on the plugin license, not on a restart
            if pid in _state['load_errors']:
                continue  # failed to load — a plain restart won't fix it
            return True
        if loaded and not enabled:
            return True  # disabled, but its code is still in memory
        if loaded and enabled and not _plugin_unlocked(pid, m):
            return True  # license/trial lapsed under a running plugin
        if loaded and _state['loaded'][pid].get('version') != m.get('version'):
            return True  # files updated under a running plugin
    for pid in _state['loaded']:
        if pid not in _state['discovered']:
            return True  # deleted, but its code is still in memory
    return False


def plugin_nav_items():
    """Sidebar entries: loaded plugins' own tabs + muted "locked" tabs.

    A paid plugin that is enabled but locked (trial over / license lapsed,
    so it is not loaded) keeps its place in the sidebar: a muted 🔒 tab
    pointing to the explanation page, instead of vanishing without a word.
    The label reuses the manifest name (t() returns unknown keys as-is).
    """
    items = list(_state['nav_items'])
    try:
        sget = _state['deps']['settings_get']
        for pid, m in _state['discovered'].items():
            if m.get('_error') or not m.get('license_required'):
                continue
            if sget(f'plugin_{pid}_enabled', 'false') != 'true':
                continue
            if pid in _state['loaded']:
                continue  # running — it registered its own tabs
            if _plugin_unlocked(pid, m):
                continue  # only awaiting a restart, not a license problem
            items.append({
                'label_key': m.get('name') or pid,
                'endpoint': 'plugins_admin.admin_plugins_locked',
                'endpoint_args': {'plugin_id': pid},
                'icon': '🔒',
                'plugin_id': pid,
                'locked': True,
            })
    except Exception:
        logger.exception('locked-tab synthesis failed')
    return items


# ── Plugin context (the API surface plugin authors code against) ────────────

class PluginContext:
    def __init__(self, manifest):
        self._manifest = manifest
        self.plugin_id = manifest['id']
        self.plugin_dir = manifest['_dir']
        self.app = _state['app']
        self.db = _state['db']

    # -- settings (namespaced automatically to avoid collisions) --
    def settings_get(self, key, default=None):
        return _state['deps']['settings_get'](
            f'plugin_{self.plugin_id}_{key}', default)

    def settings_set(self, key, value):
        if key in RESERVED_SETTING_SUFFIXES:
            raise PluginError('plugins.err_reserved_setting',
                              plugin=self.plugin_id, key=key)
        _state['deps']['settings_set'](f'plugin_{self.plugin_id}_{key}', value)

    # -- integration points --
    def register_blueprint(self, bp):
        self.app.register_blueprint(bp)

    def register_admin_nav(self, label_key, endpoint, icon='🧩'):
        """Add an entry to the admin sidebar (Plugins section).
        label_key is an i18n key (falls back to the raw string if missing)."""
        _state['nav_items'].append({
            'label_key': label_key, 'endpoint': endpoint,
            'icon': icon, 'plugin_id': self.plugin_id,
        })

    def register_i18n_dir(self, path):
        """Merge the plugin's translation files into the admin/public i18n."""
        try:
            from i18n import register_extra_i18n_dir
            register_extra_i18n_dir(path)
        except ImportError:
            logger.warning('i18n extra-dir support not available')

    def license_plan(self):
        """For licensed plugins: 'standard' / 'extended' / '' if unlicensed."""
        if not plugin_license_valid(self.plugin_id):
            return ''
        return _state['deps']['settings_get'](
            f'plugin_{self.plugin_id}_license_plan', 'standard')


# ── Loading ──────────────────────────────────────────────────────────────────

def _import_plugin_module(manifest):
    """Import plugins.<id> as a real package so relative imports work."""
    pid = manifest['id']
    module_name = f'aliothpress_plugin_{pid}'
    if module_name in sys.modules:
        return sys.modules[module_name]
    spec = importlib.util.spec_from_file_location(
        module_name,
        os.path.join(manifest['_dir'], '__init__.py'),
        submodule_search_locations=[manifest['_dir']],
    )
    module = importlib.util.module_from_spec(spec)
    sys.modules[module_name] = module
    spec.loader.exec_module(module)
    return module


def load_active_plugins():
    """Import and register every plugin that is enabled and fully licensed.

    Called once at startup (inside an app context). Core license is the
    gate for the whole mechanism: without it nothing loads.
    """
    _state['core_licensed_at_boot'] = _state['deps']['is_core_licensed']()
    if not _state['core_licensed_at_boot']:
        if _state['discovered']:
            logger.info('Plugins present but core license is missing/invalid '
                        '— plugin loading skipped.')
        return

    sget = _state['deps']['settings_get']
    any_loaded = False
    for pid, manifest in _state['discovered'].items():
        if pid in _state['loaded']:
            continue  # already registered in this process — never re-register
        if manifest.get('_error'):
            continue
        if sget(f'plugin_{pid}_enabled', 'false') != 'true':
            continue
        if not _plugin_unlocked(pid, manifest):
            logger.info(f'Plugin {pid}: enabled but license missing/invalid — not loaded.')
            continue
        try:
            module = _import_plugin_module(manifest)
            ctx = PluginContext(manifest)
            if hasattr(module, 'migrate'):
                module.migrate(ctx)
            module.register(ctx)
            _state['loaded'][pid] = manifest
            any_loaded = True
            logger.info(f'Plugin loaded: {pid} v{manifest.get("version")}')
        except Exception as e:
            logger.exception(f'Plugin {pid} failed to load')
            _state['load_errors'][pid] = (
                e if isinstance(e, PluginError) else str(e))

    if any_loaded:
        # Create any new tables plugin models declared. Idempotent —
        # existing tables are left untouched. Column-level changes are the
        # plugin's own job via its optional migrate(ctx).
        try:
            _state['db'].create_all()
        except Exception:
            logger.exception('db.create_all() after plugin load failed')


# ── ZIP install (owner-only, zip-slip safe) ──────────────────────────────────

def _install_plugin_zip(fileobj, expected_id=None):
    """Validate and unpack a plugin ZIP. Returns (ok, msg_key, plugin_id).

    Args:
        fileobj: a seekable binary file object — an upload's .stream or an
            open('rb') handle of a downloaded update package. Both install
            paths (manual upload, update from the update server) run through
            this one function so every safety check applies identically.
        expected_id: if given, the ZIP's manifest id MUST match it. Used by
            the updater so a package downloaded for plugin A can never
            overwrite plugin B.
    """
    # Size cap (compressed)
    fileobj.seek(0, os.SEEK_END)
    size = fileobj.tell()
    fileobj.seek(0)
    if size > MAX_PLUGIN_ZIP_BYTES:
        return False, 'plugins.err_too_big', None

    try:
        zf = zipfile.ZipFile(fileobj)
    except zipfile.BadZipFile:
        return False, 'plugins.err_not_zip', None

    names = zf.namelist()
    if not names:
        return False, 'plugins.err_not_zip', None

    # Reject dangerous entries + unpacked size bomb
    total_unpacked = 0
    for info in zf.infolist():
        n = info.filename.replace('\\', '/')
        if n.startswith('/') or '..' in n.split('/'):
            return False, 'plugins.err_invalid_plugin', None
        total_unpacked += info.file_size
    if total_unpacked > MAX_PLUGIN_UNPACKED_BYTES:
        return False, 'plugins.err_too_big', None

    # Layout: either plugin.json at zip root, or a single top-level folder
    roots = {n.split('/', 1)[0] for n in names if n.strip('/')}
    if 'plugin.json' in names:
        prefix = ''
        manifest_name = 'plugin.json'
    elif len(roots) == 1:
        root = next(iter(roots))
        prefix = root + '/'
        manifest_name = prefix + 'plugin.json'
        if manifest_name not in names:
            return False, 'plugins.err_invalid_plugin', None
    else:
        return False, 'plugins.err_invalid_plugin', None

    try:
        manifest = json.loads(zf.read(manifest_name).decode('utf-8'))
    except Exception:
        return False, 'plugins.err_invalid_plugin', None

    pid = manifest.get('id', '')
    if not PLUGIN_ID_RE.match(pid):
        return False, 'plugins.err_invalid_plugin', None
    if expected_id and pid != expected_id:
        return False, 'plugins.err_invalid_plugin', None
    if (prefix + '__init__.py' if prefix else '__init__.py') not in names:
        return False, 'plugins.err_invalid_plugin', None

    target = os.path.join(_state['plugins_dir'], pid)
    fresh_install = not os.path.exists(target)
    # Updating an existing plugin: replace its folder atomically-ish
    tmp_target = target + '.installing'
    if os.path.exists(tmp_target):
        shutil.rmtree(tmp_target, ignore_errors=True)
    os.makedirs(tmp_target, exist_ok=True)

    try:
        for info in zf.infolist():
            n = info.filename.replace('\\', '/')
            if prefix:
                if not n.startswith(prefix):
                    continue
                rel = n[len(prefix):]
            else:
                rel = n
            if not rel or rel.endswith('/'):
                continue
            dest = os.path.normpath(os.path.join(tmp_target, rel))
            if not dest.startswith(os.path.normpath(tmp_target) + os.sep):
                raise ValueError('zip-slip attempt')
            os.makedirs(os.path.dirname(dest), exist_ok=True)
            with zf.open(info) as src, open(dest, 'wb') as out:
                shutil.copyfileobj(src, out)
        # Final sanity: manifest must parse from disk and match folder rules
        _read_manifest_check = _read_manifest  # readability
        installed_manifest = None
        try:
            installed_manifest = _read_manifest_check(tmp_target)
        except ValueError:
            # id/folder mismatch is expected here (folder is *.installing);
            # re-check id only.
            with open(os.path.join(tmp_target, 'plugin.json'), 'r',
                      encoding='utf-8') as f:
                mm = json.load(f)
            if not PLUGIN_ID_RE.match(mm.get('id', '')):
                raise
        if os.path.exists(target):
            shutil.rmtree(target)
        os.rename(tmp_target, target)
    except Exception:
        shutil.rmtree(tmp_target, ignore_errors=True)
        logger.exception('Plugin install failed')
        return False, 'plugins.err_install_failed', None

    # New installs start disabled — the owner enables them explicitly.
    if fresh_install:
        _state['deps']['settings_set'](f'plugin_{pid}_enabled', 'false')
    discover_plugins()
    return True, 'plugins.installed_ok', pid


# ── Admin UI (Blueprint) ─────────────────────────────────────────────────────

def _build_admin_blueprint():
    bp = Blueprint('plugins_admin', __name__)

    def _owner_guard():
        from i18n import t
        if not (current_user.is_authenticated and current_user.is_owner()):
            flash(t('flash.permission_denied'), 'error')
            return redirect(url_for('admin_dashboard'))
        return None

    def _plugin_rows():
        deps = _state['deps']
        rows = []
        for pid, m in _state['discovered'].items():
            rows.append({
                'manifest': m,
                'has_icon': os.path.isfile(
                    os.path.join(m.get('_dir', ''), 'icon.png')),
                'status': plugin_status(pid),
                'enabled': deps['settings_get'](
                    f'plugin_{pid}_enabled', 'false') == 'true',
                'licensed': plugin_license_valid(pid),
                'trial_days_left': (plugin_trial_days_left(pid)
                                    if m.get('license_required') else 0),
                'has_stored_key': bool(deps['settings_get'](
                    f'plugin_{pid}_license_key_encrypted', '')) if
                    m.get('license_required') else False,
                'load_error': _display_plugin_error(
                    _state['load_errors'].get(pid) or m.get('_error')),
                'update_info': None if m.get('_error') else _pending_update_for(pid),
            })
        return rows

    def _auto_restart():
        """Schedule a graceful restart, mirroring the core update flow.

        Same mechanism as after CMS updates (systemd -> SIGHUP -> .reload).
        Returns True if a restart was scheduled. If only the 'manual'
        strategy is available, no restart is scheduled and the pending
        banner + button remain as fallback — identical UX to updates.
        """
        from i18n import t
        try:
            from updater import UpdatePerformer
            performer = UpdatePerformer(
                _state['app'].config.get('UPDATE_SERVER_URL', ''))
            restart_method, _msg = performer.schedule_restart(delay_seconds=4)
            if restart_method != 'manual':
                _state['restart_pending'] = False
                _mark_restart_scheduled()
                flash(t('plugins.restart_scheduled'), 'success')
                return True
        except Exception:
            logger.exception('Auto-restart scheduling failed')
        _state['restart_pending'] = True
        flash(t('plugins.restart_required'), 'warning')
        return False

    @bp.route('/admin/plugins')
    def admin_plugins():
        guard = _owner_guard()
        if guard:
            return guard
        discover_plugins()
        _maybe_auto_check_updates()

        # Installed plugins: paginated so the page keeps a bounded height
        # no matter how many plugins are installed.
        rows = _plugin_rows()
        rows.sort(key=lambda r: (r['manifest'].get('name') or '').lower())
        per_page = 10
        try:
            page = max(1, int(request.args.get('p', 1)))
        except (TypeError, ValueError):
            page = 1
        pages = max(1, -(-len(rows) // per_page))
        page = min(page, pages)
        page_rows = rows[(page - 1) * per_page: page * per_page]

        # Catalog: everything published on the update server that is NOT
        # installed yet (cached; refreshed silently once a day or via the
        # refresh button). The template caps the visible cards and offers
        # a client-side name search over the full list.
        installed_ids = set(_state['discovered'].keys())
        catalog = [entry for entry in _get_catalog_cache()['plugins']
                   if isinstance(entry, dict)
                   and entry.get('id') not in installed_ids]

        return render_template(
            'admin/plugins.html',
            plugins=page_rows,
            plugins_page=page, plugins_pages=pages,
            plugins_total=len(rows),
            catalog=catalog,
            update_server_url=_state['app'].config.get(
                'UPDATE_SERVER_URL', '').rstrip('/'),
            core_licensed=_state['deps']['is_core_licensed'](),
            # OR of the ephemeral flag (still useful in the worker that just
            # tried to schedule a restart) and the durable, derived answer —
            # see restart_needed() for why the flag alone is not enough.
            restart_pending=_state['restart_pending'] or restart_needed(),
            restart_in_progress=restart_in_progress(),
        )

    @bp.route('/admin/plugins/catalog/refresh', methods=['POST'])
    def admin_plugins_catalog_refresh():
        """On-demand catalog refresh (the loud path; daily fetch is silent)."""
        from i18n import t
        guard = _owner_guard()
        if guard:
            return guard
        if not _state['deps']['is_core_licensed']():
            flash(t('plugins.core_license_note'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))
        error = _fetch_catalog()
        if error:
            flash(t('plugins.catalog_error', error=error), 'error')
        else:
            flash(t('plugins.catalog_refreshed'), 'success')
        return redirect(url_for('plugins_admin.admin_plugins'))

    @bp.route('/admin/plugins/install-from-server/<plugin_id>',
              methods=['POST'])
    def admin_plugins_install_from_server(plugin_id):
        """One-click install of a catalog plugin, downloaded from the
        update server.

        Same pipeline as a plugin update (download -> checksum ->
        _install_plugin_zip with a pinned expected_id), so every validation
        that protects a manual upload protects a catalog install too.
        Paid plugins accept the buyer's key in the form (used for the
        download; the key is then activated on the installed card as
        usual).
        """
        from i18n import t
        guard = _owner_guard()
        if guard:
            return guard
        if not _state['deps']['is_core_licensed']():
            flash(t('plugins.core_license_note'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        entry = None
        for item in _get_catalog_cache()['plugins']:
            if isinstance(item, dict) and item.get('id') == plugin_id:
                entry = item
                break
        if entry is None:
            # Stale cache (e.g. first worker after a fresh release) —
            # refetch once before giving up.
            if not _fetch_catalog():
                for item in _get_catalog_cache()['plugins']:
                    if isinstance(item, dict) and item.get('id') == plugin_id:
                        entry = item
                        break
        if entry is None:
            flash(t('plugins.err_not_found'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        min_cms = (entry.get('min_cms_version') or '').strip()
        if min_cms:
            try:
                from updater import get_current_version
                current = get_current_version()
                if current and _version_tuple(current) < _version_tuple(min_cms):
                    flash(t('plugins.update_needs_cms', version=min_cms),
                          'error')
                    return redirect(url_for('plugins_admin.admin_plugins'))
            except ImportError:
                pass

        download_url = entry.get('download_url', '')
        if not download_url:
            flash(t('plugins.core_license_note'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        # Paid plugins: the buyer's key from the card form unlocks the
        # download. Free plugins: the core license key.
        if entry.get('requires_plugin_license'):
            key = (request.form.get('license_key') or '').strip() \
                  or _core_license_key()
        else:
            key = _core_license_key()

        from updater import UpdatePerformer
        performer = UpdatePerformer(
            _state['app'].config.get('UPDATE_SERVER_URL', ''))
        zip_path = performer.download_update(
            download_url, entry.get('checksum', ''), license_key=key)
        if not zip_path:
            flash(t('plugins.catalog_install_failed'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        try:
            with open(zip_path, 'rb') as fh:
                ok, msg_key, pid = _install_plugin_zip(
                    fh, expected_id=plugin_id)
        finally:
            shutil.rmtree(performer.temp_dir, ignore_errors=True)

        flash(t(msg_key), 'success' if ok else 'error')
        if ok:
            _audit('install_plugin', pid)
            discover_plugins()
            m_new = _state['discovered'].get(pid) or {}
            if entry.get('requires_plugin_license'):
                # A key typed into the catalog card must not evaporate:
                # it unlocked the download above — now it also becomes the
                # plugin's saved license, exactly as if entered on the
                # installed card. Inconclusive (server unreachable) saves
                # nothing; an invalid key reports itself and the trial
                # path stays available.
                form_key = (request.form.get('license_key') or '').strip()
                activated = False
                if form_key:
                    validator = _state['deps']['license_validator_factory']()
                    result = validator.validate(
                        form_key, site_url=request.host_url.rstrip('/'),
                        product_id=m_new.get('product_id', ''))
                    if result.get('inconclusive'):
                        flash(result.get('error')
                              or t('plugins.license_invalid'), 'error')
                    elif result.get('valid'):
                        _save_plugin_license_result(pid, form_key, result)
                        _audit('activate_plugin_license', pid)
                        flash(t('plugins.license_valid'), 'success')
                        activated = True
                    else:
                        flash(result.get('error')
                              or t('plugins.license_invalid'), 'error')
                if not activated:
                    try:
                        _t_days = int(entry.get('trial_days') or 0)
                    except (TypeError, ValueError):
                        _t_days = 0
                    if _t_days <= 0:
                        try:
                            _t_days = int(m_new.get('trial_days') or 0)
                        except (TypeError, ValueError):
                            _t_days = 0
                    if _t_days > 0 and not plugin_license_valid(pid):
                        flash(t('plugins.trial_on_enable', days=_t_days), 'info')
                    else:
                        flash(t('plugins.catalog_activate_hint'), 'info')
        return redirect(url_for('plugins_admin.admin_plugins'))

    @bp.route('/admin/plugins/icon/<plugin_id>')
    def admin_plugins_icon(plugin_id):
        """Serve an installed plugin's icon.png (any logged-in admin)."""
        from flask_login import current_user
        if not getattr(current_user, 'is_authenticated', False):
            abort(404)
        if not PLUGIN_ID_RE.match(plugin_id or ''):
            abort(404)
        _ensure_discovered(plugin_id)
        m = _state['discovered'].get(plugin_id)
        if not m or not os.path.isfile(
                os.path.join(m.get('_dir', ''), 'icon.png')):
            abort(404)
        from flask import send_from_directory
        response = send_from_directory(m['_dir'], 'icon.png')
        response.headers['Cache-Control'] = 'private, max-age=3600'
        return response

    @bp.route('/admin/plugins/install', methods=['POST'])
    def admin_plugins_install():
        from i18n import t
        guard = _owner_guard()
        if guard:
            return guard
        if not _state['deps']['is_core_licensed']():
            flash(t('plugins.core_license_note'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))
        f = request.files.get('plugin_zip')
        if not f or not f.filename:
            flash(t('plugins.err_not_zip'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))
        ok, msg_key, pid = _install_plugin_zip(f.stream)
        flash(t(msg_key), 'success' if ok else 'error')
        if ok:
            _audit('install_plugin', pid)
            discover_plugins()
            start_trial_if_needed(pid, _state['discovered'].get(pid) or {})
            if pid in _state['loaded']:
                # Updated the files of a plugin that is running in this
                # process — restart so the new code is actually loaded.
                _auto_restart()
        return redirect(url_for('plugins_admin.admin_plugins'))

    @bp.route('/admin/plugins/check-updates', methods=['POST'])
    def admin_plugins_check_updates():
        """Ask the update server which installed plugins have newer versions."""
        from i18n import t
        guard = _owner_guard()
        if guard:
            return guard
        if not _state['deps']['is_core_licensed']():
            flash(t('plugins.core_license_note'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        discover_plugins()
        installed = [
            {'id': pid, 'version': m.get('version', '0.0.0')}
            for pid, m in _state['discovered'].items()
            if not m.get('_error')
        ]
        if not installed:
            flash(t('plugins.no_plugins'), 'warning')
            return redirect(url_for('plugins_admin.admin_plugins'))

        from updater import PluginUpdateChecker
        checker = PluginUpdateChecker(
            _state['app'].config.get('UPDATE_SERVER_URL', ''))
        result = checker.check(installed, license_key=_core_license_key())

        if result.get('error'):
            # Keep the previous cache — a flaky network must not hide
            # updates that were already known.
            flash(t('plugins.update_check_failed', error=result['error']),
                  'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        updates = result.get('updates', {})
        _set_updates_cache(updates)
        available = [pid for pid in updates if _pending_update_for(pid)]
        if available:
            flash(t('plugins.updates_found', count=len(available)), 'success')
        else:
            flash(t('plugins.up_to_date'), 'success')
        return redirect(url_for('plugins_admin.admin_plugins'))

    @bp.route('/admin/plugins/<plugin_id>/update', methods=['POST'])
    def admin_plugins_update(plugin_id):
        """Download and install a plugin update from the update server.

        Same pipeline as the core update (download → checksum → apply), but
        the 'apply' step is the plugin ZIP installer — so every validation
        that protects a manual upload protects an automatic update too.
        """
        from i18n import t
        guard = _owner_guard()
        if guard:
            return guard
        if not _state['deps']['is_core_licensed']():
            flash(t('plugins.core_license_note'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))
        _ensure_discovered(plugin_id)
        m = _state['discovered'].get(plugin_id)
        if not m:
            flash(t('plugins.err_not_found'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        info = _pending_update_for(plugin_id)
        if not info:
            # Stale page or first visit on another worker — re-check once.
            from updater import PluginUpdateChecker
            checker = PluginUpdateChecker(
                _state['app'].config.get('UPDATE_SERVER_URL', ''))
            result = checker.check(
                [{'id': plugin_id, 'version': m.get('version', '0.0.0')}],
                license_key=_core_license_key())
            if result.get('updates'):
                cache = _get_updates_cache()
                cache['updates'].update(result['updates'])
                _set_updates_cache(cache['updates'])
                info = _pending_update_for(plugin_id)
        if not info:
            flash(t('plugins.err_no_update'), 'warning')
            return redirect(url_for('plugins_admin.admin_plugins'))

        # A plugin release may require a newer CMS — refuse early with a
        # clear message instead of installing something that cannot load.
        min_cms = (info.get('min_cms_version') or '').strip()
        if min_cms:
            try:
                from updater import get_current_version
                current = get_current_version()
                if current and _version_tuple(current) < _version_tuple(min_cms):
                    flash(t('plugins.update_needs_cms', version=min_cms),
                          'error')
                    return redirect(url_for('plugins_admin.admin_plugins'))
            except ImportError:
                pass

        download_url = info.get('download_url', '')
        if not download_url:
            # The server only hands out download URLs to licensed checks.
            flash(t('plugins.core_license_note'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        # Paid plugins are downloaded with their own key (the server
        # validates it against the mapped marketplace product); free
        # plugins with the core key. Owner keys pass either way.
        if info.get('requires_plugin_license'):
            key = _plugin_license_key(plugin_id) or _core_license_key()
        else:
            key = _core_license_key()

        from updater import UpdatePerformer
        performer = UpdatePerformer(
            _state['app'].config.get('UPDATE_SERVER_URL', ''))
        zip_path = performer.download_update(
            download_url, info.get('checksum', ''), license_key=key)
        if not zip_path:
            flash(t('plugins.update_failed'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        try:
            with open(zip_path, 'rb') as fh:
                ok, msg_key, pid = _install_plugin_zip(
                    fh, expected_id=plugin_id)
        finally:
            shutil.rmtree(performer.temp_dir, ignore_errors=True)

        if ok:
            _clear_update_entry(plugin_id)
            flash(t('plugins.update_ok',
                    version=info.get('latest_version', '')), 'success')
            _audit('update_plugin', plugin_id)
            discover_plugins()  # fresh manifest (may have just become paid)
            start_trial_if_needed(plugin_id,
                                  _state['discovered'].get(plugin_id) or {})
            if plugin_id in _state['loaded']:
                # New files on disk, old code in memory — restart to load it.
                _auto_restart()
        else:
            flash(t(msg_key), 'error')
        return redirect(url_for('plugins_admin.admin_plugins'))

    def _ensure_discovered(plugin_id):
        """Re-scan the plugins dir if this worker doesn't know the id yet.

        With multiple gunicorn workers, per-process state diverges: the
        worker that handled the ZIP upload has discovered the new plugin,
        but a toggle/delete/license POST may land on a sibling worker that
        booted before the folder existed. Discovery is a cheap directory
        scan, so refresh on miss instead of returning a false 'not found'.
        """
        if plugin_id not in _state['discovered']:
            discover_plugins()

    @bp.route('/admin/plugins/<plugin_id>/toggle', methods=['POST'])
    def admin_plugins_toggle(plugin_id):
        from i18n import t
        guard = _owner_guard()
        if guard:
            return guard
        _ensure_discovered(plugin_id)
        if plugin_id not in _state['discovered']:
            flash(t('plugins.err_not_found'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))
        deps = _state['deps']
        key = f'plugin_{plugin_id}_enabled'
        enabled = deps['settings_get'](key, 'false') == 'true'
        deps['settings_set'](key, 'false' if enabled else 'true')
        flash(t('plugins.disabled_ok' if enabled else 'plugins.enabled_ok'),
              'success')
        if not enabled:
            # Just ENABLED: a paid plugin without a key starts its trial now.
            m_t = _state['discovered'].get(plugin_id) or {}
            had_trial = bool(_trial_settings(plugin_id)[0])
            start_trial_if_needed(plugin_id, m_t)
            if not had_trial and plugin_trial_active(plugin_id):
                flash(t('plugins.trial_started',
                        days=plugin_trial_days_left(plugin_id)), 'info')
        _audit('toggle_plugin', plugin_id)
        # Restart automatically so the change takes effect immediately —
        # same graceful mechanism as after core updates.
        _auto_restart()
        return redirect(url_for('plugins_admin.admin_plugins'))

    @bp.route('/admin/plugins/<plugin_id>/delete', methods=['POST'])
    def admin_plugins_delete(plugin_id):
        from i18n import t
        guard = _owner_guard()
        if guard:
            return guard
        _ensure_discovered(plugin_id)
        m = _state['discovered'].get(plugin_id)
        if not m:
            flash(t('plugins.err_not_found'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))
        shutil.rmtree(m['_dir'], ignore_errors=True)
        deps = _state['deps']
        for suffix in ('enabled', 'license_key_encrypted', 'license_valid',
                       'license_last_checked', 'license_plan'):
            deps['settings_set'](f'plugin_{plugin_id}_{suffix}', '')
        deps['settings_set'](f'_plugin_{plugin_id}_license_sig', '')
        _clear_update_entry(plugin_id)
        discover_plugins()
        flash(t('plugins.removed_ok'), 'success')
        _audit('delete_plugin', plugin_id)
        if plugin_id in _state['loaded']:
            # Its code is still in this process's memory — restart to unload.
            _auto_restart()
        return redirect(url_for('plugins_admin.admin_plugins'))

    @bp.route('/admin/plugins/<plugin_id>/license', methods=['POST'])
    def admin_plugins_license(plugin_id):
        from i18n import t
        guard = _owner_guard()
        if guard:
            return guard
        _ensure_discovered(plugin_id)
        m = _state['discovered'].get(plugin_id)
        if not m:
            flash(t('plugins.err_not_found'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        license_key = (request.form.get('license_key') or '').strip()
        if not license_key:
            flash(t('plugins.err_empty_key'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        validator = _state['deps']['license_validator_factory']()
        site_url = request.host_url.rstrip('/')
        result = validator.validate(license_key, site_url=site_url,
                                    product_id=m.get('product_id', ''))
        if result.get('inconclusive'):
            flash(result.get('error') or t('plugins.license_invalid'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        _save_plugin_license_result(plugin_id, license_key, result)
        if result.get('valid'):
            flash(t('plugins.license_valid'), 'success')
            _audit('activate_plugin_license', plugin_id)
            if _state['deps']['settings_get'](
                    f'plugin_{plugin_id}_enabled', 'false') == 'true':
                # Enabled + now licensed => load it via a graceful restart.
                _auto_restart()
        else:
            flash(result.get('error') or t('plugins.license_invalid'), 'error')
        return redirect(url_for('plugins_admin.admin_plugins'))

    @bp.route('/admin/plugins/locked/<plugin_id>')
    def admin_plugins_locked(plugin_id):
        """Explanation page behind a locked plugin's muted sidebar tab.

        Shown when a paid plugin is enabled but its trial ended / license
        lapsed: instead of the tab silently disappearing (or a bare 404),
        the tab stays — muted — and leads here: "trial over, enter the
        key, nothing was deleted". Owners see the key form right on the
        page; other admins see the explanation.
        """
        from i18n import t
        from flask_login import current_user
        if not getattr(current_user, 'is_authenticated', False):
            abort(404)
        _ensure_discovered(plugin_id)
        m = _state['discovered'].get(plugin_id)
        if not m or not m.get('license_required'):
            abort(404)
        if _plugin_unlocked(plugin_id, m):
            # Unlocked again (key entered / re-validated) — nothing to
            # explain; the plugin returns after the pending restart.
            return redirect(url_for('plugins_admin.admin_plugins'))
        sget = _state['deps']['settings_get']
        return render_template(
            'admin/plugin_locked.html',
            plugin=m,
            plugin_id=plugin_id,
            has_stored_key=bool(sget(
                f'plugin_{plugin_id}_license_key_encrypted', '')),
            is_owner=bool(getattr(current_user, 'is_owner', lambda: False)()),
        )

    @bp.route('/admin/plugins/<plugin_id>/license/recheck', methods=['POST'])
    def admin_plugins_license_recheck(plugin_id):
        """Re-validate the STORED key without retyping it.

        The "Check payment" button: after a paused subscription is paid
        again, one click re-runs validation and the plugin comes back.
        Inconclusive answers (server unreachable) change nothing.
        """
        from i18n import t
        guard = _owner_guard()
        if guard:
            return guard
        _ensure_discovered(plugin_id)
        m = _state['discovered'].get(plugin_id)
        if not m:
            flash(t('plugins.err_not_found'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        raw_key = _plugin_license_key(plugin_id)
        if not raw_key:
            flash(t('plugins.err_no_stored_key'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        validator = _state['deps']['license_validator_factory']()
        result = validator.validate(raw_key,
                                    site_url=request.host_url.rstrip('/'),
                                    product_id=m.get('product_id', ''))
        if result.get('inconclusive'):
            flash(result.get('error') or t('plugins.license_invalid'), 'error')
            return redirect(url_for('plugins_admin.admin_plugins'))

        was_valid = plugin_license_valid(plugin_id)
        _save_plugin_license_result(plugin_id, raw_key, result)
        sget = _state['deps']['settings_get']
        if result.get('valid'):
            flash(t('plugins.license_valid'), 'success')
            _audit('recheck_plugin_license', plugin_id)
            if (not was_valid
                    and sget(f'plugin_{plugin_id}_enabled', 'false') == 'true'):
                # Paused plugin paid again => bring it back automatically.
                _auto_restart()
        else:
            flash(result.get('error') or t('plugins.license_invalid'), 'error')
            if was_valid and plugin_id in _state['loaded']:
                # Running plugin lost its license => take it offline (404).
                _auto_restart()
        return redirect(url_for('plugins_admin.admin_plugins'))

    @bp.before_app_request
    def _plugin_license_revalidation_hook():
        """Mirror of the core license hook, for plugin keys.

        On admin visits only, every stored plugin key is re-validated when
        its last check is older than 7 days. Authoritative answers are
        saved (a lapsed subscription goes dark on the next admin visit);
        inconclusive answers (outages) keep the stored state — a paying
        user is never downgraded because a server was unreachable.
        Never runs on public pages: a site whose admin nobody opens
        keeps working untouched.
        """
        if not request.path.startswith('/admin'):
            return
        if request.path.startswith('/admin/static'):
            return
        try:
            from datetime import datetime, timezone, timedelta
            sget = _state['deps']['settings_get']
            for pid, m in list(_state['discovered'].items()):
                if not m.get('license_required') or m.get('_error'):
                    continue
                raw_key = _plugin_license_key(pid)
                if not raw_key:
                    # Catch-up trial start: a paid plugin with no key and no
                    # trial ever recorded (e.g. it was updated to its paid
                    # version while an older core was still running, so the
                    # install/update hook never fired) starts its trial NOW
                    # instead of locking instantly.
                    started, _days = _trial_settings(pid)
                    if not started:
                        entry = None
                        for item in _get_catalog_cache()['plugins']:
                            if (isinstance(item, dict)
                                    and item.get('id') == pid):
                                entry = item
                                break
                        start_trial_if_needed(pid, m, catalog_entry=entry)
                    continue
                last = sget(f'plugin_{pid}_license_last_checked', '') or ''
                due = True
                if last:
                    try:
                        last_dt = datetime.fromisoformat(last)
                        if last_dt.tzinfo is None:
                            last_dt = last_dt.replace(tzinfo=timezone.utc)
                        due = (datetime.now(timezone.utc) - last_dt
                               > timedelta(days=7))
                    except ValueError:
                        due = True
                if not due:
                    continue
                validator = _state['deps']['license_validator_factory']()
                result = validator.validate(
                    raw_key, site_url=request.host_url.rstrip('/'),
                    product_id=m.get('product_id', ''))
                if result.get('inconclusive'):
                    continue
                _save_plugin_license_result(pid, raw_key, result)
        except Exception:
            logger.exception('Plugin license revalidation failed')

    @bp.route('/admin/plugins/restart', methods=['POST'])
    def admin_plugins_restart():
        from i18n import t
        guard = _owner_guard()
        if guard:
            return guard
        try:
            from updater import UpdatePerformer
            performer = UpdatePerformer(
                _state['app'].config.get('UPDATE_SERVER_URL', ''))
            restart_method, restart_msg = performer.schedule_restart(delay_seconds=2)
            if restart_method != 'manual':
                _state['restart_pending'] = False
                _mark_restart_scheduled()
                flash(t('plugins.restart_scheduled'), 'success')
            else:
                # No auto-restart available (e.g. localhost dev server).
                # Keep the banner, tell the truth: same message the core
                # updater shows in this situation.
                _state['restart_pending'] = True
                flash(restart_msg or t('plugins.restart_required'), 'warning')
        except Exception as e:
            logger.exception('Restart failed')
            flash(str(e), 'error')
        return redirect(url_for('plugins_admin.admin_plugins'))

    return bp


def _audit(action, plugin_id):
    """Write to the core audit log if available (best effort)."""
    try:
        from models import AuditLog, db
        AuditLog.log(action, 'plugin', None, plugin_id,
                     user=current_user, ip=request.remote_addr)
        db.session.commit()
    except Exception:
        pass


# ── Public init ──────────────────────────────────────────────────────────────

def init_plugin_system(app, db, *, settings_get, settings_set,
                       settings_set_many, is_core_licensed, encrypt, decrypt,
                       license_validator_factory):
    """Wire the plugin system into the app. Call once at startup,
    AFTER init_database() so Settings are readable."""
    _state.update({
        'app': app, 'db': db,
        'deps': {
            'settings_get': settings_get,
            'settings_set': settings_set,
            'settings_set_many': settings_set_many,
            'is_core_licensed': is_core_licensed,
            'encrypt': encrypt,
            'decrypt': decrypt,
            'license_validator_factory': license_validator_factory,
        },
        'plugins_dir': os.path.join(
            os.path.dirname(os.path.abspath(__file__)), 'plugins'),
    })
    os.makedirs(_state['plugins_dir'], exist_ok=True)

    app.register_blueprint(_build_admin_blueprint())

    # Sidebar helper for templates
    app.jinja_env.globals['plugin_nav_items'] = plugin_nav_items

    with app.app_context():
        discover_plugins()
        load_active_plugins()
        # A fresh process means any previously scheduled restart has
        # happened — retire the "restart in flight" marker so the admin
        # page stops showing the restarting banner. Best effort: a DB
        # hiccup here must never block startup.
        try:
            if settings_get(_RESTART_SCHEDULED_KEY, ''):
                settings_set(_RESTART_SCHEDULED_KEY, '')
        except Exception:
            logger.exception('Could not clear restart-scheduled marker')
