"""App-level settings API (admin only)."""

import logging
import os
from urllib.parse import urlparse

from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession

from notify_bridge_core.notifications.ssrf import UnsafeURLError, avalidate_outbound_url
from notify_bridge_core.release import ReleaseProviderKind, is_valid_repo

from ..auth.dependencies import get_current_user, require_admin
from ..auth.routes import limiter  # shared SlowAPI instance (app.state.limiter)
from ..database.engine import get_session
from ..database.models import AppSetting, TelegramBot, User

_LOGGER = logging.getLogger(__name__)

router = APIRouter(prefix="/api/settings", tags=["settings"])

# Keys exposed to the frontend with their env-var fallbacks
_SETTING_KEYS = {
    "external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
    "telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
    "telegram_cache_ttl_hours": None,  # URL cache TTL; 0 disables TTL
    "telegram_asset_cache_max_entries": None,  # LRU cap for both caches
    "supported_locales": None,  # comma-separated locale codes
    "timezone": "NOTIFY_BRIDGE_TIMEZONE",  # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
    # Logging — applied live via apply_log_levels() when changed.
    "log_level": "NOTIFY_BRIDGE_LOG_LEVEL",  # DEBUG/INFO/WARNING/ERROR
    "log_format": "NOTIFY_BRIDGE_LOG_FORMAT",  # text|json (requires restart to switch)
    "log_levels": "NOTIFY_BRIDGE_LOG_LEVELS",  # module=LEVEL,module2=LEVEL
    # Release-check — see services/release_check.py for the cached-state keys.
    "release_provider_kind": "NOTIFY_BRIDGE_RELEASE_PROVIDER",  # disabled|gitea|github
    "release_provider_url": "NOTIFY_BRIDGE_RELEASE_PROVIDER_URL",
    "release_provider_repo": "NOTIFY_BRIDGE_RELEASE_PROVIDER_REPO",
    "release_include_prereleases": None,  # "0"|"1"
    "release_check_interval_hours": None,  # 1..168
}

_DEFAULTS = {
    "external_url": "",
    "telegram_webhook_secret": "",
    # 720h = 30d. URL cache only; asset cache uses thumbhash validation
    # (content-addressable) and ignores TTL entirely.
    "telegram_cache_ttl_hours": "720",
    "telegram_asset_cache_max_entries": "5000",
    "supported_locales": "en,ru",
    "timezone": "UTC",
    "log_level": "INFO",
    "log_format": "text",
    "log_levels": "",
    # Pre-seed Gitea release checks against this repo's own upstream so a fresh
    # install knows where to look without operator intervention.
    "release_provider_kind": "gitea",
    "release_provider_url": "https://git.dolgolyov-family.by",
    "release_provider_repo": "alexei.dolgolyov/notify-bridge",
    "release_include_prereleases": "0",
    "release_check_interval_hours": "12",
}

# Settings whose changes require dropping in-memory Telegram caches so the
# next dispatch rebuilds them with the new parameters. Files are preserved.
_CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_entries"}

# Settings that change logging behaviour. ``log_level`` + ``log_levels`` apply
# live via apply_log_levels(); ``log_format`` requires a restart because
# changing it means swapping the handler formatter entirely.
_LOG_SETTING_KEYS = {"log_level", "log_levels", "log_format"}

# Release-check settings whose change must trigger cache invalidation (so a
# stale "latest version" doesn't linger after pointing at a new repo) and a
# scheduler re-arm so the new interval/provider takes effect immediately.
_RELEASE_PROVIDER_KEYS = {
    "release_provider_kind",
    "release_provider_url",
    "release_provider_repo",
    "release_include_prereleases",
}
_RELEASE_INTERVAL_KEY = "release_check_interval_hours"


async def get_setting(session: AsyncSession, key: str) -> str:
    """Read a setting from DB, falling back to env var then default."""
    row = await session.get(AppSetting, key)
    if row and row.value:
        return row.value
    env_key = _SETTING_KEYS.get(key)
    if env_key:
        env_val = os.environ.get(env_key, "")
        if env_val:
            return env_val
    return _DEFAULTS.get(key, "")


