"""Connect / disconnect / list connectors — writes tokens to the SecretStore.

Pure functions over a SecretStore so they're testable without the server. `validate=False`
skips the network check (used by tests). Secrets are never returned — only status + the
public bot identity captured at connect time.
"""

from __future__ import annotations

from typing import Any

from ..secrets import SecretStore
from .catalog_copy import about_for, access_for
from .descriptors import get_descriptor, list_descriptors
from .tool_defs import patch_tool_settings, tool_dicts

_EXPERIMENTAL_KEY = "experimental:settings"


def experimental_enabled(secrets: SecretStore) -> bool:
    """Whether the user has opted in to experimental (use-at-your-own-risk) connectors."""
    return bool((secrets.get(_EXPERIMENTAL_KEY) or {}).get("enabled"))


def set_experimental_enabled(secrets: SecretStore, value: bool) -> dict[str, Any]:
    secrets.put(_EXPERIMENTAL_KEY, {"enabled": bool(value)})
    return {"ok": True, "enabled": bool(value)}


def _profile_connected(descriptor, profile: dict[str, Any]) -> bool:
    if not descriptor.available:
        return False
    if descriptor.auth == "none":
        return True
    # Managed relay (e.g. Slack cloud relay) carries no manual credential in the
    # :default profile — the tokens live per-team (slack:team:*). The relay-mode
    # flag is what marks it connected, so don't require the manual fields.
    if profile.get("mode") == "relay":
        return True
    required = [
        f.key for f in descriptor.fields if f.required and f.key != "allowed_users"
    ]
    return bool(profile) and all(bool(profile.get(k)) for k in required)


def _mcp_tokens_present(secrets: SecretStore, name: str) -> bool:
    # Lazy import: the mcp package pulls in the MCP SDK, which connector listing
    # shouldn't pay for unless an MCP-backed profile actually exists.
    from ..mcp.oauth import has_tokens

    return has_tokens(name, secrets)


