#!/usr/bin/env python3
"""
TOTP Manager for Media Downloader
Handles Time-based One-Time Password (TOTP) operations for 2FA

Based on backup-central's implementation
"""

import sys
import pyotp
import qrcode
import io
import base64
import sqlite3
import hashlib
import bcrypt
import secrets
from datetime import datetime, timedelta
from typing import Optional, Dict, List, Tuple
from cryptography.fernet import Fernet
from pathlib import Path

# Add parent path to allow imports from modules
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from modules.universal_logger import get_logger

logger = get_logger('TOTPManager')


class TOTPManager:
    def __init__(self, db_path: str = None):
        if db_path is None:
            db_path = str(Path(__file__).parent.parent.parent / 'database' / 'auth.db')

        self.db_path = db_path
        self.issuer = 'Media Downloader'
        self.window = 1  # Allow 1 step (30s) tolerance for time skew

        # Encryption key for TOTP secrets
        self.encryption_key = self._get_encryption_key()
        self.cipher_suite = Fernet(self.encryption_key)

        # Rate limiting configuration
        self.max_attempts = 5
        self.rate_limit_window = timedelta(minutes=15)
        self.lockout_duration = timedelta(minutes=30)

        # Initialize database tables
        self._init_database()

    def _get_encryption_key(self) -> bytes:
        """Get or generate encryption key for TOTP secrets"""
        key_file = Path(__file__).parent.parent.parent / '.totp_encryption_key'

        if key_file.exists():
            with open(key_file, 'rb') as f:
                return f.read()

        # Generate new key
        key = Fernet.generate_key()
        try:
            with open(key_file, 'wb') as f:
                f.write(key)
            import os
            os.chmod(key_file, 0o600)
        except Exception as e:
            logger.warning(f"Could not save TOTP encryption key: {e}", module="TOTP")

        return key

    def _init_database(self):
        """Initialize TOTP-related database tables"""
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()

            # Ensure TOTP columns exist in users table
            try:
                cursor.execute("ALTER TABLE users ADD COLUMN totp_secret TEXT")
            except sqlite3.OperationalError:
                pass  # Column already exists

            try:
                cursor.execute("ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0")
            except sqlite3.OperationalError:
                pass

            try:
                cursor.execute("ALTER TABLE users ADD COLUMN totp_enrolled_at TEXT")
            except sqlite3.OperationalError:
                pass

            # TOTP rate limiting table
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS totp_rate_limit (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    username TEXT NOT NULL,
                    ip_address TEXT,
                    attempts INTEGER NOT NULL DEFAULT 1,
                    window_start TEXT NOT NULL,
                    locked_until TEXT,
                    UNIQUE(username, ip_address)
                )
            """)

            # TOTP audit log
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS totp_audit_log (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    username TEXT NOT NULL,
                    action TEXT NOT NULL,
                    success INTEGER NOT NULL,
                    ip_address TEXT,
                    user_agent TEXT,
                    details TEXT,
                    timestamp TEXT NOT NULL
                )
            """)

            conn.commit()

    def generate_secret(self, username: str) -> Dict:
        """
        Generate a new TOTP secret and QR code for a user

        Returns:
            dict with secret, qrCodeDataURL, manualEntryKey
        """
        try:
            # Generate secret
            secret = pyotp.random_base32()

            # Create provisioning URI
            totp = pyotp.TOTP(secret)
            provisioning_uri = totp.provisioning_uri(
                name=username,
                issuer_name=self.issuer
            )

            # Generate QR code
            qr = qrcode.QRCode(version=1, box_size=10, border=4)
            qr.add_data(provisioning_uri)
            qr.make(fit=True)

            img = qr.make_image(fill_color="black", back_color="white")

            # Convert to base64 data URL
            buffer = io.BytesIO()
            img.save(buffer, format='PNG')
            img_str = base64.b64encode(buffer.getvalue()).decode()
            qr_code_data_url = f"data:image/png;base64,{img_str}"

            self._log_audit(username, 'totp_secret_generated', True, None, None,
                          'TOTP secret generated for user')

            return {
                'secret': secret,
                'qrCodeDataURL': qr_code_data_url,
                'manualEntryKey': secret,
                'otpauthURL': provisioning_uri
            }
        except Exception as error:
            self._log_audit(username, 'totp_secret_generation_failed', False, None, None,
                          f'Error: {str(error)}')
            raise

    def verify_token(self, secret: str, token: str) -> bool:
        """
        Verify a TOTP token

        Args:
            secret: Base32 encoded secret
            token: 6-digit TOTP code

        Returns:
            True if valid, False otherwise
        """
        try:
            totp = pyotp.TOTP(secret)
            return totp.verify(token, valid_window=self.window)
        except Exception as e:
            logger.error(f"TOTP verification error: {e}", module="TOTP")
            return False

    def encrypt_secret(self, secret: str) -> str:
        """Encrypt TOTP secret for storage"""
        encrypted = self.cipher_suite.encrypt(secret.encode())
        return base64.b64encode(encrypted).decode()

    def decrypt_secret(self, encrypted_secret: str) -> str:
        """Decrypt TOTP secret from storage"""
        encrypted = base64.b64decode(encrypted_secret.encode())
        decrypted = self.cipher_suite.decrypt(encrypted)
        return decrypted.decode()

    def generate_backup_codes(self, count: int = 10) -> List[str]:
        """
        Generate backup codes for recovery

        Args:
            count: Number of codes to generate

        Returns:
            List of formatted backup codes
        """
        codes = []
        for _ in range(count):
            # Generate 8-character hex code
            code = secrets.token_hex(4).upper()
            # Format as XXXX-XXXX
            formatted = f"{code[:4]}-{code[4:8]}"
            codes.append(formatted)
        return codes

    def hash_backup_code(self, code: str) -> str:
        """
        Hash a backup code for storage using bcrypt

        Args:
            code: Backup code to hash

        Returns:
            Bcrypt hash of the code
        """
        # Use bcrypt with default work factor (12 rounds)
        return bcrypt.hashpw(code.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')

    def verify_backup_code(self, code: str, username: str) -> Tuple[bool, int]:
        """
        Verify a backup code and mark it as used

        Returns:
            Tuple of (valid, remaining_codes)
        """
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()

            # Get unused backup codes for user
            cursor.execute("""
                SELECT id, code_hash FROM backup_codes
                WHERE username = ? AND used = 0
            """, (username,))

            rows = cursor.fetchall()

            # Check if code matches any unused code
            for row_id, stored_hash in rows:
                # Support both bcrypt (new) and SHA256 (legacy) hashes
                is_match = False

                if stored_hash.startswith('$2b$'):
                    # Bcrypt hash
                    try:
                        is_match = bcrypt.checkpw(code.encode('utf-8'), stored_hash.encode('utf-8'))
                    except Exception as e:
                        logger.error(f"Error verifying bcrypt backup code: {e}", module="TOTP")
                        continue
                else:
                    # Legacy SHA256 hash
                    legacy_hash = hashlib.sha256(code.encode()).hexdigest()
                    is_match = (stored_hash == legacy_hash)

                if is_match:
                    # Mark as used
                    cursor.execute("""
                        UPDATE backup_codes
                        SET used = 1, used_at = ?
                        WHERE id = ?
                    """, (datetime.now().isoformat(), row_id))

                    conn.commit()

                    # Count remaining codes
                    cursor.execute("""
                        SELECT COUNT(*) FROM backup_codes
                        WHERE username = ? AND used = 0
                    """, (username,))

                    remaining = cursor.fetchone()[0]
                    return True, remaining

            return False, len(rows)

    def enable_totp(self, username: str, secret: str, backup_codes: List[str]) -> bool:
        """
        Enable TOTP for a user

        Args:
            username: Username
            secret: TOTP secret
            backup_codes: List of backup codes

        Returns:
            True if successful
        """
        try:
            with sqlite3.connect(self.db_path) as conn:
                cursor = conn.cursor()

                # Encrypt secret
                encrypted_secret = self.encrypt_secret(secret)

                # Update user
                cursor.execute("""
                    UPDATE users
                    SET totp_secret = ?, totp_enabled = 1, totp_enrolled_at = ?
                    WHERE username = ?
                """, (encrypted_secret, datetime.now().isoformat(), username))

                # Delete old backup codes
                cursor.execute("DELETE FROM backup_codes WHERE username = ?", (username,))

                # Insert new backup codes
                for code in backup_codes:
                    code_hash = self.hash_backup_code(code)
                    cursor.execute("""
                        INSERT INTO backup_codes (username, code_hash, created_at)
                        VALUES (?, ?, ?)
                    """, (username, code_hash, datetime.now().isoformat()))

                conn.commit()

            self._log_audit(username, 'totp_enabled', True, None, None,
                          f'TOTP enabled with {len(backup_codes)} backup codes')

            return True
        except Exception as e:
            logger.error(f"Error enabling TOTP: {e}", module="TOTP")
            return False

    def disable_totp(self, username: str) -> bool:
        """
        Disable TOTP for a user

        Returns:
            True if successful
        """
        try:
            with sqlite3.connect(self.db_path) as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    UPDATE users
                    SET totp_secret = NULL, totp_enabled = 0, totp_enrolled_at = NULL
                    WHERE username = ?
                """, (username,))

                # Delete backup codes
                cursor.execute("DELETE FROM backup_codes WHERE username = ?", (username,))

                conn.commit()

            self._log_audit(username, 'totp_disabled', True, None, None, 'TOTP disabled')

            return True
        except Exception as e:
            logger.error(f"Error disabling TOTP: {e}", module="TOTP")
            return False

    def get_totp_status(self, username: str) -> Dict:
        """
        Get TOTP status for a user

        Returns:
            dict with enabled, enrolledAt
        """
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()

            cursor.execute("""
                SELECT totp_enabled, totp_enrolled_at FROM users
                WHERE username = ?
            """, (username,))

            row = cursor.fetchone()

        if not row:
            return {'enabled': False, 'enrolledAt': None}

        return {
            'enabled': bool(row[0]),
            'enrolledAt': row[1]
        }

    def check_rate_limit(self, username: str, ip_address: str) -> Tuple[bool, int, Optional[str]]:
        """
        Check and enforce rate limiting for TOTP verification

        Returns:
            Tuple of (allowed, attempts_remaining, locked_until)
        """
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()

            now = datetime.now()
            window_start = now - self.rate_limit_window

            cursor.execute("""
                SELECT attempts, window_start, locked_until
                FROM totp_rate_limit
                WHERE username = ? AND ip_address = ?
            """, (username, ip_address))

            row = cursor.fetchone()

            if not row:
                # First attempt - create record
                cursor.execute("""
                    INSERT INTO totp_rate_limit (username, ip_address, attempts, window_start)
                    VALUES (?, ?, 1, ?)
                """, (username, ip_address, now.isoformat()))
                conn.commit()
                return True, self.max_attempts - 1, None

            attempts, stored_window_start, locked_until = row
            stored_window_start = datetime.fromisoformat(stored_window_start)

            # Check if locked out
            if locked_until:
                locked_until_dt = datetime.fromisoformat(locked_until)
                if locked_until_dt > now:
                    return False, 0, locked_until
                else:
                    # Lockout expired - reset
                    cursor.execute("""
                        DELETE FROM totp_rate_limit
                        WHERE username = ? AND ip_address = ?
                    """, (username, ip_address))
                    conn.commit()
                    return True, self.max_attempts, None

            # Check if window expired
            if stored_window_start < window_start:
                # Reset window
                cursor.execute("""
                    UPDATE totp_rate_limit
                    SET attempts = 1, window_start = ?, locked_until = NULL
                    WHERE username = ? AND ip_address = ?
                """, (now.isoformat(), username, ip_address))
                conn.commit()
                return True, self.max_attempts - 1, None

            # Increment attempts
            new_attempts = attempts + 1

            if new_attempts >= self.max_attempts:
                # Lock out
                locked_until = (now + self.lockout_duration).isoformat()
                cursor.execute("""
                    UPDATE totp_rate_limit
                    SET attempts = ?, locked_until = ?
                    WHERE username = ? AND ip_address = ?
                """, (new_attempts, locked_until, username, ip_address))
                conn.commit()
                return False, 0, locked_until
            else:
                cursor.execute("""
                    UPDATE totp_rate_limit
                    SET attempts = ?
                    WHERE username = ? AND ip_address = ?
                """, (new_attempts, username, ip_address))
                conn.commit()
                return True, self.max_attempts - new_attempts, None

    def reset_rate_limit(self, username: str, ip_address: str):
        """Reset rate limit after successful authentication"""
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()

            cursor.execute("""
                DELETE FROM totp_rate_limit
                WHERE username = ? AND ip_address = ?
            """, (username, ip_address))

            conn.commit()

    def regenerate_backup_codes(self, username: str) -> List[str]:
        """
        Regenerate backup codes for a user

        Returns:
            List of new backup codes
        """
        # Generate new codes
        new_codes = self.generate_backup_codes(10)

        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()

            # Delete old codes
            cursor.execute("DELETE FROM backup_codes WHERE username = ?", (username,))

            # Insert new codes
            for code in new_codes:
                code_hash = self.hash_backup_code(code)
                cursor.execute("""
                    INSERT INTO backup_codes (username, code_hash, created_at)
                    VALUES (?, ?, ?)
                """, (username, code_hash, datetime.now().isoformat()))

            conn.commit()

        self._log_audit(username, 'backup_codes_regenerated', True, None, None,
                      f'Generated {len(new_codes)} new backup codes')

        return new_codes

    def _log_audit(self, username: str, action: str, success: bool,
                   ip_address: Optional[str], user_agent: Optional[str],
                   details: Optional[str] = None):
        """Log TOTP audit event"""
        try:
            with sqlite3.connect(self.db_path) as conn:
                cursor = conn.cursor()

                cursor.execute("""
                    INSERT INTO totp_audit_log
                    (username, action, success, ip_address, user_agent, details, timestamp)
                    VALUES (?, ?, ?, ?, ?, ?, ?)
                """, (username, action, int(success), ip_address, user_agent, details,
                      datetime.now().isoformat()))

                conn.commit()
        except Exception as e:
            logger.error(f"Error logging TOTP audit: {e}", module="TOTP")
