"""
AliothPress — License & Auto-Update System
=============================================

Supports license validation via:
  • Envato (purchase codes)
  • LemonSqueezy (license keys)
  • Gumroad (license keys)

Update flow:
  1. CMS checks update server daily (or on demand from admin)
  2. If valid license → server returns latest version + download URL
  3. Admin clicks "Update Now" → backup → download → extract → migrate → restart

Security:
  • All requests use HTTPS
  • License keys are stored encrypted (same Fernet scheme as SMTP passwords)
  • Update packages are verified by SHA-256 checksum
  • Full backup is created before any update
  • Only owner/admin can access license & update features
"""

import os
import re
import sys
import json
import shutil
import hashlib
import zipfile
import logging
import requests
import subprocess
from datetime import datetime, timedelta, timezone
def utc_now():
    """Naive UTC datetime — drop-in replacement for the deprecated stdlib utcnow()."""
    return datetime.now(timezone.utc).replace(tzinfo=None)


logger = logging.getLogger(__name__)

# ── Current version (read from version.txt in project root) ─────────────────

VERSION_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'version.txt')

def get_current_version():
    """Read current CMS version from version.txt"""
    try:
        with open(VERSION_FILE, 'r') as f:
            return f.read().strip()
    except FileNotFoundError:
        return '1.0.0'


def set_current_version(version):
    """Write new version to version.txt after update"""
    with open(VERSION_FILE, 'w') as f:
        f.write(version.strip())


# ── License key format detection ─────────────────────────────────────────────

def detect_platform(license_key):
    """Auto-detect which marketplace a license key belongs to.
    
    Returns: 'envato', 'lemonsqueezy', 'gumroad', or 'unknown'
    """
    if not license_key or not isinstance(license_key, str):
        return 'unknown'
    
    key = license_key.strip()
    
    # Envato purchase codes: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (UUID format)
    if re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', key, re.IGNORECASE):
        return 'envato'
    
    # LemonSqueezy keys: typically alphanumeric with dashes, longer format
    # Example: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX or similar
    if re.match(r'^[A-Z0-9]{8}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{12}$', key):
        return 'lemonsqueezy'
    
    # Gumroad keys: shorter, typically alphanumeric
    # Example: XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX
    if re.match(r'^[A-F0-9]{8}-[A-F0-9]{8}-[A-F0-9]{8}-[A-F0-9]{8}$', key, re.IGNORECASE):
        return 'gumroad'
    
    # If format doesn't match known patterns, try the update server
    # (it can identify the key by checking all platforms)
    return 'unknown'


# ── License validation ───────────────────────────────────────────────────────

