# Copyright (c) 2026 동일비전(Dongil Vision Korea). All Rights Reserved.
"""GConfig 인프라 — 예외 클래스 11종 + 인프라 클래스 10종

예외: ConfigError 및 10개 서브클래스.
인프라: PathParser, ConfigCache, FileLock, BackupManager,
        YamlParser, JsonParser, PathValidator, SizeValidator,
        ChecksumManager, AuditLogger.
"""

from __future__ import annotations

import hashlib
import json
import os
import shutil
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional


# ==============================================================================
# Exception classes
# ==============================================================================

class ConfigError(Exception):
    """Base exception for configuration errors."""

    def __init__(self, message: str, path: Optional[str] = None):
        self.message = message
        self.path = path
        super().__init__(message)


class ConfigFileNotFoundError(ConfigError):
    """Raised when configuration file does not exist."""

    def __init__(self, filepath: str):
        super().__init__(f"Configuration file not found: {filepath}", filepath)


class ConfigPathNotFoundError(ConfigError):
    """Raised when path does not exist (during set operation)."""

    def __init__(self, path: str):
        super().__init__(f"Path not found, cannot create new path: {path}", path)


class ConfigLockTimeoutError(ConfigError):
    """Raised when file lock acquisition times out."""

    def __init__(self, filepath: str, timeout: float):
        self.timeout = timeout
        super().__init__(f"Lock timeout after {timeout}s: {filepath}", filepath)


class ConfigValidationError(ConfigError):
    """Raised when data validation fails."""
    pass


class ConfigParseError(ConfigError):
    """Raised when configuration file parsing fails."""

    def __init__(self, filepath: str, reason: str, lineno: int = 0):
        self.reason = reason
        self.lineno = lineno
        self._print_banner(filepath, reason, lineno)
        super().__init__(f"Parse error in {filepath}: {reason}", filepath)

    def _print_banner(self, filepath: str, reason: str, lineno: int):
        w = 78
        lines_ctx = []
        if lineno > 0:
            try:
                with open(filepath, 'r', encoding='utf-8') as f:
                    all_lines = f.readlines()
                start = max(0, lineno - 3)
                end = min(len(all_lines), lineno + 2)
                for i in range(start, end):
                    marker = " >>>" if i == lineno - 1 else "    "
                    lines_ctx.append(f"{marker} {i + 1:4d} | {all_lines[i].rstrip()}")
            except Exception:
                pass

        border = "═" * w
        sys.stderr.write(f"\n╔{border}╗\n")
        sys.stderr.write(f"║  구성파일 오류 (ConfigParseError){' ' * (w - 33)}║\n")
        sys.stderr.write(f"╠{border}╣\n")
        sys.stderr.write(f"║  File: {filepath:<{w - 8}}║\n")
        sys.stderr.write(f"║  {reason:<{w - 3}}║\n")
        if lines_ctx:
            sys.stderr.write(f"╟{'─' * w}╢\n")
            for line in lines_ctx:
                padded = f"{line:<{w - 3}}"[:w - 3]
                sys.stderr.write(f"║  {padded}║\n")
        sys.stderr.write(f"╚{border}╝\n\n")
        sys.stderr.flush()


class ConfigIntegrityError(ConfigError):
    """Raised when checksum verification fails."""

    def __init__(self, filepath: str, expected: str, actual: str):
        self.expected = expected
        self.actual = actual
        super().__init__(
            f"Checksum mismatch for {filepath}: expected {expected[:8]}..., got {actual[:8]}...",
            filepath
        )


class ConfigSecurityError(ConfigError):
    """Raised when security violation is detected."""

    PATH_TRAVERSAL = "PATH_TRAVERSAL"
    SYMLINK_ATTACK = "SYMLINK_ATTACK"
    SIZE_EXCEEDED = "SIZE_EXCEEDED"
    INVALID_PATH = "INVALID_PATH"

    def __init__(self, message: str, violation_type: str, path: Optional[str] = None):
        self.violation_type = violation_type
        super().__init__(message, path)


class ConfigStaleLockError(ConfigError):
    """Raised when stale lock is detected."""

    def __init__(self, filepath: str, pid: int):
        self.pid = pid
        super().__init__(f"Stale lock detected for {filepath} (PID: {pid})", filepath)