def connector_list(secrets: SecretStore) -> list[dict[str, Any]]:
    show_experimental = experimental_enabled(secrets)
    out: list[dict[str, Any]] = []
    for d in list_descriptors():
        # Experimental connectors are invisible (not just disabled) until the user opts in;
        # hiding them here also drops their tools from engine builds via
        # _enabled_connector_tools, so flipping the setting off cuts access immediately.
        if d.experimental and not show_experimental:
            continue
        profile = secrets.get(f"{d.name}:default") or {}
        if d.mcp_url and profile.get("mode") == "mcp":
            # MCP-backed connect: the profile is just a marker — connected-ness
            # lives with the OAuth tokens (mcp-oauth:<name> in the SecretStore).
            connected = _mcp_tokens_present(secrets, d.name)
        else:
            connected = _profile_connected(d, profile)
        entry = {
            "name": d.name,
            "title": d.title,
            "icon": d.icon,
            "blurb": d.blurb,
            # Pre-connect detail page copy (UX-DECISIONS §38): About paragraph
            # (may be empty → GUI omits the group) + honest Access bullets.
            "about": about_for(d.name),
            "access": access_for(d.name),
            "auth": d.auth,
            "two_way": d.two_way,
            "channels": d.channels,
            "available": d.available,
            "brand_color": d.brand_color,
            "logo": d.logo,
            "aliases": list(d.aliases),
            # MCP-backed one-click (vendor-hosted MCP server + local OAuth) —
            # distinct from `managed` (broker OAuth): no cloud sign-in needed.
            "mcp": bool(d.mcp_url),
            "fields": [f.to_dict() for f in d.fields],
            "instructions": d.instructions,
            "connected": connected,
            "account": profile.get("account"),
            "enabled": bool(profile.get("enabled", True)) and connected,
            # The actual allow-list (the GUI manages it inline); was a bare count.
            "allowed_users": list(profile.get("allowed_users") or []),
            # Manual Socket Mode only: explicitly selected humans who may resolve
            # consequential Inbox prompts. Relay uses its OAuth installer instead.
            "approval_owner_ids": list(profile.get("approval_owner_ids") or []),
            "tools": tool_dicts(secrets, d.name),
            "experimental": d.experimental,
            "risk_notice": d.risk_notice,
            "managed": d.managed,
            "managed_paused": d.managed_paused,
            # Whether THIS profile came from managed OAuth (vs manual paste).
            "managed_profile": bool(profile.get("managed")),
            # "relay" for the managed cloud path; empty for manual/token connect.
            "mode": profile.get("mode") or "",
        }
        if d.name == "slack":
            # Managed relay is multi-workspace: each `slack:team:*` profile is one
            # connected workspace with its OWN allow-list (ids are workspace-scoped).
            entry["workspaces"] = _slack_workspaces(secrets)
            if profile.get("mode") == "relay":
                # Dormant Manual-mode owners may remain beside preserved Socket
                # Mode credentials; they never authorize a bare Relay target.
                entry["approval_owner_ids"] = []
        if d.name == "gmail":
            # Multi-account: each `gmail:account:*` profile is one mailbox; the
            # :default profile is just the default pointer + privacy filters.
            from . import gmail_accounts

            accounts = _gmail_account_list(secrets)
            default_email = gmail_accounts.default_account(secrets)
            entry["accounts"] = accounts
            entry["connected"] = bool(accounts)
            entry["enabled"] = bool(profile.get("enabled", True)) and bool(accounts)
            entry["account"] = default_email or None
            entry["managed_profile"] = any(
                a["email"] == default_email and a["managed"] for a in accounts
            )
            entry["filters"] = gmail_accounts.get_filters(secrets)
        if d.name == "google_calendar":
            # Multi-account, same shape as gmail: each `google_calendar:account:*`
            # profile is one Google account; :default is just the default pointer.
            from . import gcal_accounts

            accounts = _gcal_account_list(secrets)
            default_email = gcal_accounts.default_account(secrets)
            entry["accounts"] = accounts
            entry["connected"] = bool(accounts)
            entry["enabled"] = bool(profile.get("enabled", True)) and bool(accounts)
            entry["account"] = default_email or None
            entry["managed_profile"] = any(
                a["email"] == default_email and a["managed"] for a in accounts
            )
        if d.name == "github":
            # Managed relay is multi-installation: each `github:install:*`
            # profile is one App installation with its OWN allow-list of
            # sender logins. The manual PAT path stays on the default profile.
            entry["installations"] = _github_installations(secrets)
            if entry["installations"] and profile.get("mode") == "relay":
                first = entry["installations"][0]
                entry["account"] = entry["account"] or first["account_login"]
        if d.account_field:
            # Generic multi-account (batch-2 connectors): each
            # `<name>:account:*` profile is one account; :default is pointer-only.
            from . import accounts as _accounts

            rows = _accounts.account_rows(secrets, d.name)
            default_id = _accounts.default_account(secrets, d.name)
            entry["accounts"] = rows
            entry["connected"] = bool(rows)
            entry["enabled"] = bool(profile.get("enabled", True)) and bool(rows)
            default_row = next((r for r in rows if r["account_id"] == default_id), None)
            entry["account"] = (default_row or {}).get("name") or None
            entry["managed_profile"] = bool((default_row or {}).get("managed"))
        if d.name == "hubspot":
            # Multi-portal: each `hubspot:portal:*` profile is one portal; the
            # :default profile is the default pointer + hidden-fields policy.
            from . import hubspot_portals

            portals = _hubspot_portal_list(secrets)
            default_hub = hubspot_portals.default_portal(secrets)
            entry["portals"] = portals
            entry["connected"] = bool(portals)
            entry["enabled"] = bool(profile.get("enabled", True)) and bool(portals)
            default_row = next((p for p in portals if p["hub_id"] == default_hub), None)
            entry["account"] = (default_row or {}).get("name") or None
            entry["managed_profile"] = bool((default_row or {}).get("managed"))
            entry["hidden_fields"] = hubspot_portals.get_hidden_fields(secrets)
        out.append(entry)
    return out