class LicenseValidator:
    """Validates license keys against the AliothPress update server.
    
    The update server acts as a proxy — it receives the key, determines the 
    platform, validates against the marketplace API, and returns the result.
    This way, marketplace API credentials stay on YOUR server, not in every CMS install.
    """
    
    def __init__(self, update_server_url):
        self.update_server_url = update_server_url.rstrip('/')
        self.timeout = 15  # seconds
    
    def validate(self, license_key, site_url='', product_id=''):
        """Validate a license key.
        
        Args:
            license_key: The key from any supported marketplace
            site_url: The URL of the site activating the license (for tracking)
            product_id: Optional product identifier. Empty = the core CMS.
                For plugin licenses, the plugin manifest's product_id is sent
                so the update server validates against the right marketplace
                product. Servers that predate plugins ignore the extra field.
        
        Returns:
            dict: {
                'valid': bool,
                'platform': 'envato'|'lemonsqueezy'|'gumroad',
                'plan': 'standard'|'extended',
                'expires': '2026-12-31' or None (lifetime),
                'error': 'Error message' or None
            }
        """
        try:
            payload = {
                'license_key': license_key,
                'site_url': site_url,
                'cms_version': get_current_version(),
            }
            if product_id:
                payload['product_id'] = product_id
            response = requests.post(
                f'{self.update_server_url}/api/v1/license/validate',
                json=payload,
                timeout=self.timeout,
                headers={'Content-Type': 'application/json'}
            )
            
            if response.status_code == 200:
                # Authoritative answer (valid:true or valid:false).
                try:
                    return response.json()
                except ValueError:
                    return {
                        'valid': False,
                        'inconclusive': True,
                        'error': 'License server returned a malformed response.'
                    }
            else:
                # Could not get an authoritative answer — do NOT treat as invalid.
                return {
                    'valid': False,
                    'inconclusive': True,
                    'error': f'Server returned {response.status_code}'
                }
        
        except requests.ConnectionError:
            return {
                'valid': False,
                'inconclusive': True,
                'error': 'Could not connect to license server. Please check your internet connection.'
            }
        except requests.Timeout:
            return {
                'valid': False,
                'inconclusive': True,
                'error': 'License server did not respond in time. Please try again.'
            }
        except Exception as e:
            logger.error(f'License validation error: {e}')
            return {
                'valid': False,
                'inconclusive': True,
                'error': 'An unexpected error occurred during validation.'
            }
    
    def deactivate(self, license_key, site_url=''):
        """Deactivate a license (e.g., when moving to a new server)."""
        try:
            response = requests.post(
                f'{self.update_server_url}/api/v1/license/deactivate',
                json={
                    'license_key': license_key,
                    'site_url': site_url,
                },
                timeout=self.timeout,
                headers={'Content-Type': 'application/json'}
            )
            return response.json() if response.status_code == 200 else {'success': False}
        except Exception as e:
            logger.error(f'License deactivation error: {e}')
            return {'success': False, 'error': str(e)}


# ── Update checker ───────────────────────────────────────────────────────────

class UpdateChecker:
    """Checks for available updates from the AliothPress update server."""
    
    def __init__(self, update_server_url):
        self.update_server_url = update_server_url.rstrip('/')
        self.timeout = 15
    
    def check(self, license_key=''):
        """Check if a newer version is available.
        
        Returns:
            dict: {
                'update_available': bool,
                'current_version': '1.0.0',
                'latest_version': '1.1.0',
                'download_url': 'https://...',
                'checksum': 'sha256:...',
                'changelog': 'What changed...',
                'release_date': '2026-03-01',
                'min_python': '3.10',
                'requires_migration': bool
            }
        """
        try:
            response = requests.post(
                f'{self.update_server_url}/api/v1/update/check',
                json={
                    'license_key': license_key,
                    'current_version': get_current_version(),
                },
                timeout=self.timeout,
                headers={'Content-Type': 'application/json'}
            )
            
            if response.status_code == 200:
                data = response.json()
                data['current_version'] = get_current_version()
                return data
            else:
                return {
                    'update_available': False,
                    'current_version': get_current_version(),
                    'error': f'Server returned {response.status_code}'
                }
        
        except requests.ConnectionError:
            return {
                'update_available': False,
                'current_version': get_current_version(),
                'error': 'Could not connect to update server.'
            }
        except Exception as e:
            logger.error(f'Update check error: {e}')
            return {
                'update_available': False,
                'current_version': get_current_version(),
                'error': 'Update check failed.'
            }


# ── Plugin update checker ────────────────────────────────────────────────────

