"""
Configuration for Majoor Assets Manager.

Output directory resolution order
=================================
The output root is resolved at import time by ``_resolve_output_root()``.
The **first** strategy that returns a valid directory wins:

1. **Environment variable** – ``MJR_AM_OUTPUT_DIRECTORY`` or
   ``MAJOOR_OUTPUT_DIRECTORY``, or the ``.mjr_output_directory_override``
   file next to this package.
2. **ComfyUI CLI argument** – ``--output-directory`` parsed from
   ``comfy.cli_args`` (loaded lazily).
3. **folder_paths module** – ``folder_paths.get_output_directory()`` from
   the already-loaded ``sys.modules`` dict, then a fresh import.
4. **sys.argv fallback** – ``--output-directory`` parsed from raw argv.
5. **Filesystem heuristic** – walk parent directories looking for a ComfyUI
   root (contains ``main.py`` + ``folder_paths.py``), then ``<root>/output``.
6. **cwd fallback** – ``Path.cwd() / "output"`` (last resort).

A log line at startup (level INFO) reports which strategy was selected.

Input/index directory resolution follows a similar pattern; see
``_resolve_index_dir()``.
"""
import contextlib
import logging
import os
import sys
import threading
from pathlib import Path

from .utils import env_bool

logger = logging.getLogger(__name__)
_INIT_LOCK = threading.Lock()
_DIRS_INITIALIZED = False
_OUTPUT_DIR_OVERRIDE_FILE_PATH = Path(__file__).resolve().parents[1] / ".mjr_output_directory_override"
_INDEX_DIR_OVERRIDE_ENV_NAMES = ("MJR_AM_INDEX_DIRECTORY", "MAJOOR_INDEX_DIRECTORY")
_INDEX_DIR_OVERRIDE_FILE_PATH = Path(__file__).resolve().parents[1] / ".mjr_index_directory_override"
_INDEX_DIR_LOGGED_SELECTIONS: set[tuple[str, str]] = set()


def _env_raw(*names: str, default: str | None = None) -> str | None:
    for name in names:
        if not name:
            continue
        try:
            val = os.getenv(name)
        except Exception:  # OK: os.getenv can raise on broken env (embedded runtimes)
            val = None
        if val is not None and str(val).strip() != "":
            return str(val).strip()
    return default


def _env_int(default: int, *names: str, min_value: int | None = None, max_value: int | None = None) -> int:
    raw = _env_raw(*names)
    if raw is None:
        return default
    try:
        value = int(raw)
    except (TypeError, ValueError):
        logger.warning("Invalid integer for %s=%r, using default=%s", names[0] if names else "<unknown>", raw, default)
        return default
    if min_value is not None and value < min_value:
        logger.warning("Value too small for %s=%s, clamped to %s", names[0] if names else "<unknown>", value, min_value)
        value = min_value
    if max_value is not None and value > max_value:
        logger.warning("Value too large for %s=%s, clamped to %s", names[0] if names else "<unknown>", value, max_value)
        value = max_value
    return value


def _env_float(default: float, *names: str, min_value: float | None = None, max_value: float | None = None) -> float:
    raw = _env_raw(*names)
    if raw is None:
        return default
    try:
        value = float(raw)
    except (TypeError, ValueError):
        logger.warning("Invalid float for %s=%r, using default=%s", names[0] if names else "<unknown>", raw, default)
        return default
    if min_value is not None and value < min_value:
        logger.warning("Value too small for %s=%s, clamped to %s", names[0] if names else "<unknown>", value, min_value)
        value = min_value
    if max_value is not None and value > max_value:
        logger.warning("Value too large for %s=%s, clamped to %s", names[0] if names else "<unknown>", value, max_value)
        value = max_value
    return value


def _env_bool(default: bool, *names: str) -> bool:
    for name in names:
        if not name:
            continue
        try:
            if name in os.environ:
                return env_bool(name, default)
        except Exception:  # OK: env access guard (embedded/restricted runtimes)
            continue
    return default


def _read_override_from_file(file_path: Path) -> str | None:
    """Read a single-line directory override from *file_path*.

    Returns the stripped content, or ``None`` when the file is absent, empty,
    or cannot be read (locked, corrupt, permission error, etc.).
    """
    try:
        if not file_path.exists():
            return None
        raw = file_path.read_text(encoding="utf-8").strip()
        return raw or None
    except Exception:  # OK: file may be missing/locked/corrupt — graceful fallback
        return None


def _read_output_dir_override_from_file() -> str | None:
    return _read_override_from_file(_OUTPUT_DIR_OVERRIDE_FILE_PATH)


def _read_index_dir_override_from_file() -> str | None:
    return _read_override_from_file(_INDEX_DIR_OVERRIDE_FILE_PATH)

def _resolve_output_root() -> Path:
    resolved = _resolve_output_root_from_env()
    if resolved is not None:
        logger.info("Selected output directory strategy=env path=%s", resolved)
        return resolved
    resolved = _resolve_output_root_from_comfy_cli()
    if resolved is not None:
        logger.info("Selected output directory strategy=comfy_cli path=%s", resolved)
        return resolved
    resolved = _resolve_output_root_from_folder_paths()
    if resolved is not None:
        logger.info("Selected output directory strategy=folder_paths path=%s", resolved)
        return resolved
    resolved = _resolve_output_root_fallback()
    logger.info("Selected output directory strategy=fallback path=%s", resolved)
    return resolved