def _slack_workspaces(secrets: SecretStore) -> list[dict[str, Any]]:
    from .config import _slack_team_profiles

    return [
        {
            "team_id": team_id,
            "account": profile.get("account") or team_id,
            "domain": profile.get("domain") or "",
            "allowed_users": list(profile.get("allowed_users") or []),
            "allow_all": bool(profile.get("allow_all")),
            # Relay approvals are installer-only. Keep the list-shaped API aligned
            # with Manual mode without creating a second editable relay role.
            "approval_owner_ids": (
                [profile["slack_user_id"]] if profile.get("slack_user_id") else []
            ),
            # Who installed (authed_user) — the GUI marks their chip "you" and
            # keys the post-connect card's "your mentions get through" line.
            "installer_user_id": profile.get("slack_user_id") or "",
            "installer_name": profile.get("sender_name") or "",
        }
        for team_id, profile in sorted(
            _slack_team_profiles(secrets), key=lambda t: t[0]
        )
    ]


def _github_installations(secrets: SecretStore) -> list[dict[str, Any]]:
    from .github_installs import list_installs

    return [
        {
            "installation_id": installation_id,
            "account_login": profile.get("account_login") or installation_id,
            "account_type": profile.get("account_type") or "",
            "repo_selection": profile.get("repo_selection") or "",
            "github_login": profile.get("github_login") or "",
            "allowed_users": list(profile.get("allowed_users") or []),
            "allow_all": bool(profile.get("allow_all")),
        }
        for installation_id, profile in list_installs(secrets)
    ]


def _gmail_account_list(secrets: SecretStore) -> list[dict[str, Any]]:
    from time import time

    from . import gmail_accounts

    default = gmail_accounts.default_account(secrets)
    out = []
    for email, profile in gmail_accounts.list_accounts(secrets):
        expires = float(profile.get("expires") or 0)
        out.append(
            {
                "email": email,
                "default": email == default,
                "managed": bool(profile.get("managed")),
                "scopes": profile.get("scope") or "",
                # Expired with no way to renew silently → the GUI offers Reauthorize.
                "needs_reauth": bool(
                    expires and expires < time() and not profile.get("refresh_token")
                ),
            }
        )
    return out


def _gcal_account_list(secrets: SecretStore) -> list[dict[str, Any]]:
    from time import time

    from . import gcal_accounts

    default = gcal_accounts.default_account(secrets)
    out = []
    for email, profile in gcal_accounts.list_accounts(secrets):
        expires = float(profile.get("expires") or 0)
        out.append(
            {
                "email": email,
                "default": email == default,
                "managed": bool(profile.get("managed")),
                "scopes": profile.get("scope") or "",
                # Expired with no way to renew silently → the GUI offers Reauthorize.
                "needs_reauth": bool(
                    expires and expires < time() and not profile.get("refresh_token")
                ),
            }
        )
    return out


def _hubspot_portal_list(secrets: SecretStore) -> list[dict[str, Any]]:
    from . import hubspot_portals

    default = hubspot_portals.default_portal(secrets)
    out = []
    for hub_id, profile in hubspot_portals.list_portals(secrets):
        scope = str(profile.get("scope") or "")
        out.append(
            {
                "hub_id": hub_id,
                "name": profile.get("account") or f"portal {hub_id}",
                "sandbox": bool(profile.get("sandbox")),
                "default": hub_id == default,
                "managed": bool(profile.get("managed")),
                # Consent tier granted at connect: managed profiles reveal it in
                # their scope grant; a manual private-app token doesn't say.
                "access": (".write" in scope and "write") or (scope and "read") or "",
            }
        )
    return out


def update_connector_tools(
    secrets: SecretStore, name: str, enabled: dict[str, Any]
) -> dict[str, Any]:
    if get_descriptor(name) is None:
        return {"ok": False, "error": "unknown connector"}
    return patch_tool_settings(secrets, name, enabled)


