"""Per-tenant feature toggles, and the email copy that goes with one of them.

Storage is one row per key in the tenant's strategist_settings table, under a
row lock so two concurrent saves merge instead of one silently overwriting
the other. Reads fall back to the older strategist_summaries rows for
tenants the migration (scripts/migrate_settings.py) hasn't reached yet.

Only some switches have a tool behind them (see FEATURE_TOOLS); the rest are
stored so the dashboard works now. Provider settings (PROVIDER_OPTIONS/
PROVIDER_DEFAULTS) pick one option instead of on/off, under a "providers"
key -- chat_conversation and send_email are wired into real behavior;
ticket_management and product_recommendation are stored only, for now.
"""
import json
import logging
from functools import partial

from app.services.infra.database import get_latest_summary, get_setting, update_setting
from app.services.integrations.google_calendar import get_google_calendar_connection
from app.services.integrations.mailchimp import get_mailchimp_connection
from app.services.integrations.teams import get_teams_connection

logger = logging.getLogger(__name__)

SETTINGS_KEY = "tool_settings"
LEGACY_SETTINGS_KEY = "email_settings"

# The switches the dashboard renders. The original eight default on to
# preserve pre-existing behaviour; chat_conversation is new and unwired, so
# it starts off, and its channel (see PROVIDER_OPTIONS) can't be chosen
# until it is switched on.
FEATURE_DEFAULTS = {
    "send_email": True,
    "voice_mode": True,
    "book_meeting": True,
    "order_tracking": True,
    "attach_documents": True,
    "ticket_management": True,
    "crm_integration": True,
    "product_recommendation": True,
    "chat_conversation": False,
}

# Which switches actually gate a tool the chat model can call. A feature absent
# from this map is stored and returned, but switching it off changes nothing in
# the chat yet -- its backend does not exist.
FEATURE_TOOLS = {
    "send_email": "send_email",
    "ticket_management": "create_or_update_ticket",
    "attach_documents": "send_document",
    "product_recommendation": "recommend_products",
    "book_meeting": "book_meeting",
}

# Settings that pick one option from a list rather than switch on/off. Adding a
# name here only makes it selectable in the dashboard -- see the module
# docstring. Growing a tuple (e.g. a new ticketing backend) needs no other
# change; adding a new setting name needs an entry in both dicts below.
PROVIDER_OPTIONS = {
    "chat_conversation": ("whatsapp", "slack", "galaxiq_chat"),
    "ticket_management": ("galaxiq_ticketmanagement",),
    "send_email": ("smtp", "mailchimp"),
    "product_recommendation": ("shopify",),
    "book_meeting": ("google_calendar", "teams"),
}

# None means "nothing chosen yet". ticket_management and send_email default
# to their already-live backend ("galaxiq_ticketmanagement", "smtp") so a
# fresh tenant isn't shown as switched to something it never chose.
# product_recommendation stays unset: recommend_products already draws from
# every connected source, not just Shopify.
PROVIDER_DEFAULTS = {
    "chat_conversation": None,
    "ticket_management": "galaxiq_ticketmanagement",
    "send_email": "smtp",
    "product_recommendation": None,
    "book_meeting": None,
}

# A provider that requires its feature switched on first. chat_conversation
# is gated because a channel would look configured while the switch does
# nothing. ticket_management and send_email are absent: their backends are
# either already live or gated on an external fact instead (see the
# mailchimp-specific check in save_tool_settings).
PROVIDER_REQUIRES_FEATURE = {
    "chat_conversation": "chat_conversation",
}

# The three categories the "Business Emails" dashboard screen offers. Fixed,
# not tenant-configurable -- adding a fourth means editing this tuple and the
# dashboard's dropdown together, not a per-tenant setting.
TICKET_CATEGORIES = ("General enquiries", "Sales", "Business")

# Duplicated from email.py's EMAIL_RE to avoid an import cycle (email.py
# already imports from this module).
import re as _re
_TICKET_EMAIL_RE = _re.compile(r"^[^@\s]+@[^@\s]+\.[a-zA-Z]{2,}$")

EMAIL_COPY_DEFAULTS = {
    "header": "Thanks for chatting with {{brandName}}",
    "footer": "© {{currentYear}} {{brandName}}. All rights reserved.",
    "aiDisclaimer": (
        "Responses are powered by AI and may contain errors or omissions. "
        "Please verify important information before relying on it."
    ),
}


def normalise_tenant_id(tenant_id: str) -> str:
    """Accept a tenant id with or without the org_ prefix.

    Schemas are named org_<uuid>. Callers sometimes pass the bare uuid, and
    without this a bootstrap would silently create a second, empty schema whose
    settings the chat would never read.
    """
    tenant_id = (tenant_id or "").strip()
    if tenant_id and not tenant_id.startswith("org_"):
        return f"org_{tenant_id}"
    return tenant_id


