"""
Animica RPC configuration.

This module centralizes tunables for the HTTP/WS RPC service:
- host/port
- CORS policy
- rate limits (global + per-method)
- DB path (sqlite/Rocks URI understood by core.db.*)
- chainId
- logging level
- metrics & OpenRPC toggles

Environment variables (examples):
  ANIMICA_RPC_HOST=0.0.0.0
  ANIMICA_RPC_PORT=8545
  ANIMICA_RPC_WS_PATH=/ws
  ANIMICA_RPC_CORS_ORIGINS=["http://localhost:5173","https://studio.animica.org"]
  ANIMICA_RPC_RATE_RPS=50
  ANIMICA_RPC_RATE_BURST=200
  ANIMICA_RPC_RATE_PER_METHOD='{"tx.sendRawTransaction": 5, "chain.getHead": 40}'
  ANIMICA_RPC_DB_URI=sqlite:///~/animica/data/chain.db
  ANIMICA_CHAIN_ID=1
  ANIMICA_LOG_LEVEL=INFO
  ANIMICA_METRICS_ENABLED=true
  ANIMICA_METRICS_PORT=9100
  ANIMICA_OPENRPC_ENABLED=true

Notes
- JSON-like env values accept either JSON or a comma-separated list.
- Paths beginning with ~ are expanded.
- This module has no external deps (no dotenv). Use your process manager to inject env.
"""

from __future__ import annotations

import json
import logging
import os
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional

_REPO_ROOT = Path(__file__).resolve().parents[1]
_PYTHON_DIR = _REPO_ROOT / "python"
if str(_PYTHON_DIR) not in sys.path:
    sys.path.insert(0, str(_PYTHON_DIR))

from animica.config import parse_env_bool

logger = logging.getLogger(__name__)

from rpc.access_policy import AccessMode

log = logging.getLogger(__name__)


def _expand_sqlite_uri(uri: str) -> str:
    """
    Expand ~/… in sqlite URIs while preserving scheme.
    Accepts:
      - sqlite:///~/animica/data/chain.db
      - sqlite:///home/user/animica/data/chain.db
      - rocksdb:///var/lib/animica
    Returns unchanged if scheme is not sqlite or rocksdb or if no ~ present.
    
    Also ensures parent directories exist for file-based databases.
    """
    expanded = os.path.expandvars(uri)
    if ":///" not in expanded:
        # Allow bare file path; convert to sqlite URI.
        path = Path(expanded).expanduser()
        # Create parent directory if it doesn't exist
        if path.suffix in (".db", "") and str(path) != ":memory:":
            path.parent.mkdir(parents=True, exist_ok=True)
        return "sqlite:///" + str(path)
    scheme, rest = expanded.split(":///", 1)
    if scheme in ("sqlite", "rocksdb") and rest.startswith("~"):
        rest_expanded = str(Path(rest).expanduser())
        # Create parent directory for file-based databases
        if rest_expanded != ":memory:":
            path = Path(rest_expanded)
            path.parent.mkdir(parents=True, exist_ok=True)
        return f"{scheme}:///{rest_expanded}"
    elif scheme in ("sqlite", "rocksdb") and rest and rest != ":memory:":
        # Also handle non-~ paths that need directory creation
        path = Path(rest)
        if not path.is_absolute():
            # Relative paths are relative to cwd
            path = Path.cwd() / path
        path.parent.mkdir(parents=True, exist_ok=True)
    return expanded


def _chain_id_from_genesis(genesis_path: Path | None) -> int | None:
    if genesis_path is None:
        return None
    try:
        data = json.loads(Path(genesis_path).read_text())
        chain_id = data.get("chainId", data.get("chain_id"))
        return int(chain_id) if chain_id is not None else None
    except Exception:
        return None


def _env(name: str, default: Optional[str] = None) -> Optional[str]:
    v = os.getenv(name)
    return v if v is not None else default


def _env_bool(name: str, default: bool) -> bool:
    v = _env(name)
    if v is None:
        return default
    return parse_env_bool(v, default, name=name, log=logger)


def _env_int(name: str, default: int) -> int:
    v = _env(name)
    if v is None:
        return default
    try:
        return int(v.strip())
    except Exception:
        return default


def _env_float(name: str, default: float) -> float:
    v = _env(name)
    if v is None:
        return default
    try:
        return float(v.strip())
    except Exception:
        return default


