"""Config-entry wiring for the in-process MCP server entry type (issue #1527).

Runs the full ha-mcp FastMCP server in-process inside Home Assistant and exposes
it remotely through a Home Assistant webhook. Creating the "server" config entry
starts the server; disabling the entry pauses it (HA calls
:func:`async_unload_server_entry` via the domain dispatcher in ``__init__``);
removing the entry revokes the provisioned credentials.

``__init__.async_setup_entry`` dispatches to these functions for the "server"
entry type; the "tools" services entry is handled separately. This module is
intentionally thin — the HA entry-point wiring only. The bring-up / teardown
orchestration lives in :mod:`embedded_setup`, and the server thread + webhook
ingress in :mod:`embedded_server` / :mod:`mcp_webhook`.
"""

from __future__ import annotations

import asyncio
import secrets
from contextlib import suppress
from typing import TYPE_CHECKING

from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback

from .const import (
    DATA_BRINGUP_TASK,
    DATA_LAST_OPTIONS,
    DATA_OAUTH_CLIENT_ID,
    DATA_OAUTH_CLIENT_SECRET,
    DATA_OAUTH_SIGNING_KEY,
    DATA_SECRET_PATH,
    DATA_UPDATE_COORDINATOR,
    DATA_WEBHOOK_ID,
    DOMAIN,
    OPT_ENABLE_SIDEBAR_PANEL,
    OPT_ENABLE_WEBHOOK,
    OPT_OAUTH_CLIENT_ID,
    OPT_OAUTH_CLIENT_SECRET,
    OPT_OAUTH_REGENERATE,
    OPT_REGENERATE_SECRETS,
    OPT_SECRET_PATH_OVERRIDE,
    OPT_WEBHOOK_AUTH,
    OPT_WEBHOOK_ID_OVERRIDE,
    WEBHOOK_AUTH_LEGACY,
)

# NOTE: embedded_setup / coordinator (and their embedded_server / mcp_webhook
# chain) are imported lazily inside the entry lifecycle functions below, not at
# module top level. They pull in aiohttp and several homeassistant.* submodules
# (auth, requirements, util.package, components.http/webhook) that the
# entry-point wiring here never touches directly, so a top-level import would
# make importing this package require that whole stack — breaking hermetic unit
# tests that stub only the modules they use.

if TYPE_CHECKING:
    from homeassistant.config_entries import ConfigEntry


async def async_setup_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    """Set up the server entry: schedule the server bring-up as a background task.

    The bring-up (first pip install of the fastmcp tree, token provisioning,
    thread start, webhook registration) can take minutes, so it must not stall HA
    startup. It runs as a config-entry background task — automatically cancelled
    on unload. The secret webhook id and secret path are generated first, before
    the update listener is registered, so those ``entry.data`` writes never
    trigger a mid-setup reload.
    """
    # Imported lazily (see the import note) so the aiohttp / auth / requirements
    # chain is pulled in only when an entry is actually set up.
    from .coordinator import ServerVersionCoordinator
    from .embedded_setup import async_bring_up_server, async_maybe_auto_update
    from .ui_panel import async_register_ui_panel

    _ensure_secrets(hass, entry)
    # Bind the legacy OAuth root views synchronously here — before the slow
    # background bring-up below — so they are live at boot (see the helper).
    _prebind_legacy_oauth_views(hass, entry)

    # Admin-only "Open Web UI" sidebar panel + proxy. Registered while the entry
    # exists (its proxy returns 503 until the server is actually running), so the
    # user sees the panel immediately and it reflects the running state. Gated on
    # the sidebar-panel option; a change to it reloads the entry, and unload's
    # unconditional async_unregister_ui_panel then removes the panel this skips.
    if bool(entry.options.get(OPT_ENABLE_SIDEBAR_PANEL, True)):
        await async_register_ui_panel(hass)

    domain_data = hass.data.setdefault(DOMAIN, {})
    # Snapshot the options so the update listener reloads only on a genuine
    # options change — the background bring-up persists ids/token/pip spec to
    # entry.data, and those writes must not self-reload.
    domain_data[DATA_LAST_OPTIONS] = dict(entry.options)

    # Server-version visibility + automatic updates (issue #1760): the
    # coordinator polls PyPI on its own UPDATE_CHECK_INTERVAL regardless of the
    # auto_update option, backing the `update` platform entity forwarded below.
    # Its listener forwards every refresh to async_maybe_auto_update, which
    # decides whether to actually reload. Created and stored BEFORE the
    # bring-up task: bring-up's success path (_async_finish_update_cycle)
    # refreshes this coordinator, so it must already be in hass.data whenever
    # that task runs.
    coordinator = ServerVersionCoordinator(hass, entry)
    domain_data[DATA_UPDATE_COORDINATOR] = coordinator

    task = entry.async_create_background_task(
        hass, async_bring_up_server(hass, entry), f"{DOMAIN}_bring_up"
    )
    domain_data[DATA_BRINGUP_TASK] = task

    entry.async_on_unload(entry.add_update_listener(_async_options_updated))

    @callback
    def _on_version_update() -> None:
        # A reload must never run synchronously from inside this listener
        # callback: it would unload the UPDATE platform this very coordinator
        # drives (forwarded below), tearing the coordinator down mid-callback.
        #
        # hass-owned, NOT entry.async_create_background_task: entry background
        # tasks are cancelled by the very unload that async_maybe_auto_update's
        # reload performs, so an entry-owned task would cancel itself mid-reload
        # and leave the entry unloaded without ever setting back up (server down
        # until restart). The interval-timer wiring this replaces ran its checks
        # as plain hass jobs for the same reason.
        hass.async_create_background_task(
            async_maybe_auto_update(hass, entry, coordinator.data),
            f"{DOMAIN}_server_auto_update",
        )

    entry.async_on_unload(coordinator.async_add_listener(_on_version_update))

    # Background, not awaited: entry setup must not block on a PyPI round-trip
    # (this is why async_config_entry_first_refresh is NOT used here). The
    # coordinator reschedules itself on UPDATE_CHECK_INTERVAL after this first
    # refresh completes.
    entry.async_create_background_task(
        hass, coordinator.async_refresh(), f"{DOMAIN}_server_version_refresh"
    )

    await hass.config_entries.async_forward_entry_setups(entry, [Platform.UPDATE])
    return True