def _resolve_output_root_from_env() -> Path | None:
    env_path = _env_raw("MJR_AM_OUTPUT_DIRECTORY", "MAJOOR_OUTPUT_DIRECTORY")
    if not env_path:
        env_path = _read_output_dir_override_from_file()
    if not env_path:
        return None
    try:
        return Path(env_path).expanduser().resolve()
    except (OSError, RuntimeError):
        logger.warning("Failed to resolve output directory override: %s, using fallback", env_path)
        return None


def _resolve_output_root_from_comfy_cli() -> Path | None:
    try:
        from comfy.cli_args import args as comfy_args  # type: ignore

        cli_out = getattr(comfy_args, "output_directory", None)
        if cli_out:
            return Path(str(cli_out)).expanduser().resolve()
    except Exception:  # OK: comfy module may not be loaded yet or at all
        return None
    return None


def _resolve_output_root_from_folder_paths() -> Path | None:
    output_dir = _folder_paths_output_dir_from_loaded_module()
    if output_dir is not None:
        return output_dir
    try:
        import folder_paths

        return Path(folder_paths.get_output_directory()).resolve()
    except (ImportError, ModuleNotFoundError):
        logger.warning("folder_paths module not available, using fallback path detection")
    except (AttributeError, TypeError) as exc:
        logger.warning("folder_paths.get_output_directory() failed: %s, using fallback", exc)
    except (OSError, RuntimeError) as exc:
        logger.warning("Failed to resolve output directory from folder_paths: %s, using fallback", exc)
    return None


def get_registered_model_paths() -> dict[str, list[str]]:
    """Best-effort discovery of ComfyUI folder_paths categories for navigation/index hints."""
    try:
        import folder_paths  # type: ignore

        categories = [
            "checkpoints",
            "loras",
            "vae",
            "text_encoders",
            "clip_vision",
            "diffusion_models",
            "upscale_models",
            "embeddings",
            "controlnet",
            "diffusers",
            "hypernetworks",
            "classifiers",
        ]
        result: dict[str, list[str]] = {}
        getter = getattr(folder_paths, "get_folder_paths", None)
        if not callable(getter):
            return result
        for category in categories:
            try:
                values = getter(category)
                if values:
                    result[category] = [str(Path(v).resolve(strict=False)) for v in values]
            except Exception:  # OK: individual category may be unregistered
                continue
        return result
    except Exception:  # OK: folder_paths module may be absent or broken
        return {}


def _folder_paths_output_dir_from_loaded_module() -> Path | None:
    try:
        fp_mod = sys.modules.get("folder_paths")
        if fp_mod and hasattr(fp_mod, "get_output_directory"):
            return Path(fp_mod.get_output_directory()).resolve()
    except Exception:  # OK: folder_paths may not be loaded or may lack the method
        return None
    return None


def _resolve_output_root_fallback() -> Path:
    try:
        argv_output = _resolve_output_root_from_argv()
        if argv_output is not None:
            return argv_output
        comfy_root = _detect_comfy_root(Path(__file__).resolve())
        if comfy_root is not None:
            output_dir = comfy_root / "output"
            if output_dir.is_dir():
                return output_dir.resolve()
        logger.warning("Output directory not found from ComfyUI hints, creating fallback in current working directory")
        fallback_dir = Path.cwd() / "output"
        fallback_dir.mkdir(exist_ok=True)
        return fallback_dir.resolve()
    except (OSError, RuntimeError) as exc:
        logger.error("All fallback strategies failed: %s", exc)
        fallback = Path.cwd() / "output"
        try:
            fallback.mkdir(exist_ok=True)
        except Exception as mkdir_exc:
            logger.warning("Failed to create fallback output directory %s: %s", fallback, mkdir_exc)
        return fallback


def _resolve_output_root_from_argv() -> Path | None:
    try:
        argv = list(sys.argv or [])
        for i, token in enumerate(argv):
            if token == "--output-directory" and i + 1 < len(argv):
                return Path(str(argv[i + 1])).expanduser().resolve()
            if isinstance(token, str) and token.startswith("--output-directory="):
                return Path(token.split("=", 1)[1]).expanduser().resolve()
    except Exception:
        return None
    return None


def _detect_comfy_root(current_file: Path) -> Path | None:
    for parent in current_file.parents:
        if (parent / "main.py").is_file() and (parent / "folder_paths.py").is_file():
            return parent
    # No marker files found — cannot reliably detect ComfyUI root.
    return None

OUTPUT_ROOT_PATH = _resolve_output_root()
OUTPUT_ROOT = str(OUTPUT_ROOT_PATH)


def get_runtime_output_root() -> str:
    """
    Resolve output root at runtime.

    Priority:
    1) live override from MAJOOR_OUTPUT_DIRECTORY
    2) startup-resolved OUTPUT_ROOT fallback
    """
    env_path = str(_env_raw("MJR_AM_OUTPUT_DIRECTORY", "MAJOOR_OUTPUT_DIRECTORY", default="") or "").strip()
    if env_path:
        try:
            return str(Path(env_path).expanduser().resolve())
        except Exception:  # OK: path may be invalid — fall through to static output
            pass
    try:
        return str(Path(OUTPUT_ROOT).expanduser().resolve())
    except Exception:  # OK: OUTPUT_ROOT already resolved at startup; str fallback is safe
        return str(OUTPUT_ROOT)


