"""Setup Core V2 — deterministic plan executor.

:func:`apply_plan` is the single entry point for mutating the environment
(``config.toml`` and ``.env``) from a validated :class:`SetupPlan`.  It is
intentionally decoupled from any UI layer — callers are responsible for
collecting user input and building the plan via
:func:`~otto.setup_core.engine.build_plan` before calling this function.

Typical usage::

    from otto.setup_core import build_plan, SetupChoice
    from otto.setup_core.apply import apply_plan

    choice = SetupChoice(
        model="anthropic/claude-sonnet-4-5-20250514",
        channels=("cli",),
        provider_api_key="sk-ant-...",
        owner_name="alice",
    )
    plan = build_plan(choice)
    if not plan.is_complete:
        raise RuntimeError(f"Incomplete plan: {plan.gaps}")
    config = apply_plan(plan)          # writes config.toml + .env
"""

from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from otto.setup_core.models import SetupPlan

from otto.config import (
    OTTO_HOME,
    AgentConfig,
    BotAuthConfig,
    BotConfig,
    BotSkillsConfig,
    ChannelConfig,
    Config,
    UserConfig,
    WebConfig,
    WorkspaceConfig,
    ensure_dirs,
    save_config,
)

_DEFAULT_ENV_FILE = OTTO_HOME / ".env"
_DEFAULT_CONFIG_FILE = OTTO_HOME / "config.toml"


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def apply_plan(
    plan: "SetupPlan",
    *,
    config_path: Path | None = None,
    env_file: Path | None = None,
) -> Config:
    """Execute a validated :class:`SetupPlan` by writing config files.

    Steps performed in order:

    1. Create the standard Otto directory tree (idempotent).
    2. Write ``.env`` from ``plan.secrets`` (chmod 600) — only when non-empty.
    3. Build the :class:`~otto.config.Config` object from the plan choices.
    4. Write ``config.toml`` (chmod 644).

    Parameters
    ----------
    plan:
        A plan produced by :func:`~otto.setup_core.engine.build_plan`.
        The caller should verify ``plan.is_complete`` before calling this
        function; incomplete plans will still run but missing secrets won't
        be written to ``.env``.
    config_path:
        Override the destination path for ``config.toml``.
        Defaults to ``~/.otto/config.toml``.
    env_file:
        Override the destination path for the ``.env`` file.
        Defaults to ``~/.otto/.env``.

    Returns
    -------
    Config
        The resulting configuration object, ready to be passed to
        ``otto.daemon`` or ``otto.cli``.

    Raises
    ------
    TypeError
        If ``plan`` is not a :class:`~otto.setup_core.models.SetupPlan`.
    ValueError
        If the plan's model string is empty.
    """
    _validate_plan_type(plan)

    effective_config_path = config_path or _DEFAULT_CONFIG_FILE
    effective_env_file = env_file or _DEFAULT_ENV_FILE

    # 1. Ensure directories exist.
    ensure_dirs()
    effective_config_path.parent.mkdir(parents=True, exist_ok=True)
    effective_env_file.parent.mkdir(parents=True, exist_ok=True)

    # 2. Write secrets from plan.secrets (already computed by the engine).
    if plan.secrets:
        write_env_file(plan.secrets, env_file=effective_env_file)

    # 3. Build Config.
    config = build_config(plan)

    # 4. Persist config.toml.
    save_config(config, effective_config_path)
    effective_config_path.chmod(0o644)

    return config


# ---------------------------------------------------------------------------
# Config builder — pure, no I/O
# ---------------------------------------------------------------------------