class ConfigMandatoryFieldError(ConfigError):
    """Raised when mandatory field is missing."""

    APP_INFO_NAME = "app_info.name"
    APP_INFO_VERSION = "app_info.version"
    APP_INFO_ID = "app_info.id"
    TASK_CONFIG = "task_config"

    MANDATORY_FIELDS = [APP_INFO_NAME, APP_INFO_VERSION, APP_INFO_ID, TASK_CONFIG]

    def __init__(self, field: str, reason: str = "Mandatory field missing"):
        self.field = field
        self.reason = reason
        super().__init__(f"{reason}: {field}", field)


class ConfigRecoveryError(ConfigError):
    """Raised when recovery from backup fails."""

    def __init__(self, filepath: str, tried_backups: List[str]):
        self.tried_backups = tried_backups
        super().__init__(
            f"Recovery failed for {filepath}, tried {len(tried_backups)} backups",
            filepath
        )

_IS_WINDOWS = sys.platform == 'win32'

# Conditional imports for file locking
if _IS_WINDOWS:
    import msvcrt
else:
    import fcntl

# Optional YAML support
try:
    import yaml
    YAML_AVAILABLE = True
except ImportError:
    YAML_AVAILABLE = False


# ==============================================================================
# PathParser - Static utility class for dot notation path parsing
# ==============================================================================

class PathParser:
    """Static utility class for parsing dot notation paths."""

    @staticmethod
    def parse(path: str) -> List[str]:
        """Parse path string into list of keys."""
        if not path:
            return []
        return path.split('.')

    @staticmethod
    def validate(path: str, max_depth: int = 10) -> bool:
        """Validate path string."""
        if not path:
            return False
        if not isinstance(path, str):
            return False

        if '..' in path:
            return False

        parts = path.split('.')
        if len(parts) > max_depth:
            return False

        for part in parts:
            if not part:
                return False
            if part.startswith('_'):
                return False

        return True

    @staticmethod
    def get_parent(path: str) -> str:
        """Get parent path."""
        parts = path.rsplit('.', 1)
        return parts[0] if len(parts) > 1 else ""

    @staticmethod
    def get_key(path: str) -> str:
        """Get the last key from path."""
        parts = path.rsplit('.', 1)
        return parts[-1] if parts else ""


# ==============================================================================
# ConfigCache - Memory cache management
# ==============================================================================

class ConfigCache:
    """Memory cache for configuration data."""

    __slots__ = ('_data', '_mtime', '_filepath')

    def __init__(self, filepath: str):
        self._data: Dict[str, Any] = {}
        self._mtime: float = 0.0
        self._filepath = filepath

    def get(self, path: str, default: Any = None) -> Any:
        """Get value from cache using dot notation path."""
        keys = PathParser.parse(path)
        return self._get_nested(self._data, keys, default)

    def set(self, path: str, value: Any) -> None:
        """Set value in cache using dot notation path."""
        keys = PathParser.parse(path)
        self._set_nested(self._data, keys, value)

    def is_stale(self) -> bool:
        """Check if cache is stale (file has been modified)."""
        if not os.path.exists(self._filepath):
            return True
        try:
            current_mtime = os.path.getmtime(self._filepath)
            return current_mtime > self._mtime
        except OSError:
            return True

    def refresh(self, data: Dict[str, Any], mtime: float) -> None:
        """Refresh cache with new data."""
        self._data = data
        self._mtime = mtime

    def to_dict(self) -> Dict[str, Any]:
        """Return copy of all cached data."""
        return dict(self._data)

    def _get_nested(self, data: Dict, keys: List[str], default: Any) -> Any:
        """Get value from nested dictionary."""
        current = data
        for key in keys:
            if isinstance(current, dict) and key in current:
                current = current[key]
            else:
                return default
        return current

    def _set_nested(self, data: Dict, keys: List[str], value: Any) -> None:
        """Set value in nested dictionary."""
        current = data
        for key in keys[:-1]:
            if key not in current:
                raise ConfigPathNotFoundError('.'.join(keys))
            if not isinstance(current[key], dict):
                raise ConfigPathNotFoundError('.'.join(keys))
            current = current[key]

        if keys:
            final_key = keys[-1]
            if final_key not in current and len(keys) > 1:
                parent_path = '.'.join(keys[:-1])
                if not isinstance(current, dict):
                    raise ConfigPathNotFoundError(parent_path)
            current[final_key] = value