def _env_list(name: str, default: List[str]) -> List[str]:
    """
    Parse JSON array or comma-separated string into a list of strings.
    """
    v = _env(name)
    if v is None or v.strip() == "":
        return list(default)
    s = v.strip()
    # Try JSON first
    if (s.startswith("[") and s.endswith("]")) or (
        s.startswith('"') and s.endswith('"')
    ):
        try:
            parsed = json.loads(s)
            if isinstance(parsed, list):
                return [str(x) for x in parsed]
            # Single string JSON
            return [str(parsed)]
        except Exception:
            pass
    # Fallback: comma-separated
    return [item.strip() for item in s.split(",") if item.strip()]


def _env_json_map_float(name: str, default: Dict[str, float]) -> Dict[str, float]:
    v = _env(name)
    if v is None or v.strip() == "":
        return dict(default)
    try:
        m = json.loads(v)
        if isinstance(m, dict):
            out: Dict[str, float] = {}
            for k, val in m.items():
                out[str(k)] = float(val)
            return out
    except Exception:
        # best-effort: key1=1.0;key2=2
        items = [p for p in v.split(";") if p.strip()]
        out = dict(default)
        for item in items:
            if "=" in item:
                k, val = item.split("=", 1)
                try:
                    out[k.strip()] = float(val.strip())
                except Exception:
                    continue
        return out
    return dict(default)


def _env_access_mode(name: str, default: AccessMode) -> AccessMode:
    raw = _env(name)
    if raw is None:
        return default
    try:
        return AccessMode[raw.strip().upper()]
    except Exception:
        return default


def _default_chain_dir(chain_id: int) -> Path:
    base = os.getenv("ANIMICA_DATA_DIR") or ("/data" if Path("/data").exists() else "~/.animica")
    return Path(base).expanduser() / f"chain-{chain_id}"


def _legacy_db_candidates(chain_id: int, *, base_dir: Path | None = None) -> list[Path]:
    network_name = {1: "mainnet", 2: "testnet", 1337: "devnet"}.get(
        chain_id, f"chain-{chain_id}"
    )
    default_base = "/data" if Path("/data").exists() else "~/.animica"
    base = (base_dir or Path(os.getenv("ANIMICA_DATA_DIR") or default_base)).expanduser()
    alt_base = Path.home() / "animica"
    candidates = [
        base / network_name / "chain.db",
        base / network_name / "animica.db",
    ]

    # Some legacy setups used a non-dotted root (~/animica/...).
    if base.name.startswith("."):
        candidates.append(alt_base / network_name / "chain.db")
        candidates.append(alt_base / network_name / "animica.db")

    return candidates


def _maybe_migrate_legacy_db(chain_id: int, target: Path) -> Path | None:
    if target.exists():
        return None

    base_dir = target.parent.parent if target.parent.name.startswith("chain-") else target.parent
    for legacy_path in _legacy_db_candidates(chain_id, base_dir=base_dir):
        if legacy_path.exists():
            target.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(legacy_path, target)
            log.info(
                "Migrated legacy RPC DB from %s to %s", legacy_path, target
            )
            return legacy_path
    return None


DEFAULT_PER_METHOD_RPS: Dict[str, float] = {
    # Chain & blocks
    "chain.getParams": 5.0,
    "chain.getChainId": 10.0,
    "chain.getHead": 40.0,
    "chain.getCheckpoints": 10.0,
    "chain.getBlockByNumber": 15.0,
    "chain.getBlockByHash": 15.0,
    # Tx flow
    "tx.sendRawTransaction": 3.0,
    "tx.getTransactionByHash": 25.0,
    "tx.getTransactionReceipt": 25.0,
    # State
    "state.getBalance": 40.0,
    "state.getNonce": 40.0,
    # DA
    "da.putBlob": 2.0,
    "da.getBlob": 10.0,
    "da.getProof": 10.0,
    # Randomness
    "rand.getRound": 20.0,
    "rand.getBeacon": 20.0,
    "rand.commit": 5.0,
    "rand.reveal": 5.0,
    # Miner (getwork)
    "miner.getWork": 10.0,
    "miner.submitShare": 20.0,
}


@dataclass(frozen=True)
class CorsConfig:
    allow_origins: List[str] = field(default_factory=lambda: ["http://localhost:5173"])
    allow_credentials: bool = False
    allow_methods: List[str] = field(default_factory=lambda: ["POST", "GET"])
    allow_headers: List[str] = field(default_factory=lambda: ["content-type"])


@dataclass(frozen=True)
class RateLimitConfig:
    # Global default requests-per-second and burst tokens.
    default_rps: float = 50.0
    burst: int = 200
    # Per-method overrides (method name → rps).
    per_method_rps: Dict[str, float] = field(
        default_factory=lambda: dict(DEFAULT_PER_METHOD_RPS)
    )

    def method_rps(self, method: str) -> float:
        return self.per_method_rps.get(method, self.default_rps)


