"""
Middleware for YOUR COMPANY CMS.

TenantMiddleware  — detects the active tenant from the Host header.
PanelSubdomainMiddleware — transparently mounts the CMS at / on the panel subdomain
                           so the /cms/ prefix never appears in the browser URL.
RateLimitMiddleware — blocks IPs that submit forms too frequently.
"""
import logging
import time
from collections import defaultdict
from threading import Lock
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs

from django.http import Http404, HttpResponse

log = logging.getLogger(__name__)


# ── Rate Limiter ──────────────────────────────────────────────────────────────
# POST submissions per IP: max 10 per 60 seconds on form endpoints
_rate_data: dict = defaultdict(list)
_rate_lock = Lock()

# Matched as prefixes, not exact strings. The old set was compared with
# `request.path in _RATE_LIMITED_PATHS`, so '/contact/assessment/' matched
# neither '/contact/' nor '/assessment/' and the assessment form, which fires
# an OpenAI call on every submission, had no limit at all.
_RATE_LIMITED_PATHS = (
    '/contact/',          # covers /contact/ and /contact/assessment/
    '/assessment/',
    '/chat/',             # covers /chat/message/, /chat/start/, /chat/identify/
    '/auth/login',
    '/cms/login',
    '/login',             # the panel mounts the CMS at / on panel subdomains
)

# Anything that costs money per request is held to a tighter limit than a
# plain form post.
_EXPENSIVE_PATHS = (
    '/contact/assessment/',
    '/chat/message/',
)

_MAX_REQUESTS = 10
_MAX_EXPENSIVE = 4
_WINDOW_SECONDS = 60


class RateLimitMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.method == 'POST' and request.path.startswith(_RATE_LIMITED_PATHS):
            ip = (
                request.META.get('HTTP_CF_CONNECTING_IP')
                or request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip()
                or request.META.get('REMOTE_ADDR', '')
            )
            now = time.time()
            ceiling = (_MAX_EXPENSIVE if request.path.startswith(_EXPENSIVE_PATHS)
                       else _MAX_REQUESTS)
            # Bucket per path group as well as per IP, so hammering the chat
            # cannot exhaust someone's allowance for the contact form.
            key = f"{ip}|{'ai' if ceiling == _MAX_EXPENSIVE else 'form'}"
            with _rate_lock:
                timestamps = _rate_data[key]
                # Drop old entries outside the window
                _rate_data[key] = [t for t in timestamps if now - t < _WINDOW_SECONDS]
                if len(_rate_data[key]) >= ceiling:
                    return HttpResponse(
                        'Too many requests. Please wait a moment and try again.',
                        status=429,
                        content_type='text/plain',
                    )
                _rate_data[key].append(now)
        return self.get_response(request)


from django.http import Http404
from django.conf import settings
from .utils import get_tenant_from_host
from .managers import set_current_tenant, clear_current_tenant


def invalidate_panel_host_cache():
    """Call after creating/editing a Tenant to flush the 60-second panel-host cache."""
    from django.core.cache import cache
    cache.delete('ft:panel_hosts')


def _is_panel_host(host):
    """
    Return True if `host` is any active tenant's cms_subdomain.
    Results are cached for 60 s to avoid a DB hit on every request.

    A failed or empty lookup is never cached. Caching one used to take the
    whole panel down for a full minute: with no known panel hosts the
    middleware stops prepending /cms, every CMS path is then looked up
    against the public URLConf, and Django answers 404. That produced
    intermittent "Not Found" pages on working URLs and, on /, the public
    homepage appearing in place of the panel.
    """
    # Static fallback first, so the panel never depends on the database being
    # reachable at that instant. Any ALLOWED_HOSTS entry that looks like a
    # panel subdomain counts, plus anything listed in PANEL_HOSTS. A database
    # blip used to make the panel forget it was the panel, and every CMS URL
    # answered 404 until the cache expired.
    from django.conf import settings as _s
    static_hosts = {h.lower() for h in getattr(_s, 'PANEL_HOSTS', []) if h}
    static_hosts |= {
        h.lower() for h in getattr(_s, 'ALLOWED_HOSTS', [])
        if isinstance(h, str) and h.lower().startswith('panel.')
    }
    if host in static_hosts:
        return True

    from django.core.cache import cache
    cache_key = 'ft:panel_hosts'
    panel_hosts = cache.get(cache_key)
    if panel_hosts is None:
        try:
            from .models import Tenant
            panel_hosts = set(
                Tenant.objects.filter(is_active=True)
                .exclude(cms_subdomain='')
                .values_list('cms_subdomain', flat=True)
            )
        except Exception:
            # One bad request, not sixty seconds of them. Log it: this was
            # invisible for a long time precisely because it was swallowed.
            log.exception('Panel host lookup failed for %s', host)
            return False
        if panel_hosts:
            cache.set(cache_key, panel_hosts, 60)
    return host in panel_hosts


def _resolves(path):
    """True if this path matches a URL pattern."""
    from django.urls import resolve, Resolver404
    try:
        resolve(path)
    except Resolver404:
        return False
    except Exception:
        # An import error in the URLConf lands here and would otherwise look
        # exactly like a missing route, turning a broken module into a silent
        # 404. Always leave a trace.
        log.exception('URL resolution failed for %s', path)
        return False
    return True