async def async_unload_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    """Stop the server + ingress webhook (reload-safe; keeps the provisioned token).

    Unloads the UPDATE platform first so the coordinator's entity is torn down
    before the coordinator itself is popped from hass.data, then cancels the
    bring-up task so a still-in-flight install/start is torn down before the
    explicit teardown runs.
    """
    from .embedded_setup import async_teardown_server  # lazy (see import note)
    from .ui_panel import async_unregister_ui_panel

    await hass.config_entries.async_unload_platforms(entry, [Platform.UPDATE])

    domain_data = hass.data.get(DOMAIN, {})
    task = domain_data.pop(DATA_BRINGUP_TASK, None)
    if task is not None and not task.done():
        task.cancel()
        with suppress(asyncio.CancelledError):
            await task

    await async_teardown_server(hass)
    async_unregister_ui_panel(hass)
    domain_data.pop(DATA_LAST_OPTIONS, None)
    domain_data.pop(DATA_UPDATE_COORDINATOR, None)
    return True


async def async_remove_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
    """Revoke the provisioned credentials when the server config entry is removed."""
    from .embedded_setup import (  # lazy (see import note)
        async_revoke_credentials_on_remove,
    )

    await async_revoke_credentials_on_remove(hass, entry)


async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
    """Reload the entry when its OPTIONS change (port / auth / pip spec / URL).

    Ignores the ``entry.data`` writes the background bring-up performs (webhook
    id, secret path, provisioned token ids, last pip spec): those fire the same
    update listener but must not reload the entry.
    """
    domain_data = hass.data.get(DOMAIN, {})
    if domain_data.get(DATA_LAST_OPTIONS) == dict(entry.options):
        return
    await hass.config_entries.async_reload(entry.entry_id)


def _prebind_legacy_oauth_views(hass: HomeAssistant, entry: ConfigEntry) -> None:
    """Register the legacy OAuth root ``/authorize`` + ``/token`` views during
    entry setup, before the background bring-up's (slow) package install.

    An aiohttp route is only ever live if it is registered before Home Assistant
    freezes its HTTP app at the end of startup. Binding these views from the
    background bring-up task races HA reaching RUNNING: on a slow-install boot
    the routes would register AFTER the freeze — never live until a restart —
    and ``bind_legacy_views`` would see ``hass.is_running`` True and file a
    restart repair that the restart cannot clear. Binding here, while the entry
    is still setting up (``hass.is_running`` is False at boot), mirrors the
    webhook-proxy add-on, which binds in its own ``async_setup_entry``. The
    bring-up's ``async_register_webhook`` then reuses this already-bound provider.

    Only relevant when legacy is the configured mode and the webhook endpoint is
    enabled (legacy OAuth guards that endpoint; with no webhook there is nothing
    to protect). A route-ownership conflict with the webhook-proxy add-on is
    swallowed here — the bring-up re-encounters it and files the user-facing
    start-failed repair.
    """
    if str(entry.options.get(OPT_WEBHOOK_AUTH, "")) != WEBHOOK_AUTH_LEGACY:
        return
    if not bool(entry.options.get(OPT_ENABLE_WEBHOOK, True)):
        return
    client_id = entry.data.get(DATA_OAUTH_CLIENT_ID)
    client_secret = entry.data.get(DATA_OAUTH_CLIENT_SECRET)
    signing_key = entry.data.get(DATA_OAUTH_SIGNING_KEY)
    if not (client_id and client_secret and signing_key):
        # _ensure_secrets mints these whenever legacy mode is configured; a gap
        # means a partial config — let the bring-up path surface it.
        return
    from .mcp_webhook import _register_metadata_views
    from .oauth_legacy import LegacyOAuthRouteConflict, bind_legacy_views

    with suppress(LegacyOAuthRouteConflict):
        # Register the RFC 8414/9728 discovery views alongside the root
        # /authorize + /token views, both at setup time, so the discovery
        # doc's resource_metadata URL resolves at boot for RFC-compliant
        # clients — not just the root views. Both are idempotent, so the
        # bring-up's async_register_webhook reuses them.
        _register_metadata_views(hass)
        bind_legacy_views(hass, client_id, client_secret, signing_key)