def _normalize_index_dir_candidate(raw: str, output_root: Path) -> Path:
    candidate = Path(str(raw or "").strip()).expanduser()
    if not candidate.is_absolute():
        candidate = output_root / candidate
    return candidate.resolve(strict=False)


def _log_selected_index_dir(strategy: str, resolved: Path) -> None:
    key = (strategy, str(resolved))
    if key in _INDEX_DIR_LOGGED_SELECTIONS:
        logger.debug("Selected index directory strategy=%s path=%s", strategy, resolved)
        return
    _INDEX_DIR_LOGGED_SELECTIONS.add(key)
    logger.info("Selected index directory strategy=%s path=%s", strategy, resolved)


def _resolve_index_dir() -> Path:
    raw = str(_env_raw(*_INDEX_DIR_OVERRIDE_ENV_NAMES, default="") or "").strip()
    if not raw:
        raw = str(_read_index_dir_override_from_file() or "").strip()
    if raw:
        try:
            resolved = _normalize_index_dir_candidate(raw, OUTPUT_ROOT_PATH)
            _log_selected_index_dir("override", resolved)
            return resolved
        except Exception:
            logger.warning("Invalid index directory override: %s", raw)
    resolved = (OUTPUT_ROOT_PATH / "_mjr_index").resolve(strict=False)
    _log_selected_index_dir("default", resolved)
    return resolved


def get_runtime_index_dir() -> str:
    try:
        return str(_resolve_index_dir())
    except Exception:  # OK: fallback to startup-resolved INDEX_DIR
        return str(INDEX_DIR)


def get_runtime_index_dir_path() -> Path:
    try:
        return _resolve_index_dir()
    except Exception:  # OK: fallback to startup-resolved INDEX_DIR_PATH
        return INDEX_DIR_PATH


def get_runtime_index_db_path() -> Path:
    return get_runtime_index_dir_path() / "assets.sqlite"


def get_runtime_vectors_db_path() -> Path:
    return get_runtime_index_dir_path() / "vectors.sqlite"


def get_runtime_collections_dir_path() -> Path:
    return get_runtime_index_dir_path() / "collections"


def set_index_directory_override(path: str) -> str:
    normalized = str(path or "").strip()
    if not normalized:
        for env_name in _INDEX_DIR_OVERRIDE_ENV_NAMES:
            os.environ.pop(env_name, None)
        try:
            if _INDEX_DIR_OVERRIDE_FILE_PATH.exists():
                _INDEX_DIR_OVERRIDE_FILE_PATH.unlink()
        except Exception:  # OK: best-effort cleanup of override file
            pass
        return ""

    resolved = str(_normalize_index_dir_candidate(normalized, OUTPUT_ROOT_PATH))
    for env_name in _INDEX_DIR_OVERRIDE_ENV_NAMES:
        os.environ[env_name] = resolved
    _write_index_override_file_atomic(resolved)
    return resolved


def _write_index_override_file_atomic(resolved: str) -> None:
    """Write the index directory override file atomically (write-then-rename)."""
    tmp = _INDEX_DIR_OVERRIDE_FILE_PATH.with_suffix(".tmp")
    try:
        tmp.write_text(resolved + "\n", encoding="utf-8")
        tmp.replace(_INDEX_DIR_OVERRIDE_FILE_PATH)
    except Exception:  # OK: atomic write failed — clean up temp file silently
        with contextlib.suppress(Exception):
            tmp.unlink(missing_ok=True)

# Platform detection
IS_WINDOWS = sys.platform == "win32"

# SQLite index configuration
INDEX_DIR_PATH = _resolve_index_dir()
INDEX_DIR = str(INDEX_DIR_PATH)
INDEX_DB_PATH = INDEX_DIR_PATH / "assets.sqlite"
INDEX_DB = str(INDEX_DB_PATH)
VECTORS_DB_PATH = INDEX_DIR_PATH / "vectors.sqlite"
VECTORS_DB = str(VECTORS_DB_PATH)

# Collections (user-curated sets of assets)
COLLECTIONS_DIR_PATH = INDEX_DIR_PATH / "collections"
COLLECTIONS_DIR = str(COLLECTIONS_DIR_PATH)


def initialize_directories() -> None:
    global _DIRS_INITIALIZED
    if _DIRS_INITIALIZED:
        return
    with _INIT_LOCK:
        if _DIRS_INITIALIZED:
            return
        os.makedirs(get_runtime_index_dir_path(), exist_ok=True)
        os.makedirs(get_runtime_collections_dir_path(), exist_ok=True)
        _DIRS_INITIALIZED = True

# External tool overrides (portable vs. system-wide)
EXIFTOOL_BIN: str = _env_raw("MJR_AM_EXIFTOOL_PATH", "MAJOOR_EXIFTOOL_PATH", "MAJOOR_EXIFTOOL_BIN", default="exiftool") or "exiftool"
FFPROBE_BIN: str = _env_raw("MJR_AM_FFPROBE_PATH", "MAJOOR_FFPROBE_PATH", "MAJOOR_FFPROBE_BIN", default="ffprobe") or "ffprobe"