class PanelSubdomainMiddleware:
    """
    When a request arrives on any registered panel subdomain (e.g. panel.example.com,
    panel.mostafaei.com, panel.fabclub.com …):
      - Prepend /cms to the path so Django routes it to CMS views.
      - Strip /cms from Location redirect headers so the browser never sees /cms/.
      - Rewrite href/action="/cms/..." links in HTML responses to "/...".
    Panel hosts are resolved from the Tenant table (cms_subdomain field), cached 60 s.
    """
    CMS_PREFIX = '/cms'

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        host = request.get_host().split(':')[0].lower()
        is_panel = _is_panel_host(host)

        if is_panel:
            path = request.path_info
            # Ask the URL resolver whether a CMS route exists for this path
            # rather than maintaining a list of exceptions. If /cms/<path>
            # resolves, mount it there. If not, leave it as a public route.
            if (not path.startswith(self.CMS_PREFIX)
                    and not path.startswith('/static/')
                    and not path.startswith('/media/')
                    and not path.startswith('/stream/')):
                candidate = self.CMS_PREFIX + path
                if _resolves(candidate):
                    request.path_info = candidate
                    request.path = candidate

        response = self.get_response(request)

        if is_panel:
            # Prevent Cloudflare (and any proxy) from caching CMS panel responses
            response['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
            response['Pragma'] = 'no-cache'

            # Strip /cms from redirect Location headers (path + ?next= param)
            if response.has_header('Location'):
                loc = response['Location']
                # Strip /cms from path
                if loc == self.CMS_PREFIX or loc.startswith(self.CMS_PREFIX + '/'):
                    loc = loc[len(self.CMS_PREFIX):] or '/'
                # Strip /cms from ?next= query parameter; drop ?next=/ entirely
                parsed = urlparse(loc)
                if parsed.query:
                    qs = parse_qs(parsed.query, keep_blank_values=True)
                    if 'next' in qs:
                        qs['next'] = [
                            (v[len(self.CMS_PREFIX):] or '/')
                            if (v == self.CMS_PREFIX or v.startswith(self.CMS_PREFIX + '/'))
                            else v
                            for v in qs['next']
                        ]
                        # Drop ?next=/ — it's redundant (login redirects to / by default)
                        if qs['next'] == ['/']:
                            del qs['next']
                        new_query = urlencode(qs, doseq=True)
                        loc = urlunparse(parsed._replace(query=new_query))
                response['Location'] = loc

            # Rewrite /cms/ references in HTML so internal links stay clean
            ct = response.get('Content-Type', '')
            if 'text/html' in ct and hasattr(response, 'content'):
                try:
                    content = response.content.decode('utf-8')
                    content = content.replace('href="/cms/', 'href="/')
                    content = content.replace('action="/cms/', 'action="/')
                    content = content.replace("href='/cms/", "href='/")
                    content = content.replace("action='/cms/", "action='/")
                    # Hide /login/ from the browser URL bar — always show /
                    # Fix form actions first so POST still goes to /login/
                    url_cleaner = (
                        '<script>'
                        'document.addEventListener("DOMContentLoaded",function(){'
                        # Exactly /login/, never /login/2fa/. Rewriting the
                        # form action on the 2FA page posts the OTP to the
                        # login view, which then reports invalid credentials.
                        'if(location.pathname === "/login" || location.pathname === "/login/"){'
                        'document.querySelectorAll("form").forEach(function(f){f.action="/login/";});'
                        'history.replaceState(null,"","/");'
                        '}'
                        '});'
                        '</script>'
                    )
                    if '<head>' in content:
                        content = content.replace('<head>', '<head>' + url_cleaner, 1)
                    else:
                        content = content.replace('</body>', url_cleaner + '</body>', 1)
                    response.content = content.encode('utf-8')
                    # Recalculate Content-Length after rewrite
                    if response.has_header('Content-Length'):
                        response['Content-Length'] = len(response.content)
                except Exception:
                    pass

        return response


# Hosts that bypass tenant detection (local dev, health checks, etc.)
BYPASS_HOSTS = {
    'localhost',
    '127.0.0.1',
    'testserver',
    '0.0.0.0',
}


class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        host = request.get_host().split(':')[0].lower()

        # Always bypass for local dev and Django admin
        if host in BYPASS_HOSTS or settings.DEBUG:
            tenant = _get_master_tenant()
            request.tenant = tenant
            set_current_tenant(tenant)
            try:
                response = self.get_response(request)
            finally:
                clear_current_tenant()
            return response

        tenant = get_tenant_from_host(host)

        if tenant is None:
            # Unknown domain — refuse to serve, do not leak data
            raise Http404('Site not found.')

        request.tenant = tenant
        set_current_tenant(tenant)
        try:
            response = self.get_response(request)
        finally:
            clear_current_tenant()
        return response


def _get_master_tenant():
    """Return the tenant this install serves, or a placeholder before setup."""
    from .models import Tenant
    tenant = Tenant.objects.filter(is_master=True).first()
    if tenant:
        return tenant
    # Return a lightweight placeholder so dev works before DB is seeded
    return _DevTenant()


class _DevTenant:
    """Lightweight placeholder tenant for development before DB is seeded."""
    pk = 0
    name = 'Unconfigured install'
    slug = 'unconfigured'
    primary_domain = 'localhost'
    cms_subdomain = 'localhost'
    accent_colour   = '#e8b400'
    cms_dark_colour = '#0d0d0d'
    cms_text_colour = '#b8d4ea'
    is_active = True
    is_master = True
    chat_enabled = False
    whatsapp_enabled = False
    chat_greeting = 'Hi! How can I help you today?'
    chat_system_prompt = ''
    chat_whatsapp = ''
    whatsapp_link = ''
    logo = None
    favicon = None
    plan = 'master'

    def has_module(self, module_key):
        return True  # Dev tenant has everything

    @property
    def enabled_module_keys(self):
        from .models import MODULE_CHOICES
        return {k for k, _ in MODULE_CHOICES}