def _defaults() -> dict:
    return {"features": dict(FEATURE_DEFAULTS), "email": dict(EMAIL_COPY_DEFAULTS),
            "providers": dict(PROVIDER_DEFAULTS), "ticket_emails": []}


def read_legacy_blob(tenant_id: str):
    """The tenant's settings as they were stored in strategist_summaries.

    Used as a read fallback for tenants the migration has not reached, and by
    the migration itself as its source. Prefers the newer 'tool_settings' row
    over the older flat 'email_settings' one.
    """
    for key in (SETTINGS_KEY, LEGACY_SETTINGS_KEY):
        try:
            # Returns None rather than a pair when the tenant has no schema.
            row = get_latest_summary(tenant_id, key)
            stored = row[0] if row else None
        except Exception as ex:
            logger.error(f"Could not read {key} for {tenant_id}: {ex}")
            return None
        if not stored:
            continue
        try:
            saved = json.loads(stored)
        except (json.JSONDecodeError, TypeError):
            logger.error(f"Stored {key} for {tenant_id} is not valid JSON; ignoring it.")
            continue
        if isinstance(saved, dict):
            return saved
    return None


def _read_stored(tenant_id: str):
    """The tenant's saved settings: the settings table first, then the old rows."""
    try:
        stored = get_setting(tenant_id, SETTINGS_KEY)
    except Exception as ex:
        logger.error(f"Could not read settings for {tenant_id}: {ex}")
        stored = None
    if isinstance(stored, dict) and stored:
        return stored
    return read_legacy_blob(tenant_id)


def apply_over_defaults(saved) -> dict:
    """Normalise a stored blob of either shape into the full settings dict."""
    settings = _defaults()
    if not saved:
        return settings

    # Current shape: {"features": {...}, "email": {...}, "providers": {...}}
    for name, value in (saved.get("features") or {}).items():
        if name in FEATURE_DEFAULTS:
            settings["features"][name] = bool(value)
    for name, value in (saved.get("email") or {}).items():
        if name in EMAIL_COPY_DEFAULTS and value:
            settings["email"][name] = value
    for name, value in (saved.get("providers") or {}).items():
        # A stored value outside the current option list (an option since
        # removed) is dropped rather than surfaced, same as an unknown feature
        # or email key above -- the default takes over silently.
        if name in PROVIDER_OPTIONS and (value is None or value in PROVIDER_OPTIONS[name]):
            settings["providers"][name] = value

    stored_ticket_emails = saved.get("ticket_emails")
    if isinstance(stored_ticket_emails, list):
        settings["ticket_emails"] = stored_ticket_emails

    # Legacy flat shape: {"enabled": bool, "header": ..., "footer": ..., ...}
    # 'enabled' only ever meant emailing, so that is where it lands.
    if "enabled" in saved:
        settings["features"]["send_email"] = bool(saved["enabled"])
    for name in EMAIL_COPY_DEFAULTS:
        if saved.get(name):
            settings["email"][name] = saved[name]

    return settings


def get_tool_settings(tenant_id: str) -> dict:
    """Every switch plus the email copy, merged over defaults. Never raises."""
    return apply_over_defaults(_read_stored(normalise_tenant_id(tenant_id)))


def _merge_settings(tenant_id: str, features: dict, email: dict, providers: dict,
                    ticket_emails: list, current) -> dict:
    """Apply an update on top of whatever is already stored.

    Runs inside update_setting's row lock, so `current` is the value nobody else
    can be writing at the same moment. An empty `current` means this tenant has
    not been migrated yet, so the old summaries blob is the starting point rather
    than bare defaults -- otherwise the first save after deployment would wipe
    their settings.
    """
    base = apply_over_defaults(current or read_legacy_blob(tenant_id))
    for name, value in (features or {}).items():
        base["features"][name] = bool(value)
    for name, value in (email or {}).items():
        # Blank means "use the default" rather than an empty header.
        base["email"][name] = (value or "").strip() or EMAIL_COPY_DEFAULTS[name]
    for name, value in (providers or {}).items():
        # None clears the choice rather than being rejected -- a tenant must
        # be able to switch a channel back to unset.
        base["providers"][name] = value
    # Full replace, not merge -- matches the "add rows locally, Save once"
    # dashboard flow; there is no per-entry identity to merge against anyway.
    if ticket_emails is not None:
        base["ticket_emails"] = ticket_emails
    return base


