"""CTCLR-08: bounded, owned paths and durable per-operation receipts."""
from contextlib import contextmanager
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import stat
import tempfile


def digest(value):
    return hashlib.sha256(json.dumps(value, sort_keys=True, default=str, separators=(',', ':')).encode()).hexdigest()


def safe(path):
    p = Path(path)
    if not p.is_absolute() or '..' in p.parts or any(x.is_symlink() for x in (p, *p.parents)):
        raise ValueError('UNSAFE_PATH')
    return p


def child(root, relative):
    root = safe(root)
    r = Path(relative)
    if r.is_absolute() or not r.parts or '..' in r.parts:
        raise ValueError('UNSAFE_RELATIVE_PATH')
    p = safe(root / r)
    if p == root or root not in p.parents:
        raise ValueError('OUTSIDE_OWNED_ROOT')
    return p


def tree_size(root, path):
    root, path = safe(root), safe(path)
    if root not in path.parents or not path.is_dir():
        raise ValueError('BUNDLE_DIRECTORY_REQUIRED')
    device = root.stat().st_dev
    total = 0
    for parent, dirs, files in os.walk(path, followlinks=False):
        for name in dirs + files:
            p = Path(parent) / name
            s = p.lstat()
            if stat.S_ISLNK(s.st_mode) or s.st_dev != device or os.path.ismount(p):
                raise ValueError('BUNDLE_BOUNDARY')
            if name in files:
                if not stat.S_ISREG(s.st_mode) or s.st_nlink != 1:
                    raise ValueError('BUNDLE_FILE_TYPE')
                total += s.st_size
    if path.stat().st_dev != device or os.path.ismount(path):
        raise ValueError('BUNDLE_MOUNT')
    return total


def atomic(path, value):
    path = safe(path)
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    raw = value if isinstance(value, bytes) else json.dumps(value, ensure_ascii=False, default=str, indent=2).encode()
    fd, name = tempfile.mkstemp(prefix='.ct-', dir=path.parent)
    try:
        with os.fdopen(fd, 'wb') as f:
            os.fchmod(f.fileno(), 0o600)
            f.write(raw); f.flush(); os.fsync(f.fileno())
        os.replace(name, path)
        fd = os.open(path.parent, os.O_DIRECTORY)
        try: os.fsync(fd)
        finally: os.close(fd)
    finally:
        Path(name).unlink(missing_ok=True)


@contextmanager
def lock(path):
    p = safe(path)
    fd = os.open(p, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
    try:
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        yield
    finally:
        os.close(fd)


def remove_training(root, relative, job):
    """Only complete owned bundles; rename makes a crash after removal resumable."""
    from app.training_store import MARKER
    root = safe(root)
    final = child(root, relative)
    tomb = child(root, '.ct-retiring/' + job)
    if final.exists() and tomb.exists():
        raise ValueError('AMBIGUOUS_TRAINING_BUNDLE')
    source = final if final.exists() else tomb
    if not source.exists():
        return
    marker = safe(source / 'metadata.json')
    meta = json.loads(marker.read_text())
    if meta.get('owner') != MARKER or meta.get('jobId') != job:
        raise ValueError('TRAINING_OWNERSHIP')
    tree_size(root, source)
    if not shutil.rmtree.avoids_symlink_attacks:
        raise ValueError('SAFE_RMTREE_REQUIRED')
    if final.exists():
        tomb.parent.mkdir(mode=0o700, exist_ok=True)
        final.rename(tomb)
    shutil.rmtree(tomb)
