"""Security checks and user warnings for model source inputs."""

from __future__ import annotations

import logging
import os
import stat
import sys
import threading
from dataclasses import dataclass
from pathlib import Path

logger = logging.getLogger(__name__)

_INSECURE_WRITE_MASK = stat.S_IWGRP | stat.S_IWOTH
_SYNTHETIC_PERMISSION_FS_TYPES = {"9p", "drvfs", "fuse", "fuseblk", "v9fs"}
_MAX_LOCAL_MODEL_PATH_DEPTH = 10
_MAX_LOCAL_MODEL_PATH_ENTRIES = 10000
_warned_remote_model_ids: set[tuple[str, str]] = set()
_warned_permission_filesystems: set[str] = set()
_warned_remote_model_ids_lock = threading.Lock()
_warned_permission_filesystems_lock = threading.Lock()


@dataclass(frozen=True)
class ModelSourceInfo:
    """Normalized model source information."""

    model_id: str
    is_local_path: bool


def normalize_model_source(
    model_id: str,
    remote_source: str,
    *,
    validate_local: bool = True,
    warn_remote: bool = True,
) -> ModelSourceInfo:
    """Normalize a model source and apply local-mode safety checks.

    Existing filesystem paths are treated as local safe-mode inputs and are
    normalized to absolute real paths. Non-existing inputs are treated as model
    ids for Hugging Face or ModelScope and receive an explicit remote-code risk
    warning.

    Raises:
        OSError, ValueError: If an existing local path cannot be resolved or
            fails safe-mode validation. This is intentional fail-closed
            behavior; callers must not fall back to the original path.

    Thread Safety:
        This function is thread-safe. Internal locks protect de-duplicated
        warnings from concurrent callers.
    """

    if not model_id:
        return ModelSourceInfo(model_id=model_id, is_local_path=False)

    candidate = Path(model_id).expanduser()
    if candidate.exists() or candidate.is_symlink():
        resolved = validate_local_model_path(candidate) if validate_local else candidate.resolve(strict=True)
        return ModelSourceInfo(model_id=str(resolved), is_local_path=True)

    if warn_remote:
        warn_remote_code_risk(model_id, remote_source)
    return ModelSourceInfo(model_id=model_id, is_local_path=False)


def warn_remote_code_risk(model_id: str, remote_source: str) -> None:
    """Print a once-per-model warning for remote model id mode."""

    key = (str(remote_source), model_id)
    with _warned_remote_model_ids_lock:
        if key in _warned_remote_model_ids:
            return
        _warned_remote_model_ids.add(key)
    print(
        "[msmodeling security] model_id mode is enabled for "
        f"{model_id!r} from {remote_source!s}. Loading Hugging Face or ModelScope model ids may execute "
        "remote Python code when transformers falls back to trust_remote_code=True. msmodeling does not "
        "provide security guarantees for remote model code; prefer safe local mode with a reviewed absolute "
        "model path.",
        file=sys.stderr,
    )


def validate_local_model_path(
    path: str | Path,
    *,
    max_depth: int = _MAX_LOCAL_MODEL_PATH_DEPTH,
    max_entries: int = _MAX_LOCAL_MODEL_PATH_ENTRIES,
) -> Path:
    """Validate a local model path and return its absolute resolved path.

    The local mode rejects symlinked paths, symlinked entries inside the model
    tree, unexpected owners, and group/world-writable permission bits when the
    filesystem exposes reliable POSIX mode bits. Directory validation stops
    early and fails closed when ``max_depth`` or ``max_entries`` is exceeded.

    On non-POSIX platforms without ``os.geteuid()``, owner validation is not
    available; symlink and permission-bit checks still run where the platform
    exposes those attributes.
    """

    expanded = Path(path).expanduser()
    if not expanded.exists() and not expanded.is_symlink():
        raise FileNotFoundError(f"Local model path does not exist: {expanded}")

    absolute = expanded if expanded.is_absolute() else Path.cwd() / expanded
    return _validate_local_model_path(os.fspath(absolute), max_depth, max_entries)