@dataclass(frozen=True)
class AccessControlConfig:
    mode: AccessMode = AccessMode.LOCAL_DEV
    admin_token: Optional[str] = None
    admin_allowlist: List[str] = field(default_factory=list)
    bootstrap_rate_limit: int = 0
    bootstrap_node: bool = False


@dataclass(frozen=True)
class RpcConfig:
    host: str
    port: int
    ws_path: str
    cors: CorsConfig
    rate: RateLimitConfig
    db_uri: str
    chain_id: int
    log_level: str
    metrics_enabled: bool
    metrics_port: int
    openrpc_enabled: bool
    access: AccessControlConfig
    genesis_path: Path | None = None

    @property
    def base_url(self) -> str:
        return f"http://{self.host}:{self.port}"

    @property
    def ws_url(self) -> str:
        # Consumers can build ws://…/ws; server mounts WS on same port by default.
        scheme = "ws"
        return f"{scheme}://{self.host}:{self.port}{self.ws_path}"


def load() -> RpcConfig:
    """
    Build a RpcConfig from environment variables with sensible defaults.
    """
    host = _env("ANIMICA_RPC_HOST", "127.0.0.1")
    port = _env_int("ANIMICA_RPC_PORT", 8545)
    ws_path = _env("ANIMICA_RPC_WS_PATH", "/ws")

    cors = CorsConfig(
        allow_origins=_env_list("ANIMICA_RPC_CORS_ORIGINS", ["http://localhost:5173"]),
        allow_credentials=_env_bool("ANIMICA_RPC_CORS_ALLOW_CREDENTIALS", False),
        allow_methods=_env_list("ANIMICA_RPC_CORS_METHODS", ["POST", "GET"]),
        allow_headers=_env_list("ANIMICA_RPC_CORS_HEADERS", ["content-type"]),
    )

    rate = RateLimitConfig(
        default_rps=_env_float("ANIMICA_RPC_RATE_RPS", 50.0),
        burst=_env_int("ANIMICA_RPC_RATE_BURST", 200),
        per_method_rps=_env_json_map_float(
            "ANIMICA_RPC_RATE_PER_METHOD", DEFAULT_PER_METHOD_RPS
        ),
    )

    access = AccessControlConfig(
        mode=_env_access_mode("ANIMICA_RPC_ACCESS_MODE", AccessMode.LOCAL_DEV),
        admin_token=_env("ANIMICA_RPC_ADMIN_TOKEN"),
        admin_allowlist=_env_list("ANIMICA_RPC_ADMIN_ALLOWLIST", []),
        bootstrap_rate_limit=_env_int("ANIMICA_BOOTSTRAP_RATE_LIMIT", 0),
        bootstrap_node=_env_bool("ANIMICA_RPC_BOOTSTRAP_NODE", False),
    )
    bootstrap_raw = os.getenv("ANIMICA_RPC_BOOTSTRAP_NODE")
    bootstrap_enabled = parse_env_bool(
        bootstrap_raw, False, name="ANIMICA_RPC_BOOTSTRAP_NODE", log=logger
    )
    if bootstrap_enabled:
        bootstrap_source = "process_env" if bootstrap_raw is not None else "default"
        logger.info(
            "bootstrap_mode=%s source=%s env=%s",
            bootstrap_enabled,
            bootstrap_source,
            bootstrap_raw,
        )

    explicit_chain_id = "ANIMICA_CHAIN_ID" in os.environ
    # Respect explicit chain id first, then fall back to ANIMICA_NETWORK.
    # Default to mainnet (chain_id=1) when no network is explicitly configured.
    network = (_env("ANIMICA_NETWORK", "") or "").strip().lower()
    if explicit_chain_id:
        chain_id = _env_int("ANIMICA_CHAIN_ID", 1)
    else:
        if network in {"main", "mainnet"}:
            chain_id = 1
        elif network in {"test", "testnet"}:
            chain_id = 2
        elif network in {"dev", "devnet", "local-devnet"}:
            chain_id = 1337
        else:
            # Default to mainnet when no network is specified
            chain_id = 1
    
    # Use per-network DB path based on chain_id to ensure DB isolation and
    # align with the shared data directory used by CLI + Docker mounts.
    default_db = _default_chain_dir(chain_id) / "animica.db"
    db_env = _env("ANIMICA_RPC_DB_URI")
    if db_env:
        db_uri = _expand_sqlite_uri(db_env)
    else:
        _maybe_migrate_legacy_db(chain_id, default_db)
        db_uri = _expand_sqlite_uri(f"sqlite:///{default_db}")
    
    log_level = (_env("ANIMICA_LOG_LEVEL", "INFO") or "INFO").upper()

    genesis_env = _env("ANIMICA_GENESIS_PATH")
    genesis_path: Path | None = Path(genesis_env).expanduser() if genesis_env else None
    if genesis_path is None:
        repo_root = Path(__file__).resolve().parents[1]
        try:
            from core.network_params import get_network_genesis_path

            canonical = get_network_genesis_path(network_name=network)
        except Exception:
            canonical = None
        if canonical is not None:
            genesis_path = canonical
        elif network in {"dev", "devnet"}:
            genesis_path = repo_root / "core" / "genesis" / "devnet.json"
        elif network in {"test", "testnet"}:
            genesis_path = repo_root / "core" / "genesis" / "testnet.json"
        else:
            # Default to mainnet: use the canonical genesis.json in core/genesis/
            genesis_path = repo_root / "core" / "genesis" / "mainnet.json"

    if not explicit_chain_id:
        genesis_chain_id = _chain_id_from_genesis(genesis_path)
        if genesis_chain_id is not None:
            chain_id = genesis_chain_id

    metrics_enabled = _env_bool("ANIMICA_METRICS_ENABLED", True)
    metrics_port = _env_int("ANIMICA_METRICS_PORT", 9100)
    openrpc_enabled = _env_bool("ANIMICA_OPENRPC_ENABLED", True)

    return RpcConfig(
        host=host,
        port=port,
        ws_path=ws_path,
        cors=cors,
        rate=rate,
        db_uri=db_uri,
        chain_id=chain_id,
        log_level=log_level,
        metrics_enabled=metrics_enabled,
        metrics_port=metrics_port,
        openrpc_enabled=openrpc_enabled,
        access=access,
        genesis_path=genesis_path,
    )