class PluginUpdateChecker:
    """Checks for available PLUGIN updates from the AliothPress update server.

    One batched request for all installed plugins — the server answers only
    for plugins it actually has releases for, so third-party / self-written
    plugins are silently skipped (they simply never appear in `updates`).
    """

    def __init__(self, update_server_url):
        self.update_server_url = update_server_url.rstrip('/')
        self.timeout = 15

    def check(self, installed, license_key=''):
        """Check which of the installed plugins have updates.

        Args:
            installed: list of {'id': str, 'version': str}
            license_key: the core CMS license key (needed for download URLs)

        Returns:
            dict: {
                'updates': {
                    '<plugin_id>': {
                        'latest_version': '1.0.2',
                        'changelog': '...',
                        'release_date': '2026-07-04',
                        'min_cms_version': '2.3.0',
                        'requires_plugin_license': False,
                        'download_url': 'https://...',   # only if licensed
                        'checksum': 'sha256:...',        # only if licensed
                    }, ...
                },
                'error': '...'   # only on failure; 'updates' is {} then
            }
        """
        try:
            response = requests.post(
                f'{self.update_server_url}/api/v1/plugins/update/check',
                json={
                    'license_key': license_key,
                    'cms_version': get_current_version(),
                    'plugins': installed,
                },
                timeout=self.timeout,
                headers={'Content-Type': 'application/json'}
            )
            if response.status_code == 200:
                data = response.json()
                if not isinstance(data.get('updates'), dict):
                    data['updates'] = {}
                return data
            # 404 means the endpoint does not exist — i.e. the update server
            # is still running a pre-plugins build. Surfacing this as an
            # actionable error (instead of a silent "no updates") is what
            # tells the server owner that deploying the new update_server.py
            # (and restarting it!) is the missing step.
            if response.status_code == 404:
                return {'updates': {}, 'error': (
                    'The update server has no plugin-update endpoint '
                    '(HTTP 404). Deploy the new update_server.py to the '
                    'update server and restart it.')}
            return {'updates': {}, 'error': f'Server returned {response.status_code}'}

        except requests.ConnectionError:
            return {'updates': {}, 'error': 'Could not connect to update server.'}
        except requests.Timeout:
            return {'updates': {}, 'error': 'Update server did not respond in time.'}
        except Exception as e:
            logger.error(f'Plugin update check error: {e}')
            return {'updates': {}, 'error': 'Plugin update check failed.'}


# ── Update performer ─────────────────────────────────────────────────────────

class PluginCatalogFetcher:
    """Fetches the full plugin catalog from the AliothPress update server.

    Backs the "Available plugins" block on Admin -> Plugins: one request
    returns every plugin published on the server (name, description,
    version, icon flag, and — for licensed installations — the download
    URL and checksum used by the one-click install).
    """

    def __init__(self, update_server_url):
        self.update_server_url = update_server_url.rstrip('/')
        self.timeout = 15

    def fetch(self, license_key=''):
        """Fetch the catalog.

        Returns:
            dict: {'plugins': [...], 'error': '...'}  # error only on failure
        """
        try:
            response = requests.post(
                f'{self.update_server_url}/api/v1/plugins/catalog',
                json={
                    'license_key': license_key,
                    'cms_version': get_current_version(),
                },
                timeout=self.timeout,
                headers={'Content-Type': 'application/json'}
            )
            if response.status_code == 200:
                data = response.json()
                if not isinstance(data.get('plugins'), list):
                    data['plugins'] = []
                return data
            if response.status_code == 404:
                return {'plugins': [], 'error': (
                    'The update server has no plugin-catalog endpoint '
                    '(HTTP 404). Deploy the new update_server.py to the '
                    'update server and restart it.')}
            return {'plugins': [],
                    'error': f'Server returned {response.status_code}'}
        except requests.ConnectionError:
            return {'plugins': [], 'error': 'Could not connect to update server.'}
        except requests.Timeout:
            return {'plugins': [], 'error': 'Update server did not respond in time.'}
        except Exception as e:
            logger.error(f'Plugin catalog fetch error: {e}')
            return {'plugins': [], 'error': 'Plugin catalog fetch failed.'}


