# config.py
from __future__ import annotations

import json
import os
import shutil
from importlib import metadata
from pathlib import Path
from typing import Dict, Any

# --- ENV ---
try:
    from dotenv import load_dotenv  # optional
    load_dotenv()
except Exception:
    pass

# --- INSTALL / RESOURCE LAYOUT ---
PACKAGE_DIR = Path(__file__).resolve().parent
_CHECKOUT_ROOT = PACKAGE_DIR.parent
SOURCE_CHECKOUT = (
    (_CHECKOUT_ROOT / "VERSION").is_file()
    and (_CHECKOUT_ROOT / "interface" / "web" / "package.json").is_file()
)

# PROJECT_ROOT remains the source checkout for developer installs. In a wheel it
# is the installed package directory; writable state is deliberately elsewhere.
PROJECT_ROOT = Path(os.getenv("NEXUS_PROJECT_ROOT", "")).expanduser() if os.getenv(
    "NEXUS_PROJECT_ROOT"
) else (_CHECKOUT_ROOT if SOURCE_CHECKOUT else PACKAGE_DIR)
PROJECT_ROOT = PROJECT_ROOT.resolve()
RESOURCE_ROOT = PROJECT_ROOT if SOURCE_CHECKOUT else PACKAGE_DIR / "_resources"


def _user_dir(env_name: str, windows_leaf: str, xdg_name: str, xdg_fallback: str) -> Path:
    # os.getenv's default only applies when a variable is *unset*. An exported
    # but empty XDG_DATA_HOME / LOCALAPPDATA would otherwise give Path("") ==
    # ".", scattering state through whatever the cwd happened to be. The XDG
    # spec says to treat an empty value as unset, so `or` - not a default arg.
    override = os.getenv(env_name, "").strip()
    if override:
        return Path(override).expanduser().resolve()
    if os.name == "nt":
        base = Path(os.getenv("LOCALAPPDATA", "").strip() or Path.home() / "AppData" / "Local")
        return (base / windows_leaf).expanduser().resolve()
    base = Path(os.getenv(xdg_name, "").strip() or Path.home() / xdg_fallback).expanduser()
    return (base / "nexusos").resolve()


CONFIG_DIR = _user_dir("NEXUS_CONFIG_DIR", "NexusOS", "XDG_CONFIG_HOME", ".config")
CONFIG_FILE = CONFIG_DIR / "config.json"


def read_user_config() -> dict[str, Any]:
    try:
        data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
        return data if isinstance(data, dict) else {}
    except (OSError, ValueError, TypeError):
        return {}


