"""
Protection for every public endpoint that writes to the database.

It lives in one module so a form cannot be hardened in one place and left
open in another. The contact form ran for months with a hardcoded
score = 1.0 while the page loaded a reCAPTCHA that was never executed and
never checked, and nobody could see that from either file alone.

Four layers, cheapest first:

  1  honeypot    a field a person never sees and a bot fills in
  2  time trap   a signed timestamp, anything submitted too fast is a script
  3  rate limit  per IP, cache backed
  4  reCAPTCHA   the score Google returns, actually read this time

Layers 1 to 3 need no keys and no outbound call, so they keep working when
reCAPTCHA is unconfigured, blocked in the visitor's country, or down. Layer 4
is the only one that can fail open, and it only does so when no secret is set.
"""

import logging

import requests
from django.conf import settings
from django.core import signing
from django.core.cache import cache

log = logging.getLogger(__name__)

HONEYPOT_FIELD = 'website'
STAMP_FIELD = 'ts'
MIN_SECONDS = 3
STAMP_MAX_AGE = 60 * 60 * 6


def client_ip(request):
    """The visitor's address, not Cloudflare's.

    Every one of these sites sits behind Cloudflare, so REMOTE_ADDR is a
    Cloudflare edge address and is the same for thousands of unrelated
    visitors. Rate limiting on it would throttle real people and never
    throttle one attacker.
    """
    cf = request.META.get('HTTP_CF_CONNECTING_IP', '').strip()
    if cf:
        return cf
    fwd = request.META.get('HTTP_X_FORWARDED_FOR', '')
    if fwd:
        return fwd.split(',')[0].strip()
    return request.META.get('REMOTE_ADDR', '') or ''


def form_stamp():
    """A signed marker of when the form was served.

    Signed with SECRET_KEY, so a bot cannot post a plausible looking older
    timestamp to walk past the time trap.
    """
    import time
    return signing.dumps(int(time.time()))


def _stamp_age(value):
    """Seconds since the form was served, or None when it cannot be trusted."""
    if not value:
        return None
    import time
    try:
        issued = signing.loads(value, max_age=STAMP_MAX_AGE)
        return max(0, int(time.time()) - int(issued))
    except signing.BadSignature:
        return -1
    except Exception:
        return None


def looks_automated(data, min_seconds=MIN_SECONDS):
    """True when the submission is a bot, with the reason.

    A missing timestamp is not treated as automated, so a cached page or a
    template that has not been updated yet still accepts real people.
    A forged one is, because there is no innocent way to produce it.
    """
    if (data.get(HONEYPOT_FIELD) or '').strip():
        return True, 'honeypot'

    age = _stamp_age(data.get(STAMP_FIELD))
    if age is None:
        return False, ''
    if age < 0:
        return True, 'forged timestamp'
    if age < min_seconds:
        return True, f'submitted in {age}s'
    return False, ''


def rate_limited(request, bucket, limit=5, window=3600):
    """True once this address has passed `limit` submissions inside `window`.

    Counts submissions that got as far as being saved, not page views. The
    cache is Redis here, so the counter is shared across gunicorn workers.
    Cache trouble never blocks a genuine visitor.
    """
    ip = client_ip(request)
    if not ip:
        return False
    key = f'antispam:{bucket}:{ip}'
    try:
        hits = cache.get_or_set(key, 0, window)
        hits = cache.incr(key)
    except ValueError:
        try:
            cache.set(key, 1, window)
            hits = 1
        except Exception:
            return False
    except Exception:
        return False
    return hits > limit


def verify_recaptcha(token, action=None):
    """(passed, score) from Google, using the key held in Settings.

    Returns (True, None) when no secret is configured, which is what keeps a
    site with blank credentials usable. The score is None rather than 1.0 in
    that case, so a stored score of None reads as "never checked" instead of
    pretending to be a perfect result.
    """
    from apps.pages.api_keys import get_recaptcha_secret_key

    secret = (get_recaptcha_secret_key() or '').strip()
    if not secret:
        return True, None
    if not token:
        return False, 0.0
    try:
        reply = requests.post(
            'https://www.google.com/recaptcha/api/siteverify',
            data={'secret': secret, 'response': token},
            timeout=5,
        ).json()
    except Exception:
        log.warning('reCAPTCHA could not be reached, falling back to the other checks')
        return True, None

    if not reply.get('success'):
        return False, 0.0
    score = reply.get('score')
    if score is None:
        return True, None
    threshold = getattr(settings, 'RECAPTCHA_MINIMUM_SCORE', 0.5)
    if action and reply.get('action') and reply['action'] != action:
        return False, float(score)
    return float(score) >= threshold, float(score)