def connect_connector(
    secrets: SecretStore,
    name: str,
    fields: dict[str, Any],
    *,
    validate: bool = True,
    acknowledged: bool = False,
) -> dict[str, Any]:
    d = get_descriptor(name)
    if d is None or not d.available:
        return {"ok": False, "error": "unknown or unavailable connector"}
    if d.experimental:
        if not experimental_enabled(secrets):
            return {"ok": False, "error": "experimental connectors are disabled"}
        if not acknowledged:
            return {
                "ok": False,
                "error": "risk acknowledgment required",
                "risk_notice": d.risk_notice,
            }

    # Reconnect-safe: never let a re-submit clobber a stored secret. The GUI masks a connected
    # connector's secret fields (it shows the placeholder, e.g. `xoxb-…`), so a blank — or
    # mask-equal — submission means "keep what's stored", not "overwrite with the mask". (This is
    # the bug that reset a real token down to its 6-char placeholder.)
    existing = secrets.get(f"{name}:default") or {}

    def _resolved(f) -> str:
        v = str(fields.get(f.key) or "").strip()
        if f.key == "allowed_users":
            return v  # a list in storage / CSV in the form — handled separately below
        if not v or (f.secret and v == (f.placeholder or "").strip()):
            return str(existing.get(f.key) or "").strip()
        return v

    raw = {f.key: _resolved(f) for f in d.fields}
    missing = [f.label for f in d.fields if f.required and not raw.get(f.key)]
    if missing:
        return {"ok": False, "error": "missing: " + ", ".join(missing)}

    allowed = sorted(
        {u.strip() for u in raw.get("allowed_users", "").split(",") if u.strip()}
    )
    if not allowed and existing.get("allowed_users"):
        allowed = list(
            existing["allowed_users"]
        )  # don't wipe the live allow-list on reconnect
    token_creds = {k: v for k, v in raw.items() if k != "allowed_users" and v}

    identity = None
    if validate and d.validate is not None:
        result = d.validate(token_creds)
        if not result.ok:
            return {"ok": False, "error": result.error or "validation failed"}
        identity = result.identity

    profile_type = (
        "oauth" if d.auth == "oauth" else "none" if d.auth == "none" else "token"
    )
    profile: dict[str, Any] = {"type": profile_type, "enabled": True, **token_creds}
    if any(f.key == "allowed_users" for f in d.fields):
        profile["allowed_users"] = allowed
    if name == "slack" and existing.get("approval_owner_ids"):
        # Re-pasting manual Socket Mode tokens must not erase the locally selected
        # approval owners.
        profile["approval_owner_ids"] = list(existing["approval_owner_ids"])
    if identity:
        profile["account"] = identity
    if d.account_field:
        # Account-patterned connector: connecting ADDS an account (a second
        # submit with different creds is a second account, not an overwrite).
        from . import accounts as _accounts

        account_id = _accounts.derive_account_id(d, profile)
        result = _accounts.add_account(secrets, name, account_id, profile)
        if not result.get("ok"):
            return result
        return {"ok": True, "account": identity or account_id, "account_id": account_id}
    secrets.put(f"{name}:default", profile)
    return {"ok": True, "account": identity}


def managed_connect_connector(
    secrets: SecretStore, name: str, profile: dict[str, Any]
) -> dict[str, Any]:
    """Store a profile produced by managed OAuth (cloud.managed_profile_from_callback).

    Field-compatible with a manual connect for the same connector, so tools and
    session gating can't tell the paths apart; preserves an existing allow-list
    on reconnect just like the manual path does.
    """
    d = get_descriptor(name)
    if d is None or not d.available:
        return {"ok": False, "error": "unknown or unavailable connector"}
    if not d.managed:
        return {"ok": False, "error": f"{name} does not support managed connect"}
    if d.account_field:
        from . import accounts as _accounts

        account_id = _accounts.derive_account_id(d, profile)
        result = _accounts.add_account(secrets, name, account_id, profile)
        if not result.get("ok"):
            return result
        return {
            "ok": True,
            "account": profile.get("account") or account_id,
            "account_id": account_id,
        }
    existing = secrets.get(f"{name}:default") or {}
    if existing.get("allowed_users"):
        profile = {**profile, "allowed_users": list(existing["allowed_users"])}
    secrets.put(f"{name}:default", profile)
    return {"ok": True, "account": profile.get("account") or None}


