Investment Plans workspace
Open raw ↗
"""Configuration manager for HAD Digital MVP.

Reads config.json with sensible defaults. All settings can be overridden
via environment variables with the HAD_ prefix.
"""

import json
import os
import sys
from pathlib import Path


def get_bundle_dir() -> Path:
    """Get read-only bundle directory (where static assets, rules, guidance live)."""
    if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
        return Path(sys._MEIPASS)
    return Path(__file__).resolve().parent


def get_data_dir() -> Path:
    """Get writable persistent data directory (where SQLite DB, logs live)."""
    if getattr(sys, "frozen", False):
        exe_dir = Path(sys.executable).resolve().parent
        test_file = exe_dir / ".write_test"
        try:
            test_file.touch()
            test_file.unlink()
            return exe_dir / "data"
        except (PermissionError, OSError):
            local_appdata = os.environ.get("LOCALAPPDATA")
            if local_appdata:
                return Path(local_appdata) / "HAD Digital" / "data"
            return Path.home() / ".had_digital" / "data"
    return Path(__file__).resolve().parent / "data"


BUNDLE_DIR = get_bundle_dir()
DATA_DIR = get_data_dir()
BASE_DIR = BUNDLE_DIR

DEFAULT_CONFIG = {
    "app_name": "HAD Digital MVP",
    "version": "1.0.0",
    "host": "127.0.0.1",
    "port": 8080,
    "debug": False,
    "database": {
        "path": str(DATA_DIR / "had.db"),
        "wal_mode": True,
    },
    "session": {
        "cookie_name": "HAD_SESSION",
        "max_age_seconds": 86400,  # 24 hours
        "secret_env": "HAD_SESSION_SECRET",
    },
    "security": {
        "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'",
        "x_content_type_options": "nosniff",
        "x_frame_options": "DENY",
        "referrer_policy": "strict-origin-when-cross-origin",
    },
    "ctcae": {
        "rules_path": str(BUNDLE_DIR / "data" / "ctcae_rules.json"),
    },
    "guidance": {
        "path": str(BUNDLE_DIR / "guidance" / "guidance.json"),
    },
    "alerting": {
        "urgent_delay_minutes": 30,
        "emergency_delay_minutes": 0,
    },
    "ai": {
        "adapter": "stub",
        "glm_api_key_env": "GLM_API_KEY",
        "glm_model": "glm-4-flash",
        "glm_endpoint": "https://open.bigmodel.cn/api/paas/v4/chat/completions",
    },
    "demo": {
        "auto_seed": True,
    },
}


def _deep_merge(base: dict, override: dict) -> dict:
    """Recursively merge override into base."""
    result = base.copy()
    for key, value in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = _deep_merge(result[key], value)
        else:
            result[key] = value
    return result


class ConfigManager:
    """Singleton configuration manager."""

    _instance = None
    _config = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def load(self, config_path: str | None = None) -> dict:
        """Load configuration from file, merged with defaults."""
        if self._config is not None:
            return self._config

        config = DEFAULT_CONFIG.copy()

        # Load from file if exists
        if config_path is None:
            if (DATA_DIR / "config.json").exists():
                config_path = str(DATA_DIR / "config.json")
            else:
                config_path = str(BUNDLE_DIR / "config.json")

        if os.path.exists(config_path):
            try:
                with open(config_path, "r", encoding="utf-8") as f:
                    file_config = json.load(f)
                config = _deep_merge(config, file_config)
            except (json.JSONDecodeError, OSError):
                pass  # Use defaults if file is malformed

        # Environment variable overrides
        env_overrides = {
            "HAD_HOST": ("host", str),
            "HAD_PORT": ("port", int),
            "HAD_DEBUG": ("debug", lambda x: x.lower() in ("true", "1", "yes")),
            "HAD_DB_PATH": (("database", "path"), str),
            "HAD_SESSION_SECRET": (("session", "secret_value"), str),
            "HAD_AI_ADAPTER": (("ai", "adapter"), str),
            "GLM_API_KEY": (("ai", "glm_api_key_value"), str),
        }

        for env_var, (key_path, converter) in env_overrides.items():
            value = os.environ.get(env_var)
            if value is not None:
                try:
                    converted = converter(value)
                    if isinstance(key_path, tuple):
                        config[key_path[0]][key_path[1]] = converted
                    else:
                        config[key_path] = converted
                except (ValueError, KeyError):
                    pass

        self._config = config
        return config

    def get(self, *keys, default=None):
        """Get a nested config value by keys."""
        if self._config is None:
            self.load()
        current = self._config
        for key in keys:
            if isinstance(current, dict) and key in current:
                current = current[key]
            else:
                return default
        return current

    def reload(self):
        """Force reload configuration."""
        self._config = None
        return self.load()


# Global singleton
config = ConfigManager()