TOOL_LOCATIONS: dict[str, str] = {
    "exiftool": EXIFTOOL_BIN,
    "ffprobe": FFPROBE_BIN,
}

def get_tool_paths() -> dict[str, str]:
    """Return the resolved external tool executable paths."""
    return TOOL_LOCATIONS.copy()

EXIFTOOL_MIN_VERSION = str(_env_raw("MJR_AM_EXIFTOOL_MIN_VERSION", "MAJOOR_EXIFTOOL_MIN_VERSION", default="") or "").strip()
FFPROBE_MIN_VERSION = str(_env_raw("MJR_AM_FFPROBE_MIN_VERSION", "MAJOOR_FFPROBE_MIN_VERSION", default="") or "").strip()

# Media probe backend setting
# Options: "auto" (recommended), "exiftool", "ffprobe", "both"
MEDIA_PROBE_BACKEND = str(_env_raw("MJR_AM_MEDIA_PROBE_BACKEND", "MAJOOR_MEDIA_PROBE_BACKEND", default="auto") or "auto")

# Tool timeouts.
# 15s/10s are conservative defaults that keep UI responsive while allowing slow media probes.
EXIFTOOL_TIMEOUT = _env_int(15, "MJR_AM_EXIFTOOL_TIMEOUT", "MAJOOR_EXIFTOOL_TIMEOUT", min_value=1, max_value=120)
FFPROBE_TIMEOUT = _env_int(10, "MJR_AM_FFPROBE_TIMEOUT", "MAJOOR_FFPROBE_TIMEOUT", min_value=1, max_value=120)
TOOL_LOW_PRIORITY_SUBPROCESSES = _env_bool(
    True,
    "MJR_AM_TOOL_LOW_PRIORITY_SUBPROCESSES",
    "MAJOOR_TOOL_LOW_PRIORITY_SUBPROCESSES",
)

# Database tuning.
# 2 MiB default metadata JSON cap limits DB bloat from oversized embedded metadata blobs.
DB_TIMEOUT = _env_float(10.0, "MJR_AM_DB_TIMEOUT", "MAJOOR_DB_TIMEOUT", min_value=1.0, max_value=300.0)
DB_MAX_CONNECTIONS = _env_int(8, "MJR_AM_DB_MAX_CONNECTIONS", "MAJOOR_DB_MAX_CONNECTIONS", min_value=1, max_value=64)
DB_QUERY_TIMEOUT = _env_float(60.0, "MJR_AM_DB_QUERY_TIMEOUT", "MAJOOR_DB_QUERY_TIMEOUT", min_value=1.0, max_value=600.0)
TO_THREAD_TIMEOUT_S = _env_float(30.0, "MJR_AM_TO_THREAD_TIMEOUT", "MAJOOR_TO_THREAD_TIMEOUT", min_value=1.0, max_value=300.0)
EXECUTION_IDLE_GRACE_SECONDS = _env_float(
    6.0,
    "MJR_AM_EXECUTION_IDLE_GRACE_SECONDS",
    "MAJOOR_EXECUTION_IDLE_GRACE_SECONDS",
    min_value=0.0,
    max_value=300.0,
)
MAX_METADATA_JSON_BYTES = _env_int(2 * 1024 * 1024, "MJR_AM_MAX_METADATA_JSON_BYTES", "MAJOOR_MAX_METADATA_JSON_BYTES", min_value=64 * 1024, max_value=32 * 1024 * 1024)
METADATA_CACHE_MAX = _env_int(100_000, "MJR_AM_METADATA_CACHE_MAX", "MAJOOR_METADATA_CACHE_MAX", min_value=1000, max_value=5_000_000)
METADATA_CACHE_TTL_SECONDS = _env_float(90.0 * 24.0 * 3600.0, "MJR_AM_METADATA_CACHE_TTL_SECONDS", "MAJOOR_METADATA_CACHE_TTL_SECONDS", min_value=60.0, max_value=3650.0 * 24.0 * 3600.0)
METADATA_CACHE_CLEANUP_INTERVAL_SECONDS = _env_float(300.0, "MJR_AM_METADATA_CACHE_CLEANUP_INTERVAL_SECONDS", "MAJOOR_METADATA_CACHE_CLEANUP_INTERVAL_SECONDS", min_value=5.0, max_value=3600.0)
METADATA_EXTRACT_CONCURRENCY = _env_int(1, "MJR_AM_METADATA_EXTRACT_CONCURRENCY", "MAJOOR_METADATA_EXTRACT_CONCURRENCY", min_value=1, max_value=16)

# Max number of newly-added asset IDs pushed as mjr-asset-added events in one index_paths call.
# Increase via MAJOOR_BATCH_ASSET_PUSH_LIMIT for large batch workflows (NL-4).
BATCH_ASSET_PUSH_LIMIT = _env_int(50, "MAJOOR_BATCH_ASSET_PUSH_LIMIT", min_value=1, max_value=500)