class UpdatePerformer:
    """Downloads and applies updates safely.
    
    Update process:
    1. Download update package (ZIP)
    2. Verify SHA-256 checksum
    3. Create full backup
    4. Extract to temp directory
    5. Replace files (preserving user data: uploads, .env, database)
    6. Run database migrations if needed
    7. Update version.txt
    8. Signal restart
    """
    
    # Files/directories that should NEVER be overwritten during update
    PROTECTED_PATHS = {
        '.env',
        'version.txt',           # Updated separately after successful update
        'static/uploads',        # User media
        'static/uploads/favicon',
        'instance',              # SQLite database location
        'venv',                  # Virtual environment
        '__pycache__',
        'aliothpress.db',        # SQLite database
        'plugins',               # Installed plugins (separate products / third-party)
    }
    
    def __init__(self, update_server_url, project_dir=None):
        self.update_server_url = update_server_url.rstrip('/')
        self.project_dir = project_dir or os.path.dirname(os.path.abspath(__file__))
        self.temp_dir = os.path.join(self.project_dir, '_update_temp')
        self.backup_dir = os.path.join(self.project_dir, '_update_backup')
    
    def _find_pip(self):
        """Find the correct pip executable (venv or system)."""
        # Check for virtualenv pip first
        venv_pip = os.path.join(self.project_dir, 'venv', 'bin', 'pip')
        if os.path.isfile(venv_pip):
            return venv_pip
        
        venv_pip_win = os.path.join(self.project_dir, 'venv', 'Scripts', 'pip.exe')
        if os.path.isfile(venv_pip_win):
            return venv_pip_win
        
        # Fall back to system pip
        return 'pip3'
    
    def _find_python(self):
        """Find the correct python executable (venv or system)."""
        venv_python = os.path.join(self.project_dir, 'venv', 'bin', 'python')
        if os.path.isfile(venv_python):
            return venv_python
        
        venv_python_win = os.path.join(self.project_dir, 'venv', 'Scripts', 'python.exe')
        if os.path.isfile(venv_python_win):
            return venv_python_win
        
        return 'python3'
    
    def install_dependencies(self):
        """Install/update pip dependencies from requirements.txt after update.
        
        Returns: (success: bool, message: str)
        """
        requirements_file = os.path.join(self.project_dir, 'requirements.txt')
        if not os.path.isfile(requirements_file):
            return True, 'No requirements.txt found, skipping.'
        
        pip_path = self._find_pip()
        
        try:
            import subprocess
            result = subprocess.run(
                [pip_path, 'install', '-r', requirements_file, '--quiet', '--disable-pip-version-check'],
                cwd=self.project_dir,
                capture_output=True, text=True, timeout=120
            )
            
            if result.returncode == 0:
                return True, 'Dependencies installed successfully.'
            else:
                logger.warning(f'pip install returned {result.returncode}: {result.stderr}')
                # Non-fatal — the update itself succeeded, deps might already be fine
                return True, f'Dependencies warning: {result.stderr[:200]}'
        
        except Exception as e:
            logger.error(f'Dependency installation failed: {e}')
            return True, f'Dependencies warning: {str(e)}'
    
    def _detect_service_name(self):
        """Detect the systemd service name for this CMS instance.
        
        Priority:
          1. SERVICE_NAME in .env (explicit)
          2. Scan systemd units for one pointing to our project_dir
          3. Common defaults: 'aliothpress', 'aliothpress.service'
        """
        # 1. Explicit config
        explicit = os.getenv('SERVICE_NAME', '').strip()
        if explicit:
            return explicit
        
        # 2. Scan active systemd units
        try:
            import subprocess
            result = subprocess.run(
                ['systemctl', 'list-units', '--type=service', '--state=running', '--no-legend', '--no-pager'],
                capture_output=True, text=True, timeout=5
            )
            if result.returncode == 0:
                for line in result.stdout.strip().split('\n'):
                    svc = line.split()[0] if line.strip() else ''
                    if not svc or not svc.endswith('.service'):
                        continue
                    # Check if this service's working directory matches ours
                    try:
                        show = subprocess.run(
                            ['systemctl', 'show', svc, '--property=WorkingDirectory', '--no-pager'],
                            capture_output=True, text=True, timeout=3
                        )
                        wd = show.stdout.strip().split('=', 1)[-1] if '=' in show.stdout else ''
                        if wd and os.path.realpath(wd) == os.path.realpath(self.project_dir):
                            return svc
                    except Exception:
                        continue
        except Exception:
            pass
        
        # 3. Try common names
        for name in ['aliothpress', 'aliothpress.service']:
            try:
                import subprocess
                result = subprocess.run(
                    ['systemctl', 'is-active', name],
                    capture_output=True, text=True, timeout=3
                )
                if result.stdout.strip() == 'active':
                    return name
            except Exception:
                continue
        
        return None
    
    def _detect_gunicorn_pid(self):
        """Find gunicorn master PID if running for this project."""
        try:
            import subprocess
            result = subprocess.run(
                ['pgrep', '-f', f'gunicorn.*{self.project_dir}'],
                capture_output=True, text=True, timeout=5
            )
            if result.returncode == 0 and result.stdout.strip():
                # First PID is typically the master
                pids = result.stdout.strip().split('\n')
                return int(pids[0])
        except Exception:
            pass
        return None
    
    def schedule_restart(self, delay_seconds=2):
        """Schedule a graceful restart after a short delay.
        
        This allows the HTTP response to be sent back to the browser
        before the process is replaced.
        
        Restart strategy (in order):
          1. systemd service restart (full process restart — loads new code)
          2. gunicorn SIGHUP (fallback — only works WITHOUT --preload)
          3. Touch .reload file (last resort)
        
        NOTE: gunicorn with --preload keeps the app in the master process
        memory. SIGHUP restarts workers but they inherit the OLD code.
        That's why systemd restart is preferred — it kills the master
        and starts fresh with the updated files.
        
        For systemd to work without password prompts, add to /etc/sudoers.d/aliothpress:
          aliothpress ALL=(ALL) NOPASSWD: /bin/systemctl restart aliothpress.service
        
        Or set SERVICE_NAME in .env and configure accordingly.
        
        Returns: (method_used: str, message: str)
        """
        import subprocess
        
        # Strategy 1: systemd — preferred because it fully reloads all code
        service_name = self._detect_service_name()
        if service_name:
            # Order matters: try sudo first, because gunicorn runs as a non-root
            # service user that cannot restart systemd units directly. The plain
            # (no-sudo) attempt only works when the CMS is running as root,
            # which is unusual on production, but we keep it as a fallback.
            #
            # `sudo -n` (non-interactive) makes sudo fail immediately if the
            # current user has no NOPASSWD permission for systemctl, instead of
            # blocking forever waiting for a password that no terminal can read.
            for cmd_prefix in ['sudo -n ', '']:
                restart_cmd = f'sleep {delay_seconds} && {cmd_prefix}systemctl restart {service_name}'
                try:
                    proc = subprocess.Popen(
                        restart_cmd,
                        shell=True,
                        stdout=subprocess.DEVNULL,
                        stderr=subprocess.PIPE,
                        start_new_session=True
                    )
                    # Give it a moment to check if it fails immediately (bad sudo etc.)
                    try:
                        proc.wait(timeout=0.5)
                        if proc.returncode and proc.returncode != 0:
                            continue  # Try next prefix
                    except subprocess.TimeoutExpired:
                        pass  # Still running = good, it's waiting in `sleep`
                    
                    logger.info(f'Scheduled systemd restart ({cmd_prefix}systemctl restart {service_name}) in {delay_seconds}s')
                    return 'systemd', f'Server will restart automatically in {delay_seconds} seconds.'
                except Exception:
                    continue
        
        # Strategy 2: gunicorn SIGHUP — fallback if no systemd
        # WARNING: does NOT reload code if gunicorn uses --preload
        gunicorn_pid = self._detect_gunicorn_pid()
        if gunicorn_pid:
            try:
                subprocess.Popen(
                    f'sleep {delay_seconds} && kill -HUP {gunicorn_pid}',
                    shell=True,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    start_new_session=True
                )
                logger.info(f'Scheduled gunicorn graceful reload (PID {gunicorn_pid}) in {delay_seconds}s')
                return 'gunicorn', f'Server will reload automatically in {delay_seconds} seconds.'
            except Exception as e:
                logger.warning(f'Gunicorn SIGHUP failed: {e}')
        
        # Strategy 3: Touch .reload marker file
        reload_file = os.path.join(self.project_dir, '.reload')
        try:
            with open(reload_file, 'w') as f:
                f.write(utc_now().isoformat())
            logger.info('Created .reload marker file')
        except Exception:
            pass
        
        return 'manual', 'Auto-restart not available. Please restart the server to apply the update.'
    
    def download_update(self, download_url, expected_checksum, license_key=''):
        """Download update package and verify checksum.
        
        Returns: path to downloaded ZIP or None on failure
        """
        try:
            # Clean temp directory
            if os.path.exists(self.temp_dir):
                shutil.rmtree(self.temp_dir)
            os.makedirs(self.temp_dir)
            
            zip_path = os.path.join(self.temp_dir, 'update.zip')
            
            # Send license key in Authorization header (not URL — avoids log exposure)
            headers = {}
            if license_key:
                headers['Authorization'] = f'Bearer {license_key}'
            
            # Stream download (handles large files)
            response = requests.get(download_url, stream=True, timeout=120, headers=headers)
            response.raise_for_status()
            
            sha256 = hashlib.sha256()
            with open(zip_path, 'wb') as f:
                for chunk in response.iter_content(chunk_size=8192):
                    f.write(chunk)
                    sha256.update(chunk)
            
            # Verify checksum
            actual_checksum = f'sha256:{sha256.hexdigest()}'
            if expected_checksum and actual_checksum != expected_checksum:
                logger.error(f'Checksum mismatch: expected {expected_checksum}, got {actual_checksum}')
                return None
            
            return zip_path
        
        except Exception as e:
            logger.error(f'Download failed: {e}')
            return None
    
    def create_pre_update_backup(self):
        """Create a backup of current installation before updating.
        
        Returns: path to backup ZIP or None on failure
        """
        try:
            if os.path.exists(self.backup_dir):
                shutil.rmtree(self.backup_dir)
            os.makedirs(self.backup_dir)
            
            timestamp = utc_now().strftime('%Y%m%d_%H%M%S')
            backup_path = os.path.join(self.backup_dir, f'pre_update_{timestamp}.zip')
            
            with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zf:
                for root, dirs, files in os.walk(self.project_dir):
                    # Skip temp/backup dirs, venv, __pycache__
                    dirs[:] = [d for d in dirs if d not in (
                        '_update_temp', '_update_backup', 'venv', '__pycache__',
                        '.git', 'node_modules'
                    )]
                    
                    for file in files:
                        filepath = os.path.join(root, file)
                        arcname = os.path.relpath(filepath, self.project_dir)
                        try:
                            zf.write(filepath, arcname)
                        except (PermissionError, OSError):
                            continue  # Skip locked files
            
            return backup_path
        
        except Exception as e:
            logger.error(f'Pre-update backup failed: {e}')
            return None
    
    def apply_update(self, zip_path, new_version):
        """Extract and apply the update.
        
        Returns: (success: bool, message: str)
        """
        extract_dir = os.path.join(self.temp_dir, 'extracted')
        
        try:
            # Validate ZIP (security: check for path traversal)
            with zipfile.ZipFile(zip_path, 'r') as zf:
                for info in zf.infolist():
                    if info.filename.startswith('/') or '..' in info.filename:
                        return False, 'Update package contains suspicious paths. Aborting.'
                
                zf.extractall(extract_dir)
            
            # Find the actual content directory (ZIP might have a top-level folder)
            contents = os.listdir(extract_dir)
            if len(contents) == 1 and os.path.isdir(os.path.join(extract_dir, contents[0])):
                source_dir = os.path.join(extract_dir, contents[0])
            else:
                source_dir = extract_dir
            
            # Restore ownership of app files to the service user before writing.
            # If the admin edited files via SSH as root (e.g. quick debug patch),
            # those files become root-owned and the service user cannot overwrite
            # them. The update-prep helper runs a bounded `chown -R` on the app
            # directory via sudoers (same pattern as the SSL helper). Installed
            # by install.py / cloud-init.sh. Silent no-op on non-standard installs.
            update_prep = '/usr/local/bin/aliothpress-update-prep'
            if os.path.isfile(update_prep):
                try:
                    subprocess.run(['sudo', '-n', update_prep],
                                   capture_output=True, text=True,
                                   timeout=30, check=True)
                except (subprocess.CalledProcessError, subprocess.TimeoutExpired,
                        FileNotFoundError) as e:
                    # Not fatal — copy loop below may still succeed if files
                    # happen to be writable. If it fails, we report clearly.
                    logger.warning(f'update-prep helper unavailable: {e}')
            
            # Copy files, respecting protected paths
            updated_files = []
            for root, dirs, files in os.walk(source_dir):
                rel_root = os.path.relpath(root, source_dir)
                
                for file in files:
                    rel_path = os.path.join(rel_root, file) if rel_root != '.' else file
                    
                    # Skip protected paths
                    if self._is_protected(rel_path):
                        continue
                    
                    src = os.path.join(root, file)
                    dst = os.path.join(self.project_dir, rel_path)
                    
                    # Create directory if needed
                    os.makedirs(os.path.dirname(dst), exist_ok=True)
                    
                    try:
                        shutil.copy2(src, dst)
                    except PermissionError:
                        # Either the helper isn't installed, didn't run, or
                        # there's a deeper permissions problem. Surface a
                        # clean, actionable message instead of a raw trace.
                        return False, (
                            f'Update failed — cannot write to app files '
                            f'(first failure: {rel_path}). This usually means '
                            f'the installation is damaged: app files are owned '
                            f'by a different user than the service. Please '
                            f'contact your server administrator or reinstall '
                            f'via install.py.'
                        )
                    updated_files.append(rel_path)
            
            # Run migrations if included
            migration_script = os.path.join(source_dir, 'migrate.py')
            if os.path.exists(migration_script):
                import subprocess
                # Use sys.executable so the migration runs under the same
                # Python interpreter as the updater itself — works on Windows
                # (where 'python3' isn't a valid command) and inside venvs.
                result = subprocess.run(
                    [sys.executable, migration_script],
                    cwd=self.project_dir,
                    capture_output=True, text=True, timeout=60
                )
                if result.returncode != 0:
                    logger.warning(f'Migration script returned {result.returncode}: {result.stderr}')
            
            # Clear all __pycache__ directories so Python recompiles from
            # the fresh .py sources on next startup. shutil.copy2 above
            # preserves the source mtime from the release zip, which may
            # be older than existing .pyc files on disk; Python would
            # then keep using the stale bytecode and ignore the new
            # source until __pycache__ is removed or .py mtime touched.
            self._clear_pycache(self.project_dir)
            
            # Install/update dependencies if requirements.txt was in the update
            if 'requirements.txt' in updated_files:
                dep_ok, dep_msg = self.install_dependencies()
                logger.info(f'Dependencies: {dep_msg}')
            
            # Update version
            set_current_version(new_version)
            
            # Cleanup
            shutil.rmtree(self.temp_dir, ignore_errors=True)
            
            return True, f'Updated to {new_version}. {len(updated_files)} files updated.'
        
        except Exception as e:
            logger.error(f'Update failed: {e}')
            return False, f'Update failed: {str(e)}'
    
    def _is_protected(self, rel_path):
        """Check if a file path should be protected from overwrite."""
        rel_path = rel_path.replace('\\', '/')
        for protected in self.PROTECTED_PATHS:
            if rel_path == protected or rel_path.startswith(protected + '/'):
                return True
        return False
    
    def _clear_pycache(self, root_dir):
        """Remove every __pycache__ directory under root_dir.
        
        Called after files are copied during an update so Python recompiles
        bytecode from the fresh .py sources rather than reusing stale .pyc
        files. See the call site in apply_update for the full explanation.
        Failures are logged but never raised — a missed __pycache__ is not
        worth aborting an otherwise successful update.
        
        Skips venv/, .git/, node_modules/ and the updater's own temp dirs.
        On a real production install, walking into venv means hundreds of
        __pycache__ directories belonging to third-party packages we never
        touched — clearing them would force Python to recompile every
        dependency on the next start (slow and pointless). The skip list
        mirrors create_pre_update_backup() above for consistency.
        """
        SKIP_DIRS = {'_update_temp', '_update_backup', 'venv', '.git', 'node_modules'}
        removed = 0
        for current_root, dirs, _files in os.walk(root_dir):
            # Prune skip-list dirs BEFORE handling __pycache__ at this level,
            # so os.walk does not descend into venv/ etc. dirs[:] mutates the
            # list in-place via slice assignment (NOT rebind — that would not
            # affect os.walk's internal reference).
            dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
            # Modify dirs in-place so os.walk doesn't descend into __pycache__
            # after we delete it (avoids "directory disappeared" warnings).
            if '__pycache__' in dirs:
                cache_path = os.path.join(current_root, '__pycache__')
                try:
                    shutil.rmtree(cache_path, ignore_errors=True)
                    removed += 1
                except Exception as e:
                    logger.warning(f'Could not remove {cache_path}: {e}')
                dirs.remove('__pycache__')
        if removed:
            logger.info(f'Cleared {removed} __pycache__ directories')
    
    def rollback(self, backup_path):
        """Restore from pre-update backup if update fails.
        
        Returns: (success: bool, message: str)
        """
        try:
            with zipfile.ZipFile(backup_path, 'r') as zf:
                zf.extractall(self.project_dir)
            return True, 'Rollback completed successfully.'
        except Exception as e:
            logger.error(f'Rollback failed: {e}')
            return False, f'Rollback failed: {str(e)}. Manual restore may be needed.'