def build_config(plan: "SetupPlan") -> Config:
    """Construct a :class:`~otto.config.Config` from a :class:`SetupPlan`.

    Pure — no file writes.  Uses ``plan.choice`` for owner/workspace/bot
    metadata and ``plan.requirements`` for channel/model details.

    Raises
    ------
    TypeError
        If ``plan`` is not a :class:`~otto.setup_core.models.SetupPlan`.
    ValueError
        If the model string is empty.
    """
    _validate_plan_type(plan)

    choice = plan.choice
    requirements = plan.requirements

    # Derive model string — must be non-empty.
    model_str = ""
    if choice is not None:
        model_str = choice.model.strip()
    if not model_str:
        raise ValueError("SetupPlan model must not be empty.")

    # Prepend provider prefix when the model is bare (no slash).
    provider = requirements.model_provider or ""
    if provider and "/" not in model_str:
        model_str = f"{provider}/{model_str}"

    # Channels come from requirements (already normalised and validated).
    channels = requirements.channels or ("cli",)

    # Build ChannelConfig list.
    channel_list: list[ChannelConfig] = []
    if "telegram" in channels:
        channel_list.append(ChannelConfig(type="telegram", token="${TELEGRAM_BOT_TOKEN}"))
    if "cli" in channels:
        channel_list.append(ChannelConfig(type="cli"))
    if "web" in channels:
        channel_list.append(ChannelConfig(type="web"))
    if not channel_list:
        channel_list.append(ChannelConfig(type="cli"))

    # Auth bootstrap: let the first Telegram user claim ownership when
    # Telegram is configured, rather than requiring a pre-set telegram_id.
    bootstrap = "first_user" if "telegram" in channels else "disabled"

    # Owner / workspace / bot metadata — pulled from choice when available.
    owner_name = (choice.owner_name if choice is not None else "") or "default"
    owner_tg_id = choice.owner_telegram_id if choice is not None else None
    workspace_root_str = choice.workspace_root if choice is not None else "~/.otto/workspace"
    workspace_mode = choice.workspace_mode if choice is not None else "default"
    workspace_sandbox = choice.workspace_sandbox if choice is not None else "none"
    workspace_sandbox_network = choice.workspace_sandbox_network if choice is not None else True
    bot_name = (choice.bot_name if choice is not None else "") or "default"

    users: list[UserConfig] = [UserConfig(name=owner_name, telegram_id=owner_tg_id)]
    bots: list[BotConfig] = [
        BotConfig(
            name=bot_name,
            model=model_str,
            auth=BotAuthConfig(
                owner=owner_name,
                allowed_users=[owner_name],
                bootstrap=bootstrap,
            ),
            workspace=WorkspaceConfig(
                root=Path(workspace_root_str).expanduser(),
                mode=workspace_mode,
                sandbox=workspace_sandbox,
                sandbox_network=workspace_sandbox_network,
            ),
            skills=BotSkillsConfig(include_shared=True),
            channels=channel_list,
        )
    ]

    return Config(
        agent=AgentConfig(model=model_str),
        log_level="info",
        env_file=".env",
        users=users,
        bots=bots,
        web=WebConfig(),
    )


# ---------------------------------------------------------------------------
# .env writer
# ---------------------------------------------------------------------------


def write_env_file(
    secrets: dict[str, str],
    *,
    env_file: Path | None = None,
    merge: bool = True,
) -> Path:
    """Write ``secrets`` to an ``.env`` file (chmod 600).

    Parameters
    ----------
    secrets:
        Mapping of environment variable name → value.  Entries with empty
        values are silently skipped.
    env_file:
        Path to write.  Defaults to ``~/.otto/.env``.
    merge:
        When ``True`` (default), existing entries that are *not* in
        ``secrets`` are preserved.  Set to ``False`` to overwrite the file
        entirely.

    Returns
    -------
    Path
        The path that was written.
    """
    target = (env_file or _DEFAULT_ENV_FILE).expanduser()
    target.parent.mkdir(parents=True, exist_ok=True)

    existing: dict[str, str] = {}
    if merge and target.exists():
        existing = _read_env_file(target)

    merged = {**existing, **{k: v for k, v in secrets.items() if v}}
    lines = [f"{key}={value}" for key, value in merged.items()]
    target.write_text("\n".join(lines) + "\n" if lines else "", encoding="utf-8")
    target.chmod(0o600)
    return target


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _validate_plan_type(plan: object) -> None:
    from otto.setup_core.models import SetupPlan

    if not isinstance(plan, SetupPlan):
        raise TypeError(f"Expected SetupPlan, got {type(plan)!r}")


def _read_env_file(path: Path) -> dict[str, str]:
    """Parse a simple ``KEY=VALUE`` file.  Ignores blank lines and comments."""
    result: dict[str, str] = {}
    try:
        for line in path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            if "=" in line:
                key, _, value = line.partition("=")
                result[key.strip()] = value
    except OSError:
        pass
    return result