# ==============================================================================
# FileLock - Process-safe file locking
# ==============================================================================

class FileLock:
    """Cross-platform file locking for multiprocess synchronization."""

    __slots__ = ('_path', '_fd', '_locked', '_timeout', '_retry_interval')

    def __init__(self, filepath: str, timeout: float = 5.0, retry_interval: float = 0.1):
        self._path = filepath + '.lock'
        self._fd: Optional[int] = None
        self._locked = False
        self._timeout = timeout
        self._retry_interval = retry_interval

    def acquire(self, timeout: Optional[float] = None) -> bool:
        """Acquire file lock."""
        if timeout is None:
            timeout = self._timeout

        start_time = time.time()

        while True:
            try:
                if self._try_lock():
                    self._write_lock_info()
                    self._locked = True
                    return True
            except (IOError, OSError):
                pass

            elapsed = time.time() - start_time
            if elapsed >= timeout:
                raise ConfigLockTimeoutError(self._path, timeout)

            time.sleep(self._retry_interval)

        return False

    def release(self) -> None:
        """Release file lock."""
        if not self._locked:
            return

        try:
            if self._fd is not None:
                if _IS_WINDOWS:
                    msvcrt.locking(self._fd, msvcrt.LK_UNLCK, 1)
                else:
                    fcntl.flock(self._fd, fcntl.LOCK_UN)
                os.close(self._fd)
                self._fd = None

            if os.path.exists(self._path):
                os.remove(self._path)
        except (IOError, OSError):
            pass
        finally:
            self._locked = False

    def _try_lock(self) -> bool:
        """Try to acquire lock once."""
        if self._check_stale_lock():
            self._cleanup_stale_lock()

        self._fd = os.open(self._path, os.O_CREAT | os.O_RDWR)

        try:
            if _IS_WINDOWS:
                msvcrt.locking(self._fd, msvcrt.LK_NBLCK, 1)
            else:
                fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            return True
        except (IOError, OSError):
            os.close(self._fd)
            self._fd = None
            return False

    def _write_lock_info(self) -> None:
        """Write lock owner information."""
        lock_info = {
            "pid": os.getpid(),
            "timestamp": time.time(),
            "hostname": os.environ.get('COMPUTERNAME', '')
        }
        try:
            with open(self._path, 'w') as f:
                json.dump(lock_info, f)
        except (IOError, OSError):
            pass

    def _check_stale_lock(self) -> bool:
        """Check if existing lock is stale."""
        if not os.path.exists(self._path):
            return False

        try:
            with open(self._path, 'r') as f:
                lock_info = json.load(f)

            pid = lock_info.get('pid')
            if pid is None:
                return True

            if not self._is_process_alive(pid):
                return True

            timestamp = lock_info.get('timestamp', 0)
            if time.time() - timestamp > 300:
                return True

            return False
        except (IOError, OSError, json.JSONDecodeError):
            return True

    def _cleanup_stale_lock(self) -> None:
        """Remove stale lock file."""
        try:
            if os.path.exists(self._path):
                os.remove(self._path)
        except (IOError, OSError):
            pass

    @staticmethod
    def _is_process_alive(pid: int) -> bool:
        """Check if process is alive."""
        try:
            if _IS_WINDOWS:
                import ctypes
                kernel32 = ctypes.windll.kernel32
                handle = kernel32.OpenProcess(0x1000, False, pid)
                if handle:
                    kernel32.CloseHandle(handle)
                    return True
                return False
            else:
                os.kill(pid, 0)
                return True
        except (OSError, AttributeError):
            return False

    def __enter__(self) -> 'FileLock':
        self.acquire()
        return self

    def __exit__(self, *args) -> None:
        self.release()


# ==============================================================================
# BackupManager - Backup creation and rotation
# ==============================================================================