# Index dedupe (avoid double-indexing bursts from multiple event sources).
# 2s window catches duplicate watcher + scan events for the same file update burst.
INDEX_DEDUPE_TTL_SECONDS = _env_float(2.0, "MJR_AM_INDEX_DEDUPE_TTL_SECONDS", "MAJOOR_INDEX_DEDUPE_TTL_SECONDS", min_value=0.1, max_value=60.0)
INDEX_DEDUPE_MAX = _env_int(5000, "MJR_AM_INDEX_DEDUPE_MAX", "MAJOOR_INDEX_DEDUPE_MAX", min_value=100, max_value=200_000)

# File watcher (watches for manual file additions in output/custom directories)
# Disable with MJR_ENABLE_WATCHER=0
WATCHER_ENABLED = _env_bool(True, "MJR_AM_ENABLE_WATCHER", "MJR_ENABLE_WATCHER")
WATCHER_START_ON_BOOT = _env_bool(
    False,
    "MJR_AM_WATCHER_START_ON_BOOT",
    "MJR_WATCHER_START_ON_BOOT",
)
# 3000ms absorbs file-write bursts from generators that write temp + final files.
WATCHER_DEFAULT_DEBOUNCE_MS = _env_int(3000, "MJR_AM_WATCHER_DEBOUNCE_MS", "MJR_WATCHER_DEBOUNCE_MS", min_value=0, max_value=120_000)
WATCHER_DEFAULT_DEDUPE_TTL_MS = _env_int(3000, "MJR_AM_WATCHER_DEDUPE_TTL_MS", "MJR_WATCHER_DEDUPE_TTL_MS", min_value=0, max_value=120_000)
WATCHER_DEBOUNCE_MS = WATCHER_DEFAULT_DEBOUNCE_MS
WATCHER_DEDUPE_TTL_MS = WATCHER_DEFAULT_DEDUPE_TTL_MS
WATCHER_GENERATED_GRACE_SECONDS = _env_float(
    15.0,
    "MJR_AM_WATCHER_GENERATED_GRACE_SECONDS",
    "MJR_WATCHER_GENERATED_GRACE_SECONDS",
    min_value=0.0,
    max_value=300.0,
)
# Backward-compatible aliases:
# - MAJOOR_WATCHER_MAX_PENDING_FILES
# - MAJOOR_WATCHER_MIN_FILE_SIZE
# - MAJOOR_WATCHER_MAX_FILE_SIZE
WATCHER_MAX_FILE_SIZE_BYTES = _env_int(
    512 * 1024 * 1024,
    "MJR_AM_WATCHER_MAX_FILE_SIZE_BYTES",
    "MJR_WATCHER_MAX_FILE_SIZE_BYTES",
    "MAJOOR_WATCHER_MAX_FILE_SIZE",
    min_value=1024,
    max_value=32 * 1024 * 1024 * 1024,
)
WATCHER_MIN_FILE_SIZE_BYTES = _env_int(
    100,
    "MJR_AM_WATCHER_MIN_FILE_SIZE_BYTES",
    "MJR_WATCHER_MIN_FILE_SIZE_BYTES",
    "MAJOOR_WATCHER_MIN_FILE_SIZE",
    min_value=0,
    max_value=10_000_000,
)
WATCHER_FLUSH_MAX_FILES = _env_int(256, "MJR_AM_WATCHER_FLUSH_MAX_FILES", "MJR_WATCHER_FLUSH_MAX_FILES", min_value=1, max_value=5000)
# Alert if >=512 file events are seen in 60s, then silence alerts for 5 minutes.
WATCHER_STREAM_ALERT_WINDOW_SECONDS = _env_float(60.0, "MJR_AM_WATCHER_STREAM_ALERT_WINDOW_SECONDS", "MJR_WATCHER_STREAM_ALERT_WINDOW_SECONDS", min_value=1.0, max_value=3600.0)
WATCHER_STREAM_ALERT_THRESHOLD = _env_int(512, "MJR_AM_WATCHER_STREAM_ALERT_THRESHOLD", "MJR_WATCHER_STREAM_ALERT_THRESHOLD", min_value=1, max_value=100_000)
WATCHER_STREAM_ALERT_COOLDOWN_SECONDS = _env_float(300.0, "MJR_AM_WATCHER_STREAM_ALERT_COOLDOWN_SECONDS", "MJR_WATCHER_STREAM_ALERT_COOLDOWN_SECONDS", min_value=1.0, max_value=86400.0)
WATCHER_MAX_FLUSH_CONCURRENCY = max(1, _env_int(2, "MJR_AM_WATCHER_MAX_FLUSH_CONCURRENCY", "MJR_WATCHER_MAX_FLUSH_CONCURRENCY", min_value=1, max_value=32))
WATCHER_PENDING_MAX = _env_int(
    _env_int(500, "MJR_AM_WATCHER_PENDING_MAX", "MJR_WATCHER_PENDING_MAX", "MAJOOR_WATCHER_MAX_PENDING_FILES"),
    "MJR_AM_WATCHER_PENDING_MAX",
    "MJR_WATCHER_PENDING_MAX",
    "MAJOOR_WATCHER_MAX_PENDING_FILES",
    min_value=10,
    max_value=50000,
)