class SettingsUpdate(BaseModel):
    # Numeric fields declared as int|str so clients can send either form.
    # Svelte's bind:value on <input type="number"> coerces to a JS number,
    # so the frontend sends ints for these; older/manual clients may send
    # strings. We normalize to str before persisting.
    external_url: str | None = None
    telegram_webhook_secret: str | None = None
    telegram_cache_ttl_hours: int | str | None = None
    telegram_asset_cache_max_entries: int | str | None = None
    supported_locales: str | None = None
    timezone: str | None = None
    log_level: str | None = None
    log_format: str | None = None
    log_levels: str | None = None
    release_provider_kind: str | None = None
    release_provider_url: str | None = None
    release_provider_repo: str | None = None
    release_include_prereleases: bool | int | str | None = None
    release_check_interval_hours: int | str | None = None


@router.get("")
async def get_settings(
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Return all app settings."""
    result = {}
    for key in _SETTING_KEYS:
        value = await get_setting(session, key)
        if key == "telegram_webhook_secret" and value:
            result[key] = f"***{value[-4:]}" if len(value) > 4 else "***"
        else:
            result[key] = value
    return result


@router.put("")
async def update_settings(
    body: SettingsUpdate,
    user: User = Depends(require_admin),
    session: AsyncSession = Depends(get_session),
):
    """Update app settings (admin). Re-registers webhooks when base URL changes."""
    old_base_url = await get_setting(session, "external_url")
    old_secret = await get_setting(session, "telegram_webhook_secret")
    old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
    old_timezone = await get_setting(session, "timezone")
    old_log_values = {k: await get_setting(session, k) for k in _LOG_SETTING_KEYS}
    old_release_values = {k: await get_setting(session, k) for k in _RELEASE_PROVIDER_KEYS}
    old_release_interval = await get_setting(session, _RELEASE_INTERVAL_KEY)

    for key in _SETTING_KEYS:
        value = getattr(body, key, None)
        if value is None:
            continue
        # Normalise per-key before storing so the cache keys always hold the
        # canonical wire format ("0"/"1" for bool flags, clamped int for the
        # release interval). Without this, str(True) would leak "True" into the
        # release_include_prereleases cell and silently disable filtering.
        if key == "release_include_prereleases":
            if isinstance(value, bool):
                value_str = "1" if value else "0"
            else:
                value_str = "1" if str(value).strip().lower() in ("1", "true", "yes", "on") else "0"
        elif key == "release_check_interval_hours":
            from ..services.release_check import parse_interval_hours
            value_str = str(parse_interval_hours(str(value)))
        elif key == "release_provider_kind":
            # Reject anything outside the enum so a typo doesn't leave the DB
            # in a state the service can't interpret.
            value_str = str(value).strip().lower()
            try:
                value_str = ReleaseProviderKind(value_str).value
            except ValueError as err:
                raise HTTPException(
                    status_code=400,
                    detail=f"Invalid release_provider_kind: {value_str!r}",
                ) from err
        elif key == "release_provider_url":
            value_str = str(value).strip()
            if value_str:
                # Reject embedded userinfo (http://user:pass@host) so the
                # GET /settings response can never echo credentials back, and
                # block private/loopback/metadata targets via the SSRF guard.
                parsed = urlparse(value_str)
                if parsed.username or parsed.password:
                    raise HTTPException(
                        status_code=400,
                        detail="release_provider_url must not contain credentials",
                    )
                try:
                    await avalidate_outbound_url(value_str)
                except UnsafeURLError as err:
                    raise HTTPException(
                        status_code=400,
                        detail=f"Invalid release_provider_url: {err}",
                    ) from err
        elif key == "release_provider_repo":
            value_str = str(value).strip()
            if value_str and not is_valid_repo(value_str):
                raise HTTPException(
                    status_code=400,
                    detail="release_provider_repo must match 'owner/name' "
                           "(alphanumerics, dot, dash, underscore only)",
                )
        else:
            value_str = str(value)
        # GET masks the webhook secret as "***<last4>" so the real value is
        # never exposed to the frontend. If the client sends the mask back
        # (which happens on every save, since bind:value holds whatever GET
        # returned), treat it as "unchanged" — otherwise we'd overwrite the
        # real secret with its mask, silently breaking webhook HMAC.
        if key == "telegram_webhook_secret" and value_str.startswith("***"):
            continue
        row = await session.get(AppSetting, key)
        if row:
            row.value = value_str
        else:
            row = AppSetting(key=key, value=value_str)
        session.add(row)
    await session.commit()

    # Drop in-memory caches if any cache-tuning setting actually changed, so
    # the next dispatch rebuilds them with the new parameters. Files survive.
    cache_changed = False
    for key in _CACHE_SETTING_KEYS:
        if await get_setting(session, key) != old_cache_values[key]:
            cache_changed = True
            break
    if cache_changed:
        from ..services.watcher import reset_telegram_caches_in_memory
        await reset_telegram_caches_in_memory()

    new_base_url = await get_setting(session, "external_url")
    new_secret = await get_setting(session, "telegram_webhook_secret")
    new_timezone = await get_setting(session, "timezone")
    new_log_values = {k: await get_setting(session, k) for k in _LOG_SETTING_KEYS}

    # Apply live log-level changes (log_format still needs a restart).
    if (new_log_values["log_level"] != old_log_values["log_level"]
            or new_log_values["log_levels"] != old_log_values["log_levels"]):
        from ..logging_setup import apply_log_levels
        apply_log_levels(
            level=new_log_values["log_level"] or None,
            per_module_levels=new_log_values["log_levels"],
        )
        _LOGGER.info(
            "Log levels updated: root=%s overrides=%r",
            new_log_values["log_level"], new_log_values["log_levels"],
        )
    if new_log_values["log_format"] != old_log_values["log_format"]:
        _LOGGER.warning(
            "log_format changed from %r to %r — restart the server for it to take effect",
            old_log_values["log_format"], new_log_values["log_format"],
        )

    # Cron triggers freeze their timezone at construction time, so a tz change
    # has no effect until the jobs are rebuilt — do that here, before we
    # return success, so the UI reflects the actual schedule immediately.
    if new_timezone != old_timezone:
        from ..services.scheduler import reschedule_cron_jobs_for_timezone_change
        await reschedule_cron_jobs_for_timezone_change()

    # Update webhook secret in the webhook handler module
    if new_secret != old_secret:
        from ..commands.webhook import set_webhook_secret
        set_webhook_secret(new_secret or None)

    # Re-register webhooks for all bots in webhook mode if URL or secret changed
    if new_base_url and (new_base_url != old_base_url or new_secret != old_secret):
        await _reregister_webhooks(session, new_base_url, new_secret)

    # Release-check: clear stale cache when the provider repo/url/kind changes,
    # and re-arm the periodic job whenever the interval or provider changes.
    new_release_values = {k: await get_setting(session, k) for k in _RELEASE_PROVIDER_KEYS}
    new_release_interval = await get_setting(session, _RELEASE_INTERVAL_KEY)
    release_provider_changed = new_release_values != old_release_values
    release_interval_changed = new_release_interval != old_release_interval
    if release_provider_changed:
        from datetime import datetime, timezone

        from notify_bridge_core.release import ReleaseErrorCode

        from ..services.release_check import persist_release_state
        await persist_release_state(
            checked_at=datetime.now(timezone.utc).isoformat(),
            error=ReleaseErrorCode.PROVIDER_CHANGED.value,
            info=None,
        )
    if release_provider_changed or release_interval_changed:
        from ..services.scheduler import reschedule_release_check
        await reschedule_release_check()

    result = {}
    for key in _SETTING_KEYS:
        result[key] = await get_setting(session, key)
    return result


@router.get("/telegram-cache/stats")
async def telegram_cache_stats(
    user: User = Depends(require_admin),
):
    """Return counts and sizes for the Telegram file_id caches."""
    from ..services.watcher import get_telegram_cache_stats
    return await get_telegram_cache_stats()


@router.post("/telegram-cache/clear")
async def clear_telegram_cache(
    user: User = Depends(require_admin),
):
    """Clear the Telegram file_id cache (URL and asset) from disk and memory."""
    from ..services.watcher import clear_telegram_caches
    result = await clear_telegram_caches()
    return result


@router.get("/locales")
async def get_supported_locales(
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_session),
):
    """Return list of supported template locales (available to all users)."""
    raw = await get_setting(session, "supported_locales")
    locales = [loc.strip() for loc in raw.split(",") if loc.strip()]
    return locales or ["en"]


@router.get("/external-url")
async def get_external_url(
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_session),
):
    """Return the configured external base URL (available to all users).

    Used by the UI to render absolute provider webhook URLs. Returns empty
    string when unset so the UI falls back to the relative path.
    """
    return {"external_url": (await get_setting(session, "external_url")).rstrip("/")}


def _status_payload(status, *, is_admin: bool) -> dict:
    """Serialise a :class:`ReleaseStatus` for the API.

    Non-admin payloads strip the upstream release body (an XSS landmine —
    arbitrary attacker-controlled markdown should never reach a non-admin
    UI unless we explicitly sanitise it for display) and replace the raw
    error string with a coarse ``error`` / ``ok`` marker so internal
    hostnames from probe failures can't leak via the badge.
    """
    payload = {
        "provider": status.provider,
        "current": status.current,
        "latest": status.latest,
        "latest_tag": status.latest_tag,
        "latest_url": status.latest_url,
        "latest_name": status.latest_name,
        "latest_published_at": status.latest_published_at,
        "latest_prerelease": status.latest_prerelease,
        "checked_at": status.checked_at,
        "update_available": status.update_available,
    }
    if is_admin:
        payload["latest_body"] = status.latest_body
        payload["error"] = status.error
    else:
        payload["latest_body"] = None
        payload["error"] = None if not status.error else "error"
    return payload


@router.get("/release")
async def get_release_status(
    user: User = Depends(get_current_user),
):
    """Return the cached upstream release status (no network call).

    Available to all authenticated users so the sidebar badge can render for
    everyone — admins manage the configuration but the awareness is global.
    """
    from ..services.release_check import load_status
    return _status_payload(await load_status(), is_admin=(user.role == "admin"))


@router.post("/release/check")
@limiter.limit("6/minute")
async def force_release_check(
    request: Request,
    user: User = Depends(require_admin),
):
    """Force an immediate upstream check and return the refreshed status."""
    from ..services.release_check import run_check
    status = await run_check(force=True)
    return _status_payload(status, is_admin=True)


class ReleaseTestRequest(BaseModel):
    provider_kind: str
    provider_url: str | None = None
    provider_repo: str | None = None
    include_prereleases: bool | None = False


@router.post("/release/test")
@limiter.limit("12/minute")
async def test_release_provider(
    request: Request,
    body: ReleaseTestRequest,
    user: User = Depends(require_admin),
):
    """Dry-run an arbitrary provider config — used by the cassette's Test button.

    Validates the provider URL on the spot (SSRF + userinfo) so the operator
    sees an actionable error before any outbound request fires.
    """
    from notify_bridge_core.release import ReleaseErrorCode, build_release_provider

    from ..services.http_session import get_http_session

    test_url = (body.provider_url or "").strip()
    test_repo = (body.provider_repo or "").strip()

    if test_repo and not is_valid_repo(test_repo):
        return {"ok": False, "info": None, "error": ReleaseErrorCode.MISCONFIGURED.value}
    if test_url:
        parsed = urlparse(test_url)
        if parsed.username or parsed.password:
            return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
        try:
            await avalidate_outbound_url(test_url)
        except UnsafeURLError:
            return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}

    http = await get_http_session()
    provider = build_release_provider(
        body.provider_kind,
        session=http,
        url=test_url,
        repo=test_repo,
    )
    if provider is None:
        return {"ok": False, "info": None, "error": ReleaseErrorCode.MISCONFIGURED.value}
    result = await provider.test()
    info = result.get("info")
    info_dict = None
    if info is not None:
        info_dict = {
            "tag": info.tag,
            "version": info.version,
            "name": info.name,
            "url": info.url,
            "published_at": info.published_at,
            "prerelease": info.prerelease,
        }
    return {"ok": result["ok"], "info": info_dict, "error": result.get("error")}


async def _reregister_webhooks(
    session: AsyncSession, base_url: str, secret: str
) -> None:
    """Re-register webhooks for all bots in webhook mode."""
    from ..commands.webhook import register_webhook

    result = await session.exec(
        select(TelegramBot).where(TelegramBot.update_mode == "webhook")
    )
    bots = result.all()
    for bot in bots:
        webhook_url = f"{base_url.rstrip('/')}/api/telegram/webhook/{bot.webhook_path_id}"
        res = await register_webhook(bot.token, webhook_url, secret or None)
        if res.get("success"):
            _LOGGER.info("Re-registered webhook for bot %d (%s)", bot.id, bot.name)
        else:
            # Webhook re-register failure means the bot silently stops
            # delivering updates — this is operational visibility for an
            # admin, ERROR is appropriate.
            _LOGGER.error(
                "Failed to re-register webhook for bot %d (%s): %s",
                bot.id, bot.name, res.get("error"),
            )