class BackupManager:
    """Manages backup creation and rotation."""

    __slots__ = ('_filepath', '_max_count')

    def __init__(self, filepath: str, max_count: int = 5):
        self._filepath = filepath
        self._max_count = max_count

    def create_backup(self) -> Optional[str]:
        """Create a new backup file."""
        if not os.path.exists(self._filepath):
            return None

        self._rotate()

        backup_path = f"{self._filepath}.bak.1"
        try:
            shutil.copy2(self._filepath, backup_path)
            return backup_path
        except (IOError, OSError):
            return None

    def _rotate(self) -> None:
        """Rotate existing backup files."""
        for i in range(self._max_count, 0, -1):
            old_path = f"{self._filepath}.bak.{i}"
            new_path = f"{self._filepath}.bak.{i + 1}"

            if os.path.exists(old_path):
                if i >= self._max_count:
                    os.remove(old_path)
                else:
                    shutil.move(old_path, new_path)

    def get_backups(self) -> List[str]:
        """Get list of backup files in order."""
        backups = []
        for i in range(1, self._max_count + 1):
            backup_path = f"{self._filepath}.bak.{i}"
            if os.path.exists(backup_path):
                backups.append(backup_path)
        return backups

    def restore(self, number: int = 1) -> bool:
        """Restore from backup number."""
        backup_path = f"{self._filepath}.bak.{number}"
        if not os.path.exists(backup_path):
            return False

        try:
            shutil.copy2(backup_path, self._filepath)
            return True
        except (IOError, OSError):
            return False

    def cleanup_old_backups(self) -> None:
        """Remove backups exceeding max_count."""
        i = self._max_count + 1
        while True:
            backup_path = f"{self._filepath}.bak.{i}"
            if os.path.exists(backup_path):
                os.remove(backup_path)
                i += 1
            else:
                break


# ==============================================================================
# Parsers - YAML and JSON parsing
# ==============================================================================

class YamlParser:
    """Static class for YAML parsing."""

    @staticmethod
    def parse(content: str) -> Dict[str, Any]:
        """Parse YAML content."""
        if not YAML_AVAILABLE:
            raise ConfigParseError("", "PyYAML is not installed")
        return yaml.safe_load(content) or {}

    @staticmethod
    def dump(data: Dict[str, Any]) -> str:
        """Dump data to YAML string."""
        if not YAML_AVAILABLE:
            raise ConfigParseError("", "PyYAML is not installed")
        return yaml.dump(data, default_flow_style=False, allow_unicode=True)


class JsonParser:
    """Static class for JSON parsing."""

    @staticmethod
    def parse(content: str) -> Dict[str, Any]:
        """Parse JSON content."""
        return json.loads(content)

    @staticmethod
    def dump(data: Dict[str, Any]) -> str:
        """Dump data to JSON string."""
        return json.dumps(data, indent=2, ensure_ascii=False)


# ==============================================================================
# Security Classes (Hardening)
# ==============================================================================

class PathValidator:
    """Validates file paths for security."""

    DANGEROUS_PATTERNS = ['..', '~', '$', '%', '|', '>', '<', '`']

    @staticmethod
    def validate(filepath: str, base_dir: Optional[str] = None) -> bool:
        """Validate file path for security issues."""
        if not filepath:
            return False

        # Ensure filepath is string
        filepath_str = str(filepath)

        for pattern in PathValidator.DANGEROUS_PATTERNS:
            if pattern in filepath_str:
                raise ConfigSecurityError(
                    f"Path contains dangerous pattern: {pattern}",
                    ConfigSecurityError.PATH_TRAVERSAL,
                    filepath
                )

        path = Path(filepath)
        if path.is_symlink():
            raise ConfigSecurityError(
                "Symbolic links are not allowed",
                ConfigSecurityError.SYMLINK_ATTACK,
                filepath
            )

        if base_dir:
            try:
                resolved = path.resolve()
                base_resolved = Path(base_dir).resolve()
                if not str(resolved).startswith(str(base_resolved)):
                    raise ConfigSecurityError(
                        "Path traversal detected",
                        ConfigSecurityError.PATH_TRAVERSAL,
                        filepath
                    )
            except (OSError, ValueError):
                return False

        return True