def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None:
    """Generate + persist the stable webhook id and secret path on first setup.

    Both live in ``entry.data`` and stay stable across restarts so the connect
    URL never changes. Three owner-requested management paths, applied in
    priority order on every (re)load:

    1. ``regenerate_secrets`` option: mint fresh random values for BOTH and
       clear any overrides plus the flag itself (one-shot rotation - the old
       URL dies on this reload).
    2. Override options: a non-empty ``webhook_id_override`` /
       ``secret_path_override`` replaces the stored value (normalized: the
       secret path gets a leading ``/``).
    3. First setup: mint random values for whatever is still missing.

    When the configured webhook auth mode is legacy, the same three-path
    lifecycle additionally applies to the legacy OAuth client_id/client_secret
    (see :func:`_ensure_legacy_oauth_secrets`) — folded into this same
    read-mutate-write cycle so a single ``async_update_entry`` call covers
    every field that changed this load, including when BOTH a webhook secret
    regenerate and an OAuth credential regenerate are requested together.
    """
    data = dict(entry.data)
    options = dict(entry.options)
    changed = False

    if options.get(OPT_REGENERATE_SECRETS):
        data[DATA_WEBHOOK_ID] = f"mcp_{secrets.token_hex(16)}"
        data[DATA_SECRET_PATH] = f"/private_{secrets.token_urlsafe(16)}"
        # One-shot: clear the flag AND the overrides so the fresh random
        # values stick (leaving an override set would re-apply it below on
        # the next reload, silently undoing the rotation).
        options[OPT_REGENERATE_SECRETS] = False
        options[OPT_WEBHOOK_ID_OVERRIDE] = ""
        options[OPT_SECRET_PATH_OVERRIDE] = ""
        changed = True
    else:
        webhook_override = str(options.get(OPT_WEBHOOK_ID_OVERRIDE) or "").strip()
        if webhook_override and data.get(DATA_WEBHOOK_ID) != webhook_override:
            data[DATA_WEBHOOK_ID] = webhook_override
            changed = True
        path_override = str(options.get(OPT_SECRET_PATH_OVERRIDE) or "").strip()
        if path_override:
            if not path_override.startswith("/"):
                path_override = f"/{path_override}"
            if data.get(DATA_SECRET_PATH) != path_override:
                data[DATA_SECRET_PATH] = path_override
                changed = True

    if not data.get(DATA_WEBHOOK_ID):
        data[DATA_WEBHOOK_ID] = f"mcp_{secrets.token_hex(16)}"
        changed = True
    if not data.get(DATA_SECRET_PATH):
        data[DATA_SECRET_PATH] = f"/private_{secrets.token_urlsafe(16)}"
        changed = True

    if options.get(OPT_WEBHOOK_AUTH) == WEBHOOK_AUTH_LEGACY:
        changed = _ensure_legacy_oauth_secrets(data, options) or changed

    if changed:
        hass.config_entries.async_update_entry(entry, data=data, options=options)