def save_tool_settings(tenant_id: str, features: dict = None, email: dict = None,
                       providers: dict = None, ticket_emails: list = None) -> dict:
    """Merge the supplied switches, copy, provider choices and business emails, and persist."""
    tenant_id = normalise_tenant_id(tenant_id)

    # An unselected dashboard <select> submits "", not null -- normalise it to
    # None so it means the same "nothing chosen" that null already means.
    if providers:
        providers = {name: (value.strip() or None) if isinstance(value, str) else value
                    for name, value in providers.items()}

    unknown = set(features or {}) - set(FEATURE_DEFAULTS)
    if unknown:
        raise ValueError(f"Unknown feature(s): {', '.join(sorted(unknown))}")
    unknown = set(email or {}) - set(EMAIL_COPY_DEFAULTS)
    if unknown:
        raise ValueError(f"Unknown email setting(s): {', '.join(sorted(unknown))}")
    unknown = set(providers or {}) - set(PROVIDER_OPTIONS)
    if unknown:
        raise ValueError(f"Unknown provider setting(s): {', '.join(sorted(unknown))}")
    for name, value in (providers or {}).items():
        if value is not None and value not in PROVIDER_OPTIONS[name]:
            raise ValueError(
                f"{name} must be one of {PROVIDER_OPTIONS[name]} or null, got {value!r}")

    if ticket_emails is not None:
        for entry in ticket_emails:
            if not isinstance(entry, dict) or "email" not in entry or "category" not in entry:
                raise ValueError(
                    "Each ticket_emails entry needs both email and category.")
            if not _TICKET_EMAIL_RE.match((entry.get("email") or "").strip()):
                raise ValueError(f"{entry.get('email')!r} is not a valid email address.")
            if entry["category"] not in TICKET_CATEGORIES:
                raise ValueError(
                    f"category must be one of {TICKET_CATEGORIES}, got {entry['category']!r}")

    # A gated provider needs its feature already on, or being switched on in
    # this same call -- checked against effective state so an earlier save
    # that already enabled it doesn't need to be resent.
    needing_gate = [name for name, value in (providers or {}).items()
                    if value is not None and name in PROVIDER_REQUIRES_FEATURE]
    if needing_gate:
        effective_features = dict(get_tool_settings(tenant_id)["features"])
        effective_features.update(features or {})
        for name in needing_gate:
            gate = PROVIDER_REQUIRES_FEATURE[name]
            if not effective_features.get(gate):
                raise ValueError(
                    f"{name} cannot be set while {gate} is off; enable it first "
                    f"or in the same request")

    # send_email's "mailchimp" option is gated on a real connection existing --
    # there is nothing to select if the tenant never connected Mailchimp in
    # Integrations, and a stale selection must not survive a disconnect.
    if (providers or {}).get("send_email") == "mailchimp":
        if not get_mailchimp_connection(tenant_id):
            raise ValueError(
                "Connect Mailchimp under Integrations before selecting it as your "
                "email provider.")

    if (providers or {}).get("book_meeting") == "google_calendar":
        if not get_google_calendar_connection(tenant_id):
            raise ValueError(
                "Connect Google Calendar under Integrations before selecting it "
                "as your meeting provider.")

    if (providers or {}).get("book_meeting") == "teams":
        if not get_teams_connection(tenant_id):
            raise ValueError(
                "Connect Microsoft Teams under Integrations before selecting it "
                "as your meeting provider.")

    merged = update_setting(
        tenant_id, SETTINGS_KEY,
        partial(_merge_settings, tenant_id, features, email, providers, ticket_emails))
    off = sorted(n for n, on in merged["features"].items() if not on)
    logger.info(f"Saved tool settings for {tenant_id}; off: {off or 'none'}")
    return merged


def disabled_tools_for(tenant_id: str) -> set:
    """Tool names this tenant has switched off.

    The chat loop strips these from the tool list AND from the system prompt, so
    a disabled capability is invisible to the model rather than merely refused.
    """
    features = get_tool_settings(tenant_id)["features"]
    return {tool for feature, tool in FEATURE_TOOLS.items() if not features.get(feature, True)}


# --- Email-specific view over the same settings ------------------------------
# The email pipeline cares about one switch and three strings, so it gets them
# in the flat shape it already uses rather than reaching into the nested one.

def get_email_settings(tenant_id: str) -> dict:
    """{'enabled', 'header', 'footer', 'aiDisclaimer'} for the email pipeline."""
    settings = get_tool_settings(tenant_id)
    return {"enabled": settings["features"]["send_email"], **settings["email"]}


def render_placeholders(text: str, brand_name: str, current_year: str) -> str:
    """Fill the {{brandName}} / {{currentYear}} placeholders the settings UI offers."""
    return (text or "").replace("{{brandName}}", brand_name).replace(
        "{{currentYear}}", current_year)
