#!/usr/bin/env python3
"""
Liberty - Hardware-Bound Secrets Manager
Eliminates .env file leaks by binding secrets to hardware fingerprints.
"""

import os
import sys
import json
import hashlib
import platform
import subprocess
import time
import getpass
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from base64 import b64encode, b64decode
import argparse

try:
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
except ImportError:
    print("Error: cryptography package not installed")
    print("Install with: pip install cryptography")
    sys.exit(1)


class HardwareFingerprint:
    """
    Software-based Physical Unclonable Function (PUF)
    Generates a unique fingerprint from hardware characteristics.
    """

    @staticmethod
    def get_cpu_info() -> str:
        """Get CPU information."""
        try:
            if platform.system() == "Linux":
                with open('/proc/cpuinfo', 'r') as f:
                    cpuinfo = f.read()
                    # Extract processor ID or serial if available
                    for line in cpuinfo.split('\n'):
                        if 'processor' in line.lower() or 'serial' in line.lower():
                            return line.strip()
            elif platform.system() == "Darwin":
                result = subprocess.run(['sysctl', '-n', 'machdep.cpu.brand_string'],
                                       capture_output=True, text=True)
                return result.stdout.strip()
            elif platform.system() == "Windows":
                result = subprocess.run(['wmic', 'cpu', 'get', 'ProcessorId'],
                                       capture_output=True, text=True)
                return result.stdout.strip()
        except:
            pass
        return platform.processor()

    @staticmethod
    def get_machine_id() -> str:
        """Get machine ID."""
        try:
            if platform.system() == "Linux":
                # Try /etc/machine-id first
                if os.path.exists('/etc/machine-id'):
                    with open('/etc/machine-id', 'r') as f:
                        return f.read().strip()
                # Fallback to /var/lib/dbus/machine-id
                if os.path.exists('/var/lib/dbus/machine-id'):
                    with open('/var/lib/dbus/machine-id', 'r') as f:
                        return f.read().strip()
            elif platform.system() == "Darwin":
                result = subprocess.run(['ioreg', '-rd1', '-c', 'IOPlatformExpertDevice'],
                                       capture_output=True, text=True)
                for line in result.stdout.split('\n'):
                    if 'IOPlatformUUID' in line:
                        return line.split('"')[3]
            elif platform.system() == "Windows":
                result = subprocess.run(['wmic', 'csproduct', 'get', 'UUID'],
                                       capture_output=True, text=True)
                return result.stdout.strip().split('\n')[1].strip()
        except:
            pass
        return platform.node()

    @staticmethod
    def get_disk_serial() -> str:
        """Get disk serial number."""
        try:
            if platform.system() == "Linux":
                result = subprocess.run(['lsblk', '-ndo', 'SERIAL', '/dev/sda'],
                                       capture_output=True, text=True)
                return result.stdout.strip()
            elif platform.system() == "Darwin":
                result = subprocess.run(['system_profiler', 'SPSerialATADataType'],
                                       capture_output=True, text=True)
                for line in result.stdout.split('\n'):
                    if 'Serial Number' in line:
                        return line.split(':')[1].strip()
            elif platform.system() == "Windows":
                result = subprocess.run(['wmic', 'diskdrive', 'get', 'SerialNumber'],
                                       capture_output=True, text=True)
                return result.stdout.strip().split('\n')[1].strip()
        except:
            pass
        return ""

    @classmethod
    def generate(cls) -> str:
        """
        Generate a unique hardware fingerprint.
        Combines multiple hardware characteristics for robustness.
        """
        components = [
            platform.system(),
            platform.machine(),
            cls.get_cpu_info(),
            cls.get_machine_id(),
            cls.get_disk_serial(),
        ]

        # Filter out empty components
        components = [c for c in components if c]

        # Combine and hash
        combined = "|".join(components)
        fingerprint = hashlib.sha256(combined.encode()).hexdigest()

        return fingerprint


