"""
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 json
import shutil
import hashlib
import zipfile
import logging
import requests
import subprocess
from datetime import datetime, timedelta

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=''):
        """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)
        
        Returns:
            dict: {
                'valid': bool,
                'platform': 'envato'|'lemonsqueezy'|'gumroad',
                'plan': 'standard'|'extended',
                'expires': '2026-12-31' or None (lifetime),
                'error': 'Error message' or None
            }
        """
        try:
            response = requests.post(
                f'{self.update_server_url}/api/v1/license/validate',
                json={
                    'license_key': license_key,
                    'site_url': site_url,
                    'cms_version': get_current_version(),
                },
                timeout=self.timeout,
                headers={'Content-Type': 'application/json'}
            )
            
            if response.status_code == 200:
                return response.json()
            else:
                return {
                    'valid': False,
                    'error': f'Server returned {response.status_code}'
                }
        
        except requests.ConnectionError:
            return {
                'valid': False,
                'error': 'Could not connect to license server. Please check your internet connection.'
            }
        except requests.Timeout:
            return {
                'valid': False,
                '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,
                '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.'
            }


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

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
    }
    
    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:
            for cmd_prefix in ['', 'sudo ']:
                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(datetime.utcnow().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 = datetime.utcnow().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
                result = subprocess.run(
                    ['python3', 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}')
            
            # 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 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 datetime.utcnow() - last_dt > timedelta(days=days)
    except (ValueError, TypeError):
        return True