def _ensure_legacy_oauth_secrets(data: dict, options: dict) -> bool:
    """Mint/persist/rotate the legacy OAuth mode's credentials into ``data``
    (mutated in place, along with ``options`` for the one-shot regenerate
    flag). Only called while the configured webhook auth mode is legacy —
    switching away leaves whatever was last minted in place, so switching
    back reuses it rather than silently rotating.

    Mirrors the ``OPT_REGENERATE_SECRETS`` shape above: ``OPT_OAUTH_REGENERATE``
    is one-shot, minting a fresh client_id/client_secret and clearing itself
    plus the two override fields.

    ANY credential change — regenerate, a client_id override, or a
    client_secret override — also rotates ``signing_key``, making every
    rotation a hard revocation of outstanding tokens (they take effect at the
    restart that rebinds the views). Rotating the key on a secret change is
    load-bearing (token validation never involves the secret, so the cid
    claim alone would not evict anything). Rotating it on a client_id change
    is defence in depth: the cid claim already evicts on a normal rotation,
    but without a fresh key an ``A → B → A`` client_id sequence would
    resurrect id-A's still-unexpired tokens, since they re-match the cid
    claim under the unchanged-key HMAC. Rotating the key kills that echo at
    zero cost. Rotation does NOT shorten the pre-restart window — the bound
    views keep serving the old identity until the restart (see
    ``oauth_legacy.bind_legacy_views``) — which is why the startup log also
    withholds rotated credentials until they are active
    (``embedded_setup._surface_connect_urls`` via
    ``oauth_legacy.legacy_credentials_active``; review findings on #1880).

    Returns True if ``data``/``options`` were mutated.
    """
    changed = False
    if options.get(OPT_OAUTH_REGENERATE):
        data[DATA_OAUTH_CLIENT_ID] = f"hamcp-{secrets.token_hex(16)}"
        data[DATA_OAUTH_CLIENT_SECRET] = secrets.token_urlsafe(32)
        # Every credential change rotates the key — see docstring (kills the
        # A->B->A client_id resurrection; regenerate mints random ids so it
        # can't recur to a former id, but the key rotation is kept uniform).
        data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
        options[OPT_OAUTH_REGENERATE] = False
        options[OPT_OAUTH_CLIENT_ID] = ""
        options[OPT_OAUTH_CLIENT_SECRET] = ""
        changed = True
    else:
        client_id_override = str(options.get(OPT_OAUTH_CLIENT_ID) or "").strip()
        if client_id_override and data.get(DATA_OAUTH_CLIENT_ID) != client_id_override:
            data[DATA_OAUTH_CLIENT_ID] = client_id_override
            # Rotate the key so a re-used former client_id can't resurrect its
            # old tokens (see docstring).
            data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
            changed = True
        client_secret_override = str(options.get(OPT_OAUTH_CLIENT_SECRET) or "").strip()
        if (
            client_secret_override
            and data.get(DATA_OAUTH_CLIENT_SECRET) != client_secret_override
        ):
            data[DATA_OAUTH_CLIENT_SECRET] = client_secret_override
            # Evict outstanding tokens along with the old secret — see the
            # docstring for why a secret-only rotation must not leave them
            # valid for the rest of their TTL.
            data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
            changed = True
        # Consume the OAuth override fields once applied. entry.options is
        # readable through the server's own tools
        # (ha_get_integration(include_options=True) rebuilds it from the
        # options-form suggested_values), so a rotated client_secret left here
        # in cleartext would let a pre-restart old-identity token holder read
        # the NEW secret that way — the exact party the rotation evicts, and
        # the same leak the startup log withholds. The resolved values live in
        # entry.data and on the admin-only Configure screen
        # (config_flow._oauth_creds_hint), so nothing is lost. Cleared even
        # when the override matched the current value: the cleartext must not
        # linger regardless of whether it changed data.
        #
        # DELIBERATE divergence from the webhook_id / secret_path overrides
        # above, which persist. Three reasons the OAuth secret is different:
        # (1) A client_secret override IS the OAuth rotation path and gates a
        #     per-connection revocable bearer, so a lingering copy defeats the
        #     very revocation the rotation performs. secret_path's own rotation
        #     (OPT_REGENERATE_SECRETS) already clears its override, so that
        #     path is not self-defeating; a standalone secret_path override is
        #     configuration, not rotation.
        # (2) A connected legacy client learns the OAuth secret only via this
        #     options leak (entry.data is not tool-exposed; the webhook is
        #     OAuth-gated). secret_path gates the direct LAN port, and per
        #     SECURITY.md the local network is the trusted zone and the client
        #     is a trusted principal — LAN-peer access to standard-mode
        #     endpoints is explicitly out of scope.
        # (3) Persisting the webhook_id/secret_path override is deliberate UX
        #     (the admin sees their configured value in the form).
        if options.get(OPT_OAUTH_CLIENT_ID):
            options[OPT_OAUTH_CLIENT_ID] = ""
            changed = True
        if options.get(OPT_OAUTH_CLIENT_SECRET):
            options[OPT_OAUTH_CLIENT_SECRET] = ""
            changed = True

    if not data.get(DATA_OAUTH_CLIENT_ID):
        data[DATA_OAUTH_CLIENT_ID] = f"hamcp-{secrets.token_hex(16)}"
        changed = True
    if not data.get(DATA_OAUTH_CLIENT_SECRET):
        data[DATA_OAUTH_CLIENT_SECRET] = secrets.token_urlsafe(32)
        changed = True
    if not data.get(DATA_OAUTH_SIGNING_KEY):
        data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32)
        changed = True
    return changed