class SecretVault:
    """
    Encrypted secret storage bound to hardware fingerprint.
    Uses AES-GCM for authenticated encryption.
    """

    def __init__(self, vault_path: str = None):
        """Initialize the vault."""
        if vault_path is None:
            vault_path = os.path.join(os.getcwd(), '.liberty')

        self.vault_path = Path(vault_path)
        self.secrets_file = self.vault_path / 'secrets.enc'
        self.metadata_file = self.vault_path / 'metadata.json'
        self.audit_file = self.vault_path / 'audit.log'

    def _get_encryption_key(self, fingerprint: str) -> bytes:
        """Derive encryption key from hardware fingerprint."""
        # Use PBKDF2 to derive a key from the fingerprint
        kdf = PBKDF2HMAC(
            algorithm=hashes.SHA256(),
            length=32,
            salt=b'liberty_salt_v1',  # Static salt for reproducibility
            iterations=100000,
        )
        return kdf.derive(fingerprint.encode())

    def _log_audit(self, action: str, details: Dict = None) -> None:
        """Log an audit entry."""
        if not self.vault_path.exists():
            return

        entry = {
            'timestamp': datetime.now().astimezone().isoformat(),
            'action': action,
            'user': os.getenv('USER', 'unknown'),
            'hostname': platform.node(),
        }

        if details:
            entry.update(details)

        # Append to audit log
        try:
            with open(self.audit_file, 'a') as f:
                f.write(json.dumps(entry) + '\n')
        except Exception:
            # Don't fail operations if audit logging fails
            pass

    def init(self) -> bool:
        """Initialize a new vault."""
        if self.vault_path.exists():
            print(f"Error: Vault already exists at {self.vault_path}")
            return False

        # Create vault directory
        self.vault_path.mkdir(parents=True, exist_ok=True)

        # Get hardware fingerprint
        fingerprint = HardwareFingerprint.generate()

        # Create metadata
        metadata = {
            'version': '1.0',
            'fingerprint_hash': hashlib.sha256(fingerprint.encode()).hexdigest()[:16],
            'created': time.strftime('%Y-%m-%d %H:%M:%S'),
        }

        # Save metadata
        with open(self.metadata_file, 'w') as f:
            json.dump(metadata, f, indent=2)

        # Create empty secrets file
        self._save_secrets({})

        print(f"✓ Liberty vault initialized at {self.vault_path}")
        print(f"  Fingerprint: {fingerprint[:16]}...")

        # Log audit entry
        self._log_audit('vault_initialized', {
            'fingerprint_hash': metadata['fingerprint_hash']
        })

        # Auto-add to .gitignore if in a git repository
        self._add_to_gitignore()

        return True

    def _add_to_gitignore(self) -> None:
        """Add .liberty to .gitignore if in a git repository."""
        try:
            # Check if we're in a git repository
            result = subprocess.run(
                ['git', 'rev-parse', '--git-dir'],
                capture_output=True,
                text=True,
                cwd=os.getcwd()
            )

            if result.returncode != 0:
                return  # Not in a git repo

            gitignore_path = Path(os.getcwd()) / '.gitignore'

            # Check if .liberty is already in .gitignore
            if gitignore_path.exists():
                with open(gitignore_path, 'r') as f:
                    content = f.read()
                    if '.liberty' in content:
                        return  # Already ignored

            # Ask user if they want to add to .gitignore
            response = input("Add .liberty/ to .gitignore? [Y/n] ").strip().lower()
            if response in ('', 'y', 'yes'):
                with open(gitignore_path, 'a') as f:
                    f.write('\n# Liberty vault (encrypted secrets)\n.liberty/\n')
                print("✓ Added .liberty/ to .gitignore")
        except Exception:
            pass  # Silently fail if git is not available

    def _save_secrets(self, secrets: Dict[str, str]) -> None:
        """Save encrypted secrets to file."""
        # Get hardware fingerprint and derive key
        fingerprint = HardwareFingerprint.generate()
        key = self._get_encryption_key(fingerprint)

        # Serialize secrets
        plaintext = json.dumps(secrets).encode()

        # Encrypt with AES-GCM
        aesgcm = AESGCM(key)
        nonce = os.urandom(12)
        ciphertext = aesgcm.encrypt(nonce, plaintext, None)

        # Save nonce + ciphertext
        with open(self.secrets_file, 'wb') as f:
            f.write(nonce + ciphertext)

    def _load_secrets(self) -> Dict[str, str]:
        """Load and decrypt secrets from file."""
        if not self.secrets_file.exists():
            return {}

        # Get hardware fingerprint and derive key
        fingerprint = HardwareFingerprint.generate()
        key = self._get_encryption_key(fingerprint)

        # Read nonce + ciphertext
        with open(self.secrets_file, 'rb') as f:
            data = f.read()

        nonce = data[:12]
        ciphertext = data[12:]

        # Decrypt with AES-GCM
        try:
            aesgcm = AESGCM(key)
            plaintext = aesgcm.decrypt(nonce, ciphertext, None)
            return json.loads(plaintext.decode())
        except Exception as e:
            print(f"Error: Failed to decrypt secrets. Hardware fingerprint mismatch?")
            print(f"  {str(e)}")
            sys.exit(1)

    def add(self, key: str, value: Optional[str] = None) -> bool:
        """Add or update a secret."""
        if not self.vault_path.exists():
            print("Error: Vault not initialized. Run 'liberty init' first.")
            return False

        # If no value provided, ask interactively
        if value is None:
            try:
                value = getpass.getpass(f"Enter value for {key} (hidden): ")
            except KeyboardInterrupt:
                print("\nCancelled.")
                return False

        secrets = self._load_secrets()
        is_update = key in secrets

        # Store with timestamp
        now = datetime.now(timezone.utc).isoformat()
        if key not in secrets:
            # New secret - add timestamp
            secrets[key] = {
                'value': value,
                'added': now,
                'accessed': now
            }
        else:
            # Update existing - preserve added time
            if isinstance(secrets[key], dict):
                secrets[key]['value'] = value
            else:
                # Old format - migrate
                secrets[key] = {
                    'value': value,
                    'added': now,
                    'accessed': now
                }

        self._save_secrets(secrets)

        print(f"✓ Secret '{key}' {'updated' if is_update else 'added'}")

        # Log audit entry
        self._log_audit('secret_added' if not is_update else 'secret_updated', {
            'key': key
        })

        return True

    def list_secrets(self) -> List[Tuple[str, Optional[str]]]:
        """List all secret keys with timestamps."""
        if not self.vault_path.exists():
            print("Error: Vault not initialized. Run 'liberty init' first.")
            return []

        secrets = self._load_secrets()
        result = []

        for key, data in secrets.items():
            if isinstance(data, dict) and 'added' in data:
                # Format timestamp as relative time
                added_time = datetime.fromisoformat(data['added'].replace('Z', '+00:00'))
                now = datetime.now(timezone.utc)
                delta = now - added_time

                if delta.days > 365:
                    time_str = f"{delta.days // 365} year{'s' if delta.days // 365 > 1 else ''} ago"
                elif delta.days > 30:
                    time_str = f"{delta.days // 30} month{'s' if delta.days // 30 > 1 else ''} ago"
                elif delta.days > 0:
                    time_str = f"{delta.days} day{'s' if delta.days > 1 else ''} ago"
                elif delta.seconds > 3600:
                    hours = delta.seconds // 3600
                    time_str = f"{hours} hour{'s' if hours > 1 else ''} ago"
                elif delta.seconds > 60:
                    minutes = delta.seconds // 60
                    time_str = f"{minutes} minute{'s' if minutes > 1 else ''} ago"
                else:
                    time_str = "just now"

                result.append((key, time_str))
            else:
                # Old format - no timestamp
                result.append((key, None))

        return sorted(result)

    def show(self, key: str) -> Optional[str]:
        """Show a secret value."""
        if not self.vault_path.exists():
            print("Error: Vault not initialized. Run 'liberty init' first.")
            return None

        secrets = self._load_secrets()

        if key not in secrets:
            print(f"Error: Secret '{key}' not found")
            return None

        # Extract value from new or old format
        data = secrets[key]
        if isinstance(data, dict):
            value = data.get('value', data)
            # Update last accessed time
            data['accessed'] = datetime.now(timezone.utc).isoformat()
            self._save_secrets(secrets)
        else:
            value = data

        # Log audit entry
        self._log_audit('secret_accessed', {
            'key': key
        })

        return value

    def exec_with_env(self, command: List[str]) -> int:
        """Execute a command with secrets as environment variables."""
        if not self.vault_path.exists():
            print("Error: Vault not initialized. Run 'liberty init' first.")
            return 1

        secrets = self._load_secrets()

        # Extract values from new or old format
        env_secrets = {}
        for key, data in secrets.items():
            if isinstance(data, dict):
                env_secrets[key] = data.get('value', data)
            else:
                env_secrets[key] = data

        # Log audit entry
        self._log_audit('secrets_injected', {
            'command': ' '.join(command),
            'secret_count': len(env_secrets)
        })

        # Create environment with secrets
        env = os.environ.copy()
        env.update(env_secrets)

        # Execute command
        try:
            result = subprocess.run(command, env=env)
            return result.returncode
        except Exception as e:
            print(f"Error: Failed to execute command: {e}")
            return 1

    def show_audit(self, lines: int = 20) -> bool:
        """Show audit log entries."""
        if not self.vault_path.exists():
            print("Error: Vault not initialized. Run 'liberty init' first.")
            return False

        if not self.audit_file.exists():
            print("No audit log found")
            return False

        try:
            with open(self.audit_file, 'r') as f:
                entries = f.readlines()

            if not entries:
                print("Audit log is empty")
                return True

            # Show last N lines
            display_entries = entries[-lines:] if lines > 0 else entries

            print(f"Audit Log (showing last {len(display_entries)} entries):")
            print()

            for line in display_entries:
                try:
                    entry = json.loads(line.strip())
                    timestamp = entry.get('timestamp', 'unknown')
                    action = entry.get('action', 'unknown')
                    user = entry.get('user', 'unknown')

                    # Format timestamp
                    try:
                        dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
                        timestamp_display = dt.strftime('%Y-%m-%d %H:%M:%S')
                    except:
                        timestamp_display = timestamp

                    # Build detail string
                    details = []
                    if 'key' in entry:
                        details.append(f"key={entry['key']}")
                    if 'command' in entry:
                        details.append(f"command={entry['command']}")
                    if 'secret_count' in entry:
                        details.append(f"secrets={entry['secret_count']}")
                    if 'fingerprint_hash' in entry:
                        details.append(f"fingerprint={entry['fingerprint_hash']}")

                    detail_str = f" ({', '.join(details)})" if details else ""

                    print(f"  {timestamp_display} | {user:12s} | {action:20s}{detail_str}")

                except json.JSONDecodeError:
                    continue

            return True

        except Exception as e:
            print(f"Error reading audit log: {e}")
            return False