# Background scan / filesystem listing tuning.
# 30s grace/min-interval prevents immediate rescans after manual actions or list calls.
BG_SCAN_FAILURE_HISTORY_MAX = _env_int(50, "MJR_AM_BG_SCAN_FAILURE_HISTORY_MAX", "MAJOOR_BG_SCAN_FAILURE_HISTORY_MAX", min_value=10, max_value=10000)
SCAN_PENDING_MAX = _env_int(64, "MJR_AM_SCAN_PENDING_MAX", "MAJOOR_SCAN_PENDING_MAX", min_value=1, max_value=10000)
MANUAL_BG_SCAN_GRACE_SECONDS = _env_float(30.0, "MJR_AM_MANUAL_BG_SCAN_GRACE_SECONDS", "MAJOOR_MANUAL_BG_SCAN_GRACE_SECONDS", min_value=0.0, max_value=3600.0)
BG_SCAN_ON_LIST = _env_bool(False, "MJR_AM_BG_SCAN_ON_LIST", "MAJOOR_BG_SCAN_ON_LIST")
BG_SCAN_MIN_INTERVAL_SECONDS = _env_float(30.0, "MJR_AM_BG_SCAN_MIN_INTERVAL_SECONDS", "MAJOOR_BG_SCAN_MIN_INTERVAL_SECONDS", min_value=0.0, max_value=3600.0)
SEARCH_MAX_LIMIT = _env_int(500, "MJR_AM_SEARCH_MAX_LIMIT", "MAJOOR_SEARCH_MAX_LIMIT", min_value=1, max_value=100000)
SEARCH_MAX_OFFSET = _env_int(10_000, "MJR_AM_SEARCH_MAX_OFFSET", "MAJOOR_SEARCH_MAX_OFFSET", min_value=0, max_value=10_000_000)

# Filesystem listing cache (used by filesystem fallback search/list).
# 1.5s TTL smooths repeated UI queries without serving stale lists for long.
FS_LIST_CACHE_MAX = _env_int(32, "MJR_AM_FS_LIST_CACHE_MAX", "MAJOOR_FS_LIST_CACHE_MAX", min_value=1, max_value=10000)
FS_LIST_CACHE_TTL_SECONDS = _env_float(1.5, "MJR_AM_FS_LIST_CACHE_TTL_SECONDS", "MAJOOR_FS_LIST_CACHE_TTL_SECONDS", min_value=0.1, max_value=3600.0)
FS_LIST_CACHE_WATCHER_ENABLED = _env_bool(
    False,
    "MJR_AM_ENABLE_FS_LIST_CACHE_WATCHER",
    "MAJOOR_ENABLE_FS_LIST_CACHE_WATCHER",
)
# Scanner batching (bounded transactions)
# Tweak only if you know your workload; larger batches reduce transaction overhead but increase lock time.
SCAN_BATCH_SMALL_THRESHOLD = _env_int(100, "MJR_AM_SCAN_BATCH_SMALL_THRESHOLD", "MAJOOR_SCAN_BATCH_SMALL_THRESHOLD", min_value=1, max_value=1_000_000)
SCAN_BATCH_MED_THRESHOLD = _env_int(1000, "MJR_AM_SCAN_BATCH_MED_THRESHOLD", "MAJOOR_SCAN_BATCH_MED_THRESHOLD", min_value=1, max_value=1_000_000)
SCAN_BATCH_LARGE_THRESHOLD = _env_int(10000, "MJR_AM_SCAN_BATCH_LARGE_THRESHOLD", "MAJOOR_SCAN_BATCH_LARGE_THRESHOLD", min_value=1, max_value=1_000_000)
# Default to fewer DB transactions for typical directories (~100-500 files).
SCAN_BATCH_SMALL = _env_int(100, "MJR_AM_SCAN_BATCH_SMALL", "MAJOOR_SCAN_BATCH_SMALL", min_value=1, max_value=10000)
SCAN_BATCH_MED = _env_int(150, "MJR_AM_SCAN_BATCH_MED", "MAJOOR_SCAN_BATCH_MED", min_value=1, max_value=10000)
SCAN_BATCH_LARGE = _env_int(250, "MJR_AM_SCAN_BATCH_LARGE", "MAJOOR_SCAN_BATCH_LARGE", min_value=1, max_value=10000)
# Initial 1000 is optimistic for startup scans; runtime can downshift with lock pressure.
SCAN_BATCH_INITIAL = _env_int(1000, "MJR_AM_SCAN_BATCH_INITIAL", "MAJOOR_SCAN_BATCH_INITIAL", min_value=1, max_value=100000)
SCAN_BATCH_MIN = _env_int(100, "MJR_AM_SCAN_BATCH_MIN", "MAJOOR_SCAN_BATCH_MIN", min_value=1, max_value=10000)