# Singleton-style accessor for frameworks that prefer module-level config.
CONFIG: RpcConfig = load()


def resolve_chain_id(cfg: RpcConfig | Config | None = None) -> int:
    """Best-effort accessor for chainId used by lightweight server banners.

    Some call sites pass the legacy ``Config`` shim while others rely on the
    newer ``RpcConfig``. This helper normalizes both shapes (and tolerates
    ``chainId``/``CHAIN_ID`` attribute variants) to avoid attribute errors when
    rendering metadata endpoints.
    """

    cfg = cfg or CONFIG
    for attr in ("chain_id", "chainId", "CHAIN_ID"):
        if hasattr(cfg, attr):
            try:
                return int(getattr(cfg, attr))
            except Exception:
                break
    # Fallback to default mainnet id if all else fails.
    return 1


@dataclass
class Config:
    """
    Backwards-compatible shim used by tests and simple scripts. Newer code
    should prefer `RpcConfig` above, but the lightweight `Config` mirrors the
    fields expected by rpc.tests helpers.
    """

    host: str
    port: int
    db_uri: str
    chain_id: int
    logging: str = "INFO"
    cors_allow_origins: list[str] = field(default_factory=list)
    rate_limit_per_ip: float = 0.0
    rate_limit_per_method: float = 0.0
    genesis_path: Path | None = None
    access_mode: str = AccessMode.LOCAL_DEV.value
    admin_token: str | None = None
    admin_allowlist: list[str] = field(default_factory=list)
    bootstrap_rate_limit: int = 0
    bootstrap_node: bool = False


def load_config() -> Config:
    cfg = load()
    return Config(
        host=cfg.host,
        port=cfg.port,
        db_uri=cfg.db_uri,
        chain_id=cfg.chain_id,
        logging=cfg.log_level,
        cors_allow_origins=list(cfg.cors.allow_origins),
        rate_limit_per_ip=cfg.rate.default_rps,
        rate_limit_per_method=cfg.rate.default_rps,
        genesis_path=cfg.genesis_path,
        access_mode=cfg.access.mode.value,
        admin_token=cfg.access.admin_token,
        admin_allowlist=list(cfg.access.admin_allowlist),
        bootstrap_rate_limit=cfg.access.bootstrap_rate_limit,
        bootstrap_node=cfg.access.bootstrap_node,
    )


__all__ = [
    "CorsConfig",
    "RateLimitConfig",
    "AccessControlConfig",
    "AccessMode",
    "RpcConfig",
    "CONFIG",
    "resolve_chain_id",
    "load",
    "Config",
    "load_config",
]