def main():
    """Main CLI entry point."""
    parser = argparse.ArgumentParser(
        description='Liberty - Hardware-Bound Secrets Manager',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  liberty init                    # Initialize vault
  liberty add API_KEY sk-xxx      # Add a secret
  liberty list                    # List all secrets
  liberty show API_KEY            # Show a secret
  liberty exec -- npm start       # Run command with secrets
  liberty audit                   # Show audit log
  liberty audit -n 50             # Show last 50 entries
        """
    )

    subparsers = parser.add_subparsers(dest='command', help='Command to run')

    # Init command
    subparsers.add_parser('init', help='Initialize a new Liberty vault')

    # Add command
    add_parser = subparsers.add_parser('add', help='Add or update a secret')
    add_parser.add_argument('key', help='Secret key (environment variable name)')
    add_parser.add_argument('value', nargs='?', help='Secret value (will prompt if not provided)')

    # List command
    subparsers.add_parser('list', help='List all secret keys')

    # Show command
    show_parser = subparsers.add_parser('show', help='Show a secret value')
    show_parser.add_argument('key', help='Secret key to show')

    # Exec command
    exec_parser = subparsers.add_parser('exec', help='Execute command with secrets as env vars')
    exec_parser.add_argument('exec_command', nargs='+', metavar='command', help='Command to execute')

    # Audit command
    audit_parser = subparsers.add_parser('audit', help='Show audit log')
    audit_parser.add_argument('-n', '--lines', type=int, default=20, help='Number of lines to show (default: 20)')

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        return 1

    # Create vault instance
    vault = SecretVault()

    # Execute command
    if args.command == 'init':
        return 0 if vault.init() else 1

    elif args.command == 'add':
        value = getattr(args, 'value', None)
        return 0 if vault.add(args.key, value) else 1

    elif args.command == 'list':
        secrets_with_times = vault.list_secrets()
        if secrets_with_times:
            print("Secrets:")
            for key, time_str in secrets_with_times:
                if time_str:
                    print(f"  - {key} (added {time_str})")
                else:
                    print(f"  - {key}")
        else:
            print("No secrets stored")
        return 0

    elif args.command == 'show':
        value = vault.show(args.key)
        if value is not None:
            print(value)
            return 0
        return 1

    elif args.command == 'exec':
        return vault.exec_with_env(args.exec_command)

    elif args.command == 'audit':
        return 0 if vault.show_audit(args.lines) else 1

    return 0


if __name__ == '__main__':
    sys.exit(main())