def managed_connect_slack_install(
    secrets: SecretStore, form: dict[str, Any]
) -> dict[str, Any]:
    """Store a managed Slack install (relay mode) from the broker's form-POST.

    Slack managed install is multi-workspace and inbound-via-relay, so unlike a
    single-token connector it writes:
    - `slack:team:<team_id>` — that workspace's bot token + bot_user_id (used for
      replies and to ignore the bot's own posts);
    - `slack:default` flipped to `mode="relay"` so the gateway builds the
      `SlackRelayAdapter` (Socket Mode's manual bot_token/app_token untouched if
      the user later switches back). Existing allow-list preserved.
    """
    team_id = form.get("team_id", "")
    bot_token = form.get("access_token", "")
    if not team_id or not bot_token:
        return {"ok": False, "error": "missing team_id or bot token"}
    # A reinstall replaces the token but must not reset authorization state.
    existing = secrets.get(f"slack:team:{team_id}") or {}
    allowed = set(existing.get("allowed_users") or [])
    installer = form.get("slack_user_id", "")
    if installer:
        # Pre-add the installer (UX-027): connecting the workspace is consent to
        # talk to your own bot — without this, the connector's very first mention
        # comes from the installer and parks.
        allowed.add(installer)
    secrets.put(
        f"slack:team:{team_id}",
        {
            "type": "oauth",
            "managed": True,
            "bot_token": bot_token,
            "bot_user_id": form.get("bot_user_id", ""),
            # The INSTALLER's Slack member id (authed_user) — who this workspace's
            # outbound posts speak for (attribution.py resolves + caches the name).
            "slack_user_id": installer,
            "team_id": team_id,
            "account": form.get("account", ""),
            # The workspace's slack.com subdomain (broker resolves it via auth.test)
            # — the unique human handle when two workspaces share a display name.
            "domain": form.get("team_domain", ""),
            "scope": form.get("scope", ""),
            "connection_id": form.get("connection_id", ""),
            "allowed_users": sorted(allowed),
            "allow_all": bool(existing.get("allow_all")),
            "sender_name": existing.get("sender_name", ""),
        },
    )
    default = secrets.get("slack:default") or {}
    default.update({"type": "oauth", "managed": True, "mode": "relay", "enabled": True})
    secrets.put("slack:default", default)
    return {"ok": True, "account": form.get("account") or team_id}


def disconnect_connector(secrets: SecretStore, name: str) -> dict[str, Any]:
    dropped_accounts = False
    from . import accounts as _accounts

    if _accounts.is_account_connector(name):
        for account_id, _profile in _accounts.list_accounts(secrets, name):
            dropped_accounts = (
                secrets.delete(_accounts.prefix(name) + account_id) or dropped_accounts
            )
    if name == "gmail":
        # Whole-connector disconnect drops every mailbox (per-account removal
        # lives on the Gmail page); filters go too — an explicit full reset.
        from . import gmail_accounts

        for email, _profile in gmail_accounts.list_accounts(secrets):
            dropped_accounts = (
                secrets.delete(gmail_accounts.PREFIX + email) or dropped_accounts
            )
    if name == "google_calendar":
        from . import gcal_accounts

        for email, _profile in gcal_accounts.list_accounts(secrets):
            dropped_accounts = (
                secrets.delete(gcal_accounts.PREFIX + email) or dropped_accounts
            )
    if name == "hubspot":
        from . import hubspot_portals

        for hub_id, _profile in hubspot_portals.list_portals(secrets):
            dropped_accounts = (
                secrets.delete(hubspot_portals.PREFIX + hub_id) or dropped_accounts
            )
    if name == "github":
        from . import github_installs

        for installation_id, _profile in github_installs.list_installs(secrets):
            dropped_accounts = (
                secrets.delete(github_installs.PREFIX + installation_id)
                or dropped_accounts
            )
    profile = secrets.get(f"{name}:default") or {}
    if profile.get("mode") == "mcp":
        # MCP-backed connect: forget the OAuth tokens + DCR registration and remove
        # the seeded server entry, so a reconnect runs a fresh flow.
        from ..mcp import config as mcp_config
        from ..mcp import oauth as mcp_oauth

        dropped_accounts = mcp_oauth.sign_out(name, secrets) or dropped_accounts
        mcp_config.delete_global_server(name)
    return {"ok": secrets.delete(f"{name}:default") or dropped_accounts}
