# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
# See LICENSE file for details.
"""
Brain I/O: Transaction-safe JSON read/write with file locking.

Split from brain_utils.py for Law 7 compliance.
"""

import json
import os
import shutil
from pathlib import Path

# C4: File locking for concurrent agent safety
try:
    from filelock import FileLock
    _HAS_FILELOCK = True
except ImportError:
    _HAS_FILELOCK = False

# C4: Lock cache — one FileLock per path, reused across calls
_lock_cache: dict[str, "FileLock"] = {}
_LOCK_TIMEOUT = 60  # seconds (raised for 24+ concurrent swarm agents)


def _get_lock(path: Path):
    """Get or create a FileLock for the given path. No-op if filelock unavailable."""
    if not _HAS_FILELOCK:
        return None
    key = str(path.resolve())
    if key not in _lock_cache:
        _lock_cache[key] = FileLock(str(path) + ".lock", timeout=_LOCK_TIMEOUT)
    return _lock_cache[key]


class _NoOpLock:
    """Context manager fallback when filelock is unavailable."""
    def __enter__(self): return self
    def __exit__(self, *a): pass


def _acquire(path: Path):
    """Return a context manager that locks the file path."""
    lock = _get_lock(path)
    return lock if lock is not None else _NoOpLock()


def load_json(path: Path) -> list:
    """Load JSON file, return list (default empty). File-locked.
    On corruption: auto-restores from .bak if valid."""
    if not path.exists():
        return []
    try:
        with _acquire(path):
            with open(path, "r", encoding="utf-8") as f:
                return json.load(f)
    except json.JSONDecodeError:
        # Try recovering from backup
        bak = path.with_suffix(".bak")
        if bak.exists():
            try:
                with open(bak, "r", encoding="utf-8") as f:
                    data = json.load(f)
                # Backup is valid — restore it
                shutil.copy2(bak, path)
                import logging
                logging.getLogger("pulse.brain_io").warning(
                    "Recovered %s from backup (original was corrupted)", path.name)
                return data
            except (json.JSONDecodeError, OSError):
                pass
        return []
    except OSError:
        return []


def load_json_dict(path: Path) -> dict:
    """Load JSON file, return dict (default empty). File-locked.
    On corruption: auto-restores from .bak if valid."""
    if not path.exists():
        return {}
    try:
        with _acquire(path):
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
                return data if isinstance(data, dict) else {}
    except json.JSONDecodeError:
        bak = path.with_suffix(".bak")
        if bak.exists():
            try:
                with open(bak, "r", encoding="utf-8") as f:
                    data = json.load(f)
                if isinstance(data, dict):
                    shutil.copy2(bak, path)
                    import logging
                    logging.getLogger("pulse.brain_io").warning(
                        "Recovered %s from backup (original was corrupted)", path.name)
                    return data
            except (json.JSONDecodeError, OSError):
                pass
        return {}
    except OSError:
        return {}


def _replace_with_retry(tmp: Path, target: Path, max_attempts: int = 8):
    """os.replace with exponential backoff for Windows high-contention scenarios.
    WinError 5 (Access Denied) occurs when antivirus, Explorer, or concurrent
    swarm workers briefly hold the target file."""
    import time as _time
    for attempt in range(max_attempts):
        try:
            os.replace(str(tmp), str(target))
            return
        except PermissionError:
            if attempt == max_attempts - 1:
                raise
            _time.sleep(0.05 * (2 ** attempt))  # 50ms, 100ms, 200ms ... 6.4s


def save_json(path: Path, data, indent: int = 2):
    """Transaction-safe JSON write with undo backup. File-locked."""
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    try:
        with _acquire(path):
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=indent, ensure_ascii=False)
            _replace_with_retry(tmp, path)
    except OSError:
        if tmp.exists():
            tmp.unlink()
        raise


def atomic_update_json(path: Path, updater, default=None):
    """Atomic read-modify-write under a single lock acquisition.
    `updater` is a callable that receives the loaded data and returns modified data.
    `default` is the initial value if file doesn't exist (dict or list)."""
    if default is None:
        default = {}
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    try:
        with _acquire(path):
            # Read
            if path.exists():
                try:
                    with open(path, "r", encoding="utf-8") as f:
                        data = json.load(f)
                except (json.JSONDecodeError, OSError):
                    data = default
            else:
                data = default
            # Modify
            data = updater(data)
            # Write
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2, ensure_ascii=False)
            
            _replace_with_retry(tmp, path)
            return data
    except OSError:
        if tmp.exists():
            tmp.unlink()
        raise


def load_config() -> dict:
    """Load brain_config.json. Priority: user project dir > package default."""
    try:
        from .paths import get_project_dir, _is_dev_mode
        if not _is_dev_mode():
            user_config = get_project_dir() / "config" / "brain_config.json"
            if user_config.exists():
                return load_json_dict(user_config)
    except Exception:
        pass
    from .brain_utils import CONFIG_FILE
    return load_json_dict(CONFIG_FILE)