# Search limits
SEARCH_MAX_QUERY_LENGTH = _env_int(512, "MJR_AM_SEARCH_MAX_QUERY_LENGTH", "MJR_SEARCH_MAX_QUERY_LENGTH", min_value=16, max_value=8192)
SEARCH_MAX_TOKENS = _env_int(
    16,
    "MJR_AM_SEARCH_MAX_TOKENS",
    "MJR_SEARCH_MAX_TOKENS",
    "MAJOOR_SEARCH_MAX_TOKENS",
    min_value=1,
    max_value=128,
)
SEARCH_MAX_TOKEN_LENGTH = _env_int(
    64,
    "MJR_AM_SEARCH_MAX_TOKEN_LENGTH",
    "MJR_SEARCH_MAX_TOKEN_LENGTH",
    "MAJOOR_SEARCH_MAX_TOKEN_LENGTH",
    min_value=1,
    max_value=512,
)
SEARCH_MAX_BATCH_IDS = _env_int(200, "MJR_AM_SEARCH_MAX_BATCH_IDS", "MJR_SEARCH_MAX_BATCH_IDS", min_value=1, max_value=5000)
SEARCH_MAX_FILEPATH_LOOKUP = _env_int(5000, "MJR_AM_SEARCH_MAX_FILEPATH_LOOKUP", "MJR_SEARCH_MAX_FILEPATH_LOOKUP", min_value=1, max_value=100000)
SCAN_BATCH_XL = _env_int(400, "MJR_AM_SCAN_BATCH_XL", "MAJOOR_SCAN_BATCH_XL", min_value=1, max_value=20000)

# Scan behavior defaults and debug logging.
SCAN_DEFAULT_FAST = _env_bool(True, "MJR_AM_SCAN_DEFAULT_FAST", "MAJOOR_SCAN_DEFAULT_FAST")
SCAN_DEFAULT_BACKGROUND_METADATA = _env_bool(
    True,
    "MJR_AM_SCAN_DEFAULT_BACKGROUND_METADATA",
    "MAJOOR_SCAN_DEFAULT_BACKGROUND_METADATA",
)
SCAN_LOG_PROGRESS_EVERY = _env_int(
    0,
    "MJR_AM_SCAN_LOG_PROGRESS_EVERY",
    "MAJOOR_SCAN_LOG_PROGRESS_EVERY",
    min_value=0,
    max_value=500_000,
)
SCAN_LOG_PROGRESS_MIN_SECONDS = _env_float(
    15.0,
    "MJR_AM_SCAN_LOG_PROGRESS_MIN_SECONDS",
    "MAJOOR_SCAN_LOG_PROGRESS_MIN_SECONDS",
    min_value=0.0,
    max_value=600.0,
)

# Scanner limits
# Hard cap protects UI payload size when many entries are eligible for enrichment.
MAX_TO_ENRICH_ITEMS = _env_int(10000, "MJR_AM_MAX_TO_ENRICH_ITEMS", "MAJOOR_MAX_TO_ENRICH_ITEMS", min_value=1, max_value=1_000_000)

# ── Vector / multimodal semantic search (SigLIP2 / X-CLIP) ───────────────
# Enable the whole subsystem with MJR_AM_ENABLE_VECTOR_SEARCH=1
# (legacy aliases are still accepted for backward compatibility).
# Default is ON.
VECTOR_SEARCH_ENABLED = _env_bool(
    True,
    "MJR_AM_ENABLE_VECTOR_SEARCH",
    "MJR_ENABLE_VECTOR_SEARCH",
    "MAJOOR_ENABLE_VECTOR_SEARCH",
)
VECTOR_INDEX_ON_SCAN = _env_bool(
    False,
    "MJR_AM_VECTOR_INDEX_ON_SCAN",
    "MAJOOR_VECTOR_INDEX_ON_SCAN",
)
EXECUTION_GROUPING_ENABLED = _env_bool(
    True,
    "MJR_AM_EXECUTION_GROUPING_ENABLED",
    "MAJOOR_EXECUTION_GROUPING_ENABLED",
)


def is_vector_search_enabled() -> bool:
    """Return runtime vector-search enabled state from environment toggles."""
    return _env_bool(
        VECTOR_SEARCH_ENABLED,
        "MJR_AM_ENABLE_VECTOR_SEARCH",
        "MJR_ENABLE_VECTOR_SEARCH",
        "MAJOOR_ENABLE_VECTOR_SEARCH",
    )


def is_vector_index_on_scan_enabled() -> bool:
    """Return runtime toggle for automatic vector indexing during scans."""
    return _env_bool(
        VECTOR_INDEX_ON_SCAN,
        "MJR_AM_VECTOR_INDEX_ON_SCAN",
        "MAJOOR_VECTOR_INDEX_ON_SCAN",
    )


def is_vector_unload_after_use_enabled() -> bool:
    """Return whether Majoor AI models should be unloaded after heavy vector actions."""
    return _env_bool(
        False,
        "MJR_AM_VECTOR_UNLOAD_AFTER_USE",
        "MAJOOR_VECTOR_UNLOAD_AFTER_USE",
    )


def is_vector_caption_on_index_enabled() -> bool:
    """Return whether automatic vector indexing may run Florence captioning."""
    return _env_bool(
        False,
        "MJR_AM_VECTOR_CAPTION_ON_INDEX",
        "MAJOOR_VECTOR_CAPTION_ON_INDEX",
    )


def is_execution_grouping_enabled() -> bool:
    """Return runtime toggle for job_id / stack_id execution grouping logic."""
    return _env_bool(
        EXECUTION_GROUPING_ENABLED,
        "MJR_AM_EXECUTION_GROUPING_ENABLED",
        "MAJOOR_EXECUTION_GROUPING_ENABLED",
    )

# Primary multimodal model name (default: SigLIP2 SO400M).
VECTOR_MODEL_NAME = str(
    _env_raw(
        "MJR_AM_VECTOR_MODEL",
        "MJR_VECTOR_MODEL",
        default="google/siglip-so400m-patch14-384",
    )
    or "google/siglip-so400m-patch14-384"
)