def write_user_config(values: dict[str, Any]) -> None:
    """Atomically persist CLI-managed configuration."""
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    tmp = CONFIG_FILE.with_suffix(".tmp")
    tmp.write_text(json.dumps(values, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    tmp.replace(CONFIG_FILE)


USER_CONFIG = read_user_config()


def _value(key: str, env_name: str, default: Any) -> Any:
    raw = os.getenv(env_name)
    return raw if raw not in (None, "") else USER_CONFIG.get(key, default)


def _int_value(key: str, env_name: str, default: int) -> int:
    try:
        return int(_value(key, env_name, default))
    except (TypeError, ValueError):
        return default


def _configured_path(key: str, env_name: str, default: Path) -> Path:
    return Path(str(_value(key, env_name, default))).expanduser().resolve()


# --- VERSION (repo file in a checkout; distribution metadata in a wheel) ---
try:
    if SOURCE_CHECKOUT:
        VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip()
    else:
        VERSION = metadata.version("nexusos-ai")
except Exception:
    try:
        VERSION = (RESOURCE_ROOT / "VERSION").read_text(encoding="utf-8").strip()
    except Exception:
        VERSION = "0.0.0"

# --- MODEL DEFAULTS ---
# Single source of truth for the three models NexusOS ships with. The installers
# pull and pin these, the runtime falls back to them; keeping them in one place
# is what stops installer and backend from drifting apart.
#
# Chat: llama3.1:8b - a strong non-reasoning instruct model (~4.9 GB; overflows a
# 4 GB GPU into CPU/RAM). Chosen over Qwen3 because Qwen3 is a reasoning model:
# smart only with its slow <think> step, weak without it.
# Memory: mistral - the curator that extracts facts and titles conversations.
# Embed: nomic-embed-text - powers semantic recall of past conversations
# (OllamaManager.embed / store.semantic_search_conversations). Without it,
# recall silently degrades to lexical substring matching.
DEFAULT_CHAT_MODEL = "llama3.1:8b"
DEFAULT_MEMORY_MODEL = "mistral:latest"
DEFAULT_EMBED_MODEL = "nomic-embed-text"

# --- CORE DIRECTORIES ---
_DEFAULT_STATE = PROJECT_ROOT if SOURCE_CHECKOUT else _user_dir(
    "NEXUS_HOME", "NexusOS", "XDG_DATA_HOME", ".local/share"
)
STATE_DIR = _configured_path("home", "NEXUS_HOME", _DEFAULT_STATE)
_USE_CHECKOUT_STATE = SOURCE_CHECKOUT and not os.getenv("NEXUS_HOME", "").strip()
DATA_DIR = _configured_path("data_dir", "NEXUS_DATA_DIR", (
    PROJECT_ROOT / "data" if _USE_CHECKOUT_STATE else STATE_DIR / "data"
))
MODELS_DIR = _configured_path("models_dir", "NEXUS_MODELS_DIR", (
    PROJECT_ROOT / "models" if _USE_CHECKOUT_STATE else STATE_DIR / "models"
))
RUNTIME_DIR = _configured_path("runtime_dir", "NEXUS_RUNTIME_DIR", (
    PROJECT_ROOT / "runtime" if _USE_CHECKOUT_STATE else STATE_DIR / "runtime"
))

MEMORY_DIR = _configured_path("memory_dir", "NEXUS_MEMORY_DIR", (
    PROJECT_ROOT / "synapse" / "memory" if _USE_CHECKOUT_STATE else DATA_DIR
))

LOGS_DIR = RUNTIME_DIR / "logs"
CACHE_DIR = RUNTIME_DIR / "cache"
TEMP_DIR = RUNTIME_DIR / "tmp"

# --- APPLICATION SUBSYSTEM DIRECTORIES ---
PLAYBOOK_DIR = DATA_DIR / "playbooks"  # YAML playbook files (PlaybookFileStore)
UPLOADS_DIR = DATA_DIR / "uploads"
EXPORTS_DIR = DATA_DIR / "exports"
WEB_DIST_DIR = (
    PROJECT_ROOT / "interface" / "web" / "dist"
    if SOURCE_CHECKOUT else RESOURCE_ROOT / "web"
)
FRONTEND_SOURCE_DIR = PROJECT_ROOT / "interface" / "web"
ASSETS_DIR = PROJECT_ROOT / "assets" if SOURCE_CHECKOUT else RESOURCE_ROOT / "assets"
SEED_PLAYBOOK_DIR = (
    PROJECT_ROOT / "data" / "playbooks"
    if SOURCE_CHECKOUT else RESOURCE_ROOT / "playbooks"
)

# --- DATABASE / STORAGE FILES (match your repo) ---
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
# Vendored Curry (synapse/curry_core.py) database: immutable versioned
# constants/functions/models + inference provenance. Separate file from
# MEMORY_DB on purpose - Curry's schema and lifecycle are independent of the
# memory/conversation store.
CURRY_DB = _configured_path("curry_db", "NEXUS_CURRY_DB", DATA_DIR / "curry.db")

# --- LOG FILES ---
BACKEND_LOG = RUNTIME_DIR / "backend.log"
OLLAMA_LOG = LOGS_DIR / "ollama.log"
CHAT_LOG = LOGS_DIR / "chat.log"

# --- ENSURE REQUIRED DIRECTORIES EXIST ---
_REQUIRED_DIRS = (
    STATE_DIR,
    DATA_DIR,
    MODELS_DIR,
    RUNTIME_DIR,
    LOGS_DIR,
    MEMORY_DIR,
    CACHE_DIR,
    TEMP_DIR,
    PLAYBOOK_DIR,
    UPLOADS_DIR,
    EXPORTS_DIR,
    MEMORY_DB.parent,
    CURRY_DB.parent,
)


def init_state() -> list[Path]:
    """Create writable state and seed playbooks on a first wheel install."""
    for directory in _REQUIRED_DIRS:
        directory.mkdir(parents=True, exist_ok=True)
    copied: list[Path] = []
    if SEED_PLAYBOOK_DIR.resolve() != PLAYBOOK_DIR.resolve() and SEED_PLAYBOOK_DIR.is_dir():
        for source in SEED_PLAYBOOK_DIR.glob("*.yaml"):
            target = PLAYBOOK_DIR / source.name
            if not target.exists():
                shutil.copy2(source, target)
                copied.append(target)
    return copied


INITIALIZED_FILES = init_state()

# --- PATH ACCESSOR (fail-fast) ---
def path(name: str) -> Path:
    """
    Return a Path for a known name. Raises KeyError if name is unknown.
    """
    mapping = {
        "root": PROJECT_ROOT,
        "resources": RESOURCE_ROOT,
        "state": STATE_DIR,
        "config": CONFIG_FILE,
        "data": DATA_DIR,
        "models": MODELS_DIR,
        "runtime": RUNTIME_DIR,
        "logs": LOGS_DIR,
        "memory": MEMORY_DIR,
        "cache": CACHE_DIR,
        "tmp": TEMP_DIR,
        "playbooks": PLAYBOOK_DIR,
        "uploads": UPLOADS_DIR,
        "exports": EXPORTS_DIR,
        "web": WEB_DIST_DIR,
        "assets": ASSETS_DIR,
        "memory_db": MEMORY_DB,
        "backend_log": BACKEND_LOG,
        "ollama_log": OLLAMA_LOG,
        "chat_log": CHAT_LOG,
    }
    try:
        return mapping[name]
    except KeyError:
        raise KeyError(f"Unknown config path name: {name}")

# --- Settings class and exported instance ---
def _normalize_ollama_host(raw: str) -> str:
    """Turn an OLLAMA_HOST value into a URL a client can actually connect to.

    OLLAMA_HOST is Ollama's *server bind* variable, and the common way to expose
    Ollama on a LAN is `OLLAMA_HOST=0.0.0.0:11434`. Taken literally as a client
    base URL that is unusable twice over: 0.0.0.0 means "every local interface"
    to a listener but is not a destination, and there is no scheme for httpx to
    parse. The result was a silent empty model list, because list_models()
    catches everything and returns [].

    So: supply the scheme when it's missing, and rewrite wildcard binds to
    loopback. An explicit host is left alone — someone pointing at a real remote
    Ollama means it.
    """
    host = (raw or "").strip().rstrip("/")
    if not host:
        return "http://127.0.0.1:11434"
    if "://" not in host:
        host = f"http://{host}"
    scheme, _, rest = host.partition("://")
    hostport = rest.split("/", 1)[0]
    name, sep, port = hostport.rpartition(":")
    if not sep:            # no port given
        name, port = hostport, ""
    # 0.0.0.0 and :: are bind-any; from a client they mean "this machine".
    if name.strip("[]") in ("0.0.0.0", "::", ""):
        name = "127.0.0.1"
    return f"{scheme}://{name}:{port}" if port else f"{scheme}://{name}"


class Settings:
    """
    Lightweight settings container. Use `settings` instance for runtime access,
    or `Settings` class for typing/tests.
    """
    def __init__(self) -> None:
        self.version: str = VERSION
        self.source_checkout: bool = SOURCE_CHECKOUT
        self.project_root: Path = PROJECT_ROOT
        self.resource_root: Path = RESOURCE_ROOT
        self.state_dir: Path = STATE_DIR
        self.config_file: Path = CONFIG_FILE
        self.data_dir: Path = DATA_DIR
        self.models_dir: Path = MODELS_DIR
        self.runtime_dir: Path = RUNTIME_DIR
        self.memory_dir: Path = MEMORY_DIR
        self.logs_dir: Path = LOGS_DIR
        self.web_dist_dir: Path = WEB_DIST_DIR
        self.frontend_source_dir: Path = FRONTEND_SOURCE_DIR
        self.assets_dir: Path = ASSETS_DIR

        # DB files
        self.memory_db: Path = MEMORY_DB

        # Logs
        self.backend_log: Path = BACKEND_LOG
        self.ollama_log: Path = OLLAMA_LOG
        self.chat_log: Path = CHAT_LOG

        # Env overrides. Two values from one variable, because OLLAMA_HOST means
        # two different things: where a server should LISTEN, and where a client
        # should CONNECT. `ollama_bind` keeps the user's literal intent for a
        # serve we spawn (0.0.0.0 to expose it on the LAN); `ollama_host` is the
        # connectable form for our own requests.
        self.provider: str = str(_value("provider", "NEXUS_PROVIDER", "ollama"))
        # A Nexus provider setting is more specific than the legacy Ollama bind
        # variable. This matters on a desktop that has OLLAMA_HOST globally set
        # but configures NexusOS to use a different remote inference machine.
        provider_url = str(_value("provider_url", "NEXUS_PROVIDER_URL", "")).strip()
        configured_host = (
            provider_url
            or os.getenv("OLLAMA_HOST", "").strip()
            or "http://127.0.0.1:11434"
        )
        self.ollama_bind: str = configured_host or "127.0.0.1:11434"
        self.ollama_host: str = _normalize_ollama_host(
            configured_host
        )
        self.provider_url: str = self.ollama_host
        self.manage_ollama: bool = self.provider == "ollama"
        self.ollama_timeout: int = _int_value("provider_timeout", "OLLAMA_TIMEOUT", 120)
        self.bind_host: str = str(_value(
            "bind_host", "NEXUS_BIND_HOST", "127.0.0.1"
        ))
        self.backend_port: int = _int_value("backend_port", "NEXUS_BACKEND_PORT", 8000)
        self.memory_port: int = _int_value("memory_port", "NEXUS_MEMORY_PORT", 8001)
        self.api_url: str = str(_value(
            "api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}"
        )).rstrip("/")
        self.memory_url: str = str(_value(
            "memory_url", "NEXUS_MEMORY_URL", f"http://127.0.0.1:{self.memory_port}"
        )).rstrip("/")

    def as_dict(self) -> Dict[str, Any]:
        return {
            "version": self.version,
            "source_checkout": self.source_checkout,
            "project_root": str(self.project_root),
            "resource_root": str(self.resource_root),
            "state_dir": str(self.state_dir),
            "config_file": str(self.config_file),
            "data_dir": str(self.data_dir),
            "models_dir": str(self.models_dir),
            "runtime_dir": str(self.runtime_dir),
            "memory_dir": str(self.memory_dir),
            "memory_db": str(self.memory_db),
            "web_dist_dir": str(self.web_dist_dir),
            "provider": self.provider,
            "ollama_host": self.ollama_host,
            "ollama_timeout": self.ollama_timeout,
            "api_url": self.api_url,
            "bind_host": self.bind_host,
            "backend_port": self.backend_port,
            "memory_port": self.memory_port,
            "memory_url": self.memory_url,
        }

# --- local-access allowlists (shared by the backend + memory FastAPI apps) ---
# The REST APIs are unauthenticated, so they are meant to be reached only from
# this machine. Two independent browser-side defenses depend on these lists:
#   * ALLOWED_ORIGINS drives CORS — blocks a malicious page from *reading*
#     responses cross-origin (was previously "*", which let any site read them).
#   * ALLOWED_HOSTS drives TrustedHostMiddleware — rejects a foreign Host header,
#     which is what stops DNS-rebinding (same-origin from the browser's view, so
#     CORS can't help there).
# Both accept a comma-separated env override for the intentional-LAN case, to be
# paired with real auth. NEXUS_ALLOWED_HOSTS=* disables the Host check.
def _csv_env(name: str, default: list) -> list:
    raw = os.getenv(name, "").strip()
    return [p.strip() for p in raw.split(",") if p.strip()] if raw else list(default)

_LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
_LOCAL_ORIGINS = [
    f"http://{h}:{p}"
    for h in ("localhost", "127.0.0.1")
    for p in (
        _int_value("backend_port", "NEXUS_BACKEND_PORT", 8000),
        _int_value("memory_port", "NEXUS_MEMORY_PORT", 8001),
        5173,
    )
]
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
ALLOWED_ORIGINS = _csv_env("NEXUS_ALLOWED_ORIGINS", _LOCAL_ORIGINS)


# --- resource limits (DoS guardrails for the unauthenticated local APIs) ---
# Even local-only, an unbounded base64 upload or a flood of concurrent inference
# requests can exhaust RAM/CPU. These are generous defaults for single-user use,
# all env-overridable.
def _int_env(name: str, default: int) -> int:
    try:
        return int((os.getenv(name) or "").strip() or default)
    except ValueError:
        return default

MAX_REQUEST_BYTES = _int_env("NEXUS_MAX_REQUEST_MB", 32) * 1024 * 1024
MAX_UPLOAD_BYTES = _int_env("NEXUS_MAX_UPLOAD_MB", 20) * 1024 * 1024
MAX_PDF_PAGES = _int_env("NEXUS_MAX_PDF_PAGES", 500)
MAX_CONCURRENT_CHATS = _int_env("NEXUS_MAX_CONCURRENT_CHATS", 4)
MAX_CONCURRENT_UPLOADS = _int_env("NEXUS_MAX_CONCURRENT_UPLOADS", 2)

# Opt-in allowlist for /models/pull. Empty (default) = unrestricted, preserving
# current behaviour. Set NEXUS_MODEL_ALLOWLIST=mistral,llama3 to bound which
# models can be downloaded; a bare repo name (before the ':tag') matches all of
# its tags, so "mistral" permits "mistral:latest", "mistral:7b", etc.
MODEL_ALLOWLIST = _csv_env("NEXUS_MODEL_ALLOWLIST", [])


def model_pull_allowed(name: str) -> bool:
    """True if `name` may be pulled: always when no allowlist is configured,
    otherwise when the full name or its repo part (before the first ':') is
    listed. Case-insensitive."""
    if not MODEL_ALLOWLIST:
        return True
    n = (name or "").strip().lower()
    if not n:
        return False
    allow = {a.lower() for a in MODEL_ALLOWLIST}
    return n in allow or n.split(":", 1)[0] in allow

# exported instance
settings = Settings()

# explicit exports for static checkers and IDEs
__all__ = ["Settings", "settings", "path", "VERSION",
           "DEFAULT_CHAT_MODEL", "DEFAULT_MEMORY_MODEL", "DEFAULT_EMBED_MODEL",
           "PACKAGE_DIR", "PROJECT_ROOT", "RESOURCE_ROOT", "SOURCE_CHECKOUT",
           "STATE_DIR", "CONFIG_DIR", "CONFIG_FILE", "USER_CONFIG",
           "read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
           "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
           "MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
           "EXPORTS_DIR", "MEMORY_DB", "CURRY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
           "ASSETS_DIR", "SEED_PLAYBOOK_DIR",
           "BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
           "ALLOWED_HOSTS", "ALLOWED_ORIGINS",
           "MAX_REQUEST_BYTES", "MAX_UPLOAD_BYTES", "MAX_PDF_PAGES",
           "MAX_CONCURRENT_CHATS", "MAX_CONCURRENT_UPLOADS",
           "MODEL_ALLOWLIST", "model_pull_allowed"]
           
# --- quick runtime sanity check when run directly (no side effects on import) ---
if __name__ == "__main__":
    print("Config paths:")
    for key in ("root", "data", "models", "runtime", "memory", "memory_db"):
        print(f"  {key}: {path(key)}")