def _validate_local_model_path(absolute_path: str, max_depth: int, max_entries: int) -> Path:
    """Validate an absolute local model path."""

    if max_depth < 0:
        raise ValueError(f"max_depth must be non-negative, got {max_depth}")
    if max_entries < 1:
        raise ValueError(f"max_entries must be at least 1, got {max_entries}")

    absolute = Path(absolute_path)
    _validate_no_symlink_path_components(absolute)

    resolved = absolute.resolve(strict=True)
    enforce_permission_bits = _permission_bits_are_enforceable(resolved)
    _validate_local_entry(resolved, enforce_permission_bits=enforce_permission_bits)

    if resolved.is_dir():
        entry_count = 0
        for root, dir_names, file_names in os.walk(resolved, followlinks=False, onerror=_raise_walk_error):
            root_path = Path(root)
            relative_root = root_path.relative_to(resolved)
            current_depth = len(relative_root.parts)
            if current_depth > max_depth or (current_depth == max_depth and dir_names):
                raise ValueError(
                    "Local model path exceeds maximum validation depth "
                    f"({max_depth}). Please use a reviewed model directory with a shallower structure: {root_path}"
                )
            entry_count += len(dir_names) + len(file_names)
            if entry_count > max_entries:
                raise ValueError(
                    "Local model path exceeds maximum validation entry count "
                    f"({max_entries}). Please use a reviewed model directory with fewer files: {resolved}"
                )
            _validate_local_entry(root_path, enforce_permission_bits=enforce_permission_bits)
            for entry_name in dir_names + file_names:
                _validate_local_entry(root_path / entry_name, enforce_permission_bits=enforce_permission_bits)

    return resolved


def _validate_no_symlink_path_components(path: Path) -> None:
    current = Path(path.anchor)
    parts = path.parts[1:] if path.anchor else path.parts
    for part in parts:
        current = current / part
        if current.is_symlink():
            raise ValueError(f"Local model path must not contain symlinks: {current}")


def _raise_walk_error(error: OSError) -> None:
    raise ValueError(f"Failed to inspect local model path entry: {error.filename}") from error


def _validate_local_entry(path: Path, *, enforce_permission_bits: bool) -> None:
    try:
        metadata = path.lstat()
    except OSError as exc:
        raise ValueError(f"Failed to inspect local model path entry: {path}") from exc

    mode = metadata.st_mode
    if stat.S_ISLNK(mode):
        raise ValueError(f"Local model path must not contain symlinks: {path}")
    if not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
        raise ValueError(f"Local model path may only contain regular files and directories: {path}")

    _validate_owner(path, metadata.st_uid)
    if enforce_permission_bits and mode & _INSECURE_WRITE_MASK:
        raise ValueError(
            f"Local model path entries must not be group- or world-writable. Please fix permissions for: {path}"
        )


def _validate_owner(path: Path, owner_uid: int) -> None:
    if not hasattr(os, "geteuid"):
        return

    current_uid = os.geteuid()
    allowed_uids = {current_uid}
    if current_uid != 0:
        allowed_uids.add(0)
    if owner_uid not in allowed_uids:
        raise ValueError(
            "Local model path entries must be owned by the current user"
            f"{' or root' if current_uid != 0 else ''}: {path}"
        )


def _permission_bits_are_enforceable(path: Path) -> bool:
    fs_type = _find_mount_type(path)
    if not _permission_bits_may_be_synthetic(fs_type):
        return True

    should_warn = False
    with _warned_permission_filesystems_lock:
        if fs_type not in _warned_permission_filesystems:
            _warned_permission_filesystems.add(fs_type)
            should_warn = True
    if should_warn:
        logger.warning(
            "Local model path is on filesystem type %s, whose POSIX permission bits may be synthetic. "
            "Owner and symlink checks are still enforced, but group/world-writable mode bits are not rejected.",
            fs_type,
        )
    return False


def _permission_bits_may_be_synthetic(fs_type: str | None) -> bool:
    if fs_type is None:
        return False
    return fs_type in _SYNTHETIC_PERMISSION_FS_TYPES or fs_type.startswith("fuse.")


def _find_mount_type(path: Path) -> str | None:
    mounts_path = Path("/proc/mounts")
    if not mounts_path.is_file():
        return None

    target = os.path.abspath(os.fspath(path))
    best_mount = ""
    best_fs_type: str | None = None
    try:
        with mounts_path.open(encoding="utf-8") as mounts_file:
            for line in mounts_file:
                parts = line.split()
                if len(parts) < 3:
                    continue
                mount_point = _decode_mount_field(parts[1])
                try:
                    common_path = os.path.commonpath([target, mount_point])
                except ValueError:
                    continue
                if common_path == mount_point and len(mount_point) > len(best_mount):
                    best_mount = mount_point
                    best_fs_type = parts[2]
    except OSError:
        return None
    return best_fs_type


def _decode_mount_field(value: str) -> str:
    return value.replace("\\040", " ").replace("\\011", "\t").replace("\\012", "\n").replace("\\134", "\\")