# Dedicated video encoder (X-CLIP / VideoCLIP family). Empty = disabled.
VECTOR_VIDEO_MODEL_NAME = str(
    _env_raw(
        "MJR_AM_VECTOR_VIDEO_MODEL",
        "MJR_VECTOR_VIDEO_MODEL",
        default="microsoft/xclip-base-patch32",
    )
    or "microsoft/xclip-base-patch32"
)

# Florence-2 model used for enhanced long captions.
VECTOR_PROMPT_MODEL_NAME = str(
    _env_raw(
        "MJR_AM_PROMPT_MODEL",
        "MJR_PROMPT_MODEL",
        default="microsoft/Florence-2-base",
    )
    or "microsoft/Florence-2-base"
)

# Florence-2 caption task prompt (kept configurable for variants).
VECTOR_PROMPT_TASK = str(
    _env_raw(
        "MJR_AM_PROMPT_TASK",
        "MJR_PROMPT_TASK",
        default="<MORE_DETAILED_CAPTION>",
    )
    or "<MORE_DETAILED_CAPTION>"
)

# Directory where the Faiss index file is persisted on disk.
VECTOR_INDEX_DIR_PATH = INDEX_DIR_PATH / "vectors"
VECTOR_INDEX_DIR = str(VECTOR_INDEX_DIR_PATH)

# Embedding dimensionality (SigLIP SO400M → 1152, base → 768).
VECTOR_EMBEDDING_DIM = _env_int(1152, "MJR_AM_VECTOR_DIM", min_value=64, max_value=4096)

# Pre-warm the text-query model path and Faiss index during startup prewarm.
VECTOR_PREWARM_ON_STARTUP = _env_bool(
    False,
    "MJR_AM_VECTOR_PREWARM_ON_STARTUP",
    "MAJOOR_VECTOR_PREWARM_ON_STARTUP",
)
SERVICES_PREWARM_ON_STARTUP = _env_bool(
    True,
    "MJR_AM_SERVICES_PREWARM_ON_STARTUP",
    "MAJOOR_SERVICES_PREWARM_ON_STARTUP",
)
OBS_INSTALL_GLOBAL_ASYNCIO_HANDLER = _env_bool(
    sys.platform.startswith("win"),
    "MJR_AM_OBS_INSTALL_GLOBAL_ASYNCIO_HANDLER",
    "MAJOOR_OBS_INSTALL_GLOBAL_ASYNCIO_HANDLER",
)

# Auto-tagging: minimum cosine similarity to assign a tag.
VECTOR_AUTOTAG_THRESHOLD = _env_float(
    0.06,
    "MJR_AM_VECTOR_AUTOTAG_THRESHOLD",
    min_value=0.0,
    max_value=1.0,
)

# Auto-tagging: include the "nsfw" classifier prompt in the vocabulary.
# Off by default — opt-in to avoid writing "nsfw" labels into the DB without
# explicit user consent.
VECTOR_AUTOTAG_NSFW_ENABLED = _env_bool(
    False,
    "MJR_AM_VECTOR_AUTOTAG_NSFW",
    "MAJOOR_VECTOR_AUTOTAG_NSFW",
)

# Semantic text search: score floor and relative cutoff versus best hit.
VECTOR_TEXT_SEARCH_MIN_SCORE = _env_float(
    0.02,
    "MJR_AM_VECTOR_TEXT_SEARCH_MIN_SCORE",
    min_value=0.0,
    max_value=1.0,
)
VECTOR_TEXT_SEARCH_RELATIVE_RATIO = _env_float(
    0.70,
    "MJR_AM_VECTOR_TEXT_SEARCH_RELATIVE_RATIO",
    min_value=0.0,
    max_value=1.0,
)

# Maximum number of similar results returned by "Find Similar".
VECTOR_SIMILAR_TOPK = _env_int(20, "MJR_AM_VECTOR_SIMILAR_TOPK", min_value=1, max_value=200)
VECTOR_SIMILAR_MIN_SCORE = _env_float(
    0.60,
    "MJR_AM_VECTOR_SIMILAR_MIN_SCORE",
    min_value=0.0,
    max_value=1.0,
)

# Number of threads used by Faiss for search (0 = automatic).
VECTOR_FAISS_NPROBE = _env_int(0, "MJR_AM_VECTOR_FAISS_NPROBE", min_value=0, max_value=128)

# Video key-frame extraction interval in seconds.
VECTOR_VIDEO_KEYFRAME_INTERVAL = _env_float(
    5.0,
    "MJR_AM_VECTOR_KEYFRAME_INTERVAL",
    min_value=0.5,
    max_value=60.0,
)

# Batch size when computing embeddings during a full scan.
VECTOR_BATCH_SIZE = _env_int(32, "MJR_AM_VECTOR_BATCH_SIZE", min_value=1, max_value=256)

# Concurrent vector indexing workers. Lower values reduce transient VRAM spikes.
VECTOR_CONCURRENCY = _env_int(2, "MJR_VECTOR_CONCURRENCY", "MJR_AM_VECTOR_CONCURRENCY", min_value=1, max_value=16)