# ── Helper: License status for templates ─────────────────────────────────────

def get_license_status(settings_get_func):
    """Get license status for display in admin templates.
    
    Args:
        settings_get_func: The Settings.get function
    
    Returns:
        dict with license info for templates
    """
    license_key = settings_get_func('license_key_encrypted', '')
    license_valid = settings_get_func('license_valid', 'false') == 'true'
    license_platform = settings_get_func('license_platform', '')
    license_plan = settings_get_func('license_plan', 'standard')
    license_checked = settings_get_func('license_last_checked', '')
    
    return {
        'has_key': bool(license_key),
        'is_valid': license_valid,
        'platform': license_platform,
        'plan': license_plan,
        'last_checked': license_checked,
        'version': get_current_version(),
    }


def is_licensed(settings_get_func):
    """Quick check: does this installation have a valid license?
    Used for "Powered by" link visibility and feature gating.
    """
    return settings_get_func('license_valid', 'false') == 'true'


def has_plan(settings_get_func, required_plan='standard'):
    """Check if the installation has a specific license plan.
    
    Useful for gating premium features like the Shop module:
        if has_plan(Settings.get, 'extended'):
            # enable shop routes
    
    Plan hierarchy: extended > standard > (unlicensed)
    """
    if not is_licensed(settings_get_func):
        return False
    
    plan = settings_get_func('license_plan', 'standard')
    
    if required_plan == 'standard':
        return plan in ('standard', 'extended')
    elif required_plan == 'extended':
        return plan == 'extended'
    
    return False


def should_revalidate(settings_get_func, days=7):
    """Check if the license should be re-validated against the update server.
    
    Returns True if last check was more than `days` ago or never checked.
    Call this from a before_request hook or scheduled task.
    """
    last_checked = settings_get_func('license_last_checked', '')
    if not last_checked:
        return True
    
    try:
        last_dt = datetime.strptime(last_checked, '%Y-%m-%d %H:%M')
        return utc_now() - last_dt > timedelta(days=days)
    except (ValueError, TypeError):
        return True