class SizeValidator:
    """Validates sizes for security limits."""

    DEFAULT_MAX_VALUE_SIZE = 1048576
    DEFAULT_MAX_FILE_SIZE = 10485760

    @staticmethod
    def validate_value(value: Any, max_size: int = DEFAULT_MAX_VALUE_SIZE) -> bool:
        """Validate value size."""
        try:
            serialized = json.dumps(value, ensure_ascii=False)
            if len(serialized.encode('utf-8')) > max_size:
                raise ConfigSecurityError(
                    f"Value size exceeds limit: {max_size} bytes",
                    ConfigSecurityError.SIZE_EXCEEDED
                )
            return True
        except (TypeError, ValueError):
            return True

    @staticmethod
    def validate_file(filepath: str, max_size: int = DEFAULT_MAX_FILE_SIZE) -> bool:
        """Validate file size."""
        if not os.path.exists(filepath):
            return True

        size = os.path.getsize(filepath)
        if size > max_size:
            raise ConfigSecurityError(
                f"File size {size} exceeds limit: {max_size} bytes",
                ConfigSecurityError.SIZE_EXCEEDED,
                filepath
            )
        return True


class ChecksumManager:
    """Manages file checksums for integrity verification."""

    __slots__ = ('_filepath', '_algorithm')

    def __init__(self, filepath: str, algorithm: str = 'sha256'):
        self._filepath = filepath
        self._algorithm = algorithm

    def calculate(self) -> str:
        """Calculate file checksum."""
        if not os.path.exists(self._filepath):
            return ""

        hasher = hashlib.new(self._algorithm)
        with open(self._filepath, 'rb') as f:
            for chunk in iter(lambda: f.read(8192), b''):
                hasher.update(chunk)
        return hasher.hexdigest()

    def verify(self) -> bool:
        """Verify file checksum against stored value."""
        stored = self.load()
        if not stored:
            return True

        current = self.calculate()
        if current != stored:
            raise ConfigIntegrityError(self._filepath, stored, current)
        return True

    def save(self) -> None:
        """Save current checksum."""
        checksum = self.calculate()
        checksum_file = self._filepath + '.checksum'

        data = {
            "algorithm": self._algorithm,
            "checksum": checksum,
            "timestamp": time.time(),
            "size": os.path.getsize(self._filepath) if os.path.exists(self._filepath) else 0
        }

        with open(checksum_file, 'w') as f:
            json.dump(data, f)

    def load(self) -> Optional[str]:
        """Load stored checksum."""
        checksum_file = self._filepath + '.checksum'
        if not os.path.exists(checksum_file):
            return None

        try:
            with open(checksum_file, 'r') as f:
                data = json.load(f)
            return data.get('checksum')
        except (IOError, json.JSONDecodeError):
            return None


class AuditLogger:
    """Logs configuration changes for auditing."""

    __slots__ = ('_filepath', '_mask_sensitive')

    SENSITIVE_KEYWORDS = ['password', 'secret', 'key', 'token', 'credential', 'auth']

    def __init__(self, filepath: str, mask_sensitive: bool = True):
        self._filepath = filepath
        self._mask_sensitive = mask_sensitive

    def log(self, action: str, path: str, old_value: Any = None, new_value: Any = None) -> None:
        """Log an audit entry."""
        log_file = self._filepath + '.audit.log'

        if self._mask_sensitive:
            old_value = self._mask_value(path, old_value)
            new_value = self._mask_value(path, new_value)

        entry = {
            "timestamp": datetime.now().isoformat(),
            "action": action,
            "path": path,
            "old_value": str(old_value) if old_value is not None else None,
            "new_value": str(new_value) if new_value is not None else None,
            "pid": os.getpid()
        }

        try:
            with open(log_file, 'a') as f:
                f.write(json.dumps(entry, ensure_ascii=False) + '\n')
        except (IOError, OSError):
            pass

    def _mask_value(self, path: str, value: Any) -> Any:
        """Mask sensitive values."""
        if value is None:
            return None

        path_lower = path.lower()
        for keyword in self.SENSITIVE_KEYWORDS:
            if keyword in path_lower:
                value_str = str(value)
                if len(value_str) <= 4:
                    return "****"
                return value_str[:2] + "****" + value_str[-2:]

        return value
