"""
Site audit: measure a site, score it, and hand back something a person can read.

Everything here is a real measurement. Nothing is inferred, estimated or
written by a model. Where a thing cannot be checked from outside, the section
says so rather than guessing, because a report that flatters is worse than no
report.

Scope note. Every tenant runs its own database, so content and SEO can only be
inspected on the install this code is running in. For other tenants the audit
is external: HTTP, TLS, headers, plus whatever the site reports at check-in.
"""
import logging
import socket
import ssl
from datetime import datetime, timedelta
from urllib.parse import urlparse

from django.conf import settings
from django.db.models import Avg, Count, Q
from django.utils import timezone

log = logging.getLogger(__name__)

TIMEOUT = 12


# ── scoring ──────────────────────────────────────────────────────────────────

def _grade(score):
    """A credit-report style letter, so the headline is readable at a glance."""
    if score >= 90:
        return 'A', 'Excellent'
    if score >= 80:
        return 'B', 'Good'
    if score >= 65:
        return 'C', 'Fair'
    if score >= 50:
        return 'D', 'Needs attention'
    return 'E', 'Action required'


def _finding(severity, area, detail, action=''):
    return {'severity': severity, 'area': area, 'detail': detail, 'action': action}


# ── individual checks ────────────────────────────────────────────────────────

def _check_availability(tenant):
    """Uptime and incidents over the last 30 days, from our own probes."""
    from apps.ftlink.models import Incident, UptimeSample

    since = timezone.now() - timedelta(days=30)
    agg = UptimeSample.objects.filter(tenant=tenant, checked_at__gte=since).aggregate(
        total=Count('id'), up=Count('id', filter=Q(ok=True)), lat=Avg('latency_ms'),
    )
    total = agg['total'] or 0
    findings = []

    if not total:
        return {
            'name': 'Availability',
            'score': None,
            'measured': False,
            'note': 'No monitoring data yet. This site has not been probed, so uptime cannot be stated.',
            'rows': [('Checks in last 30 days', '0')],
            'findings': [_finding('info', 'Availability',
                                  'The site is not being monitored.',
                                  'Add it to the check-in schedule so uptime can be measured.')],
            'series': [],
        }

    pct = round(agg['up'] / total * 100, 2)
    latency = int(agg['lat']) if agg['lat'] else None
    incidents = Incident.objects.filter(tenant=tenant, started_at__gte=since)
    incident_count = incidents.count()

    score = 100
    if pct < 99.9:
        score -= 10
    if pct < 99:
        score -= 25
    if pct < 95:
        score -= 30
    score -= min(incident_count * 5, 20)
    score = max(score, 0)

    if pct < 99.5:
        findings.append(_finding(
            'high' if pct < 99 else 'medium', 'Availability',
            f'Uptime measured at {pct}% over 30 days, below the 99.9% we aim for.',
            'Review the incident history below for the recurring cause.'))
    if incident_count:
        findings.append(_finding(
            'medium', 'Availability',
            f'{incident_count} incident{"s" if incident_count != 1 else ""} recorded in the last 30 days.',
            'Each incident carries a diagnosis in the panel.'))
    if total < 100:
        findings.append(_finding(
            'info', 'Availability',
            f'Only {total} checks recorded, so this figure is indicative rather than firm.',
            'It becomes reliable after a few hundred checks.'))

    # daily series for the chart
    series = []
    for day in range(29, -1, -1):
        start = (timezone.now() - timedelta(days=day)).replace(hour=0, minute=0, second=0, microsecond=0)
        end = start + timedelta(days=1)
        d = UptimeSample.objects.filter(tenant=tenant, checked_at__gte=start, checked_at__lt=end).aggregate(
            t=Count('id'), u=Count('id', filter=Q(ok=True)))
        if d['t']:
            series.append((start.strftime('%d %b'), round(d['u'] / d['t'] * 100, 2)))

    return {
        'name': 'Availability',
        'score': score,
        'measured': True,
        'note': f'Measured from {total} checks over the last 30 days.',
        'rows': [
            ('Uptime, 30 days', f'{pct}%'),
            ('Checks recorded', str(total)),
            ('Average response', f'{latency} ms' if latency else 'not recorded'),
            ('Incidents, 30 days', str(incident_count)),
        ],
        'findings': findings,
        'series': series,
    }


def _check_delivery(url):
    """Does the site answer, how fast, and does it insist on HTTPS."""
    import requests

    findings, rows = [], []
    score = 100
    try:
        started = timezone.now()
        r = requests.get(url, timeout=TIMEOUT, allow_redirects=True)
        elapsed = int((timezone.now() - started).total_seconds() * 1000)
        rows.append(('Status code', str(r.status_code)))
        rows.append(('Response time', f'{elapsed} ms'))
        rows.append(('Final address', r.url))

        if r.status_code >= 500:
            score -= 60
            findings.append(_finding('high', 'Delivery',
                                     f'The site returned {r.status_code}, a server error.',
                                     'This needs immediate attention.'))
        elif r.status_code >= 400:
            score -= 40
            findings.append(_finding('high', 'Delivery',
                                     f'The site returned {r.status_code}.',
                                     'Check the address and the routing.'))
        if elapsed > 2000:
            score -= 20
            findings.append(_finding('medium', 'Delivery',
                                     f'The homepage took {elapsed} ms to respond.',
                                     'Anything over two seconds costs visitors and search ranking.'))
        elif elapsed > 1000:
            score -= 10
        if not r.url.startswith('https://'):
            score -= 30
            findings.append(_finding('high', 'Delivery',
                                     'The site did not end on an HTTPS address.',
                                     'Force HTTPS for every request.'))
        return {'name': 'Delivery', 'score': max(score, 0), 'measured': True,
                'note': 'Fetched live at the time of this report.',
                'rows': rows, 'findings': findings, 'headers': dict(r.headers),
                'html': r.text[:400000] if r.status_code < 400 else ''}
    except Exception as exc:
        return {'name': 'Delivery', 'score': 0, 'measured': True,
                'note': 'The site could not be reached.',
                'rows': [('Result', 'unreachable')],
                'findings': [_finding('high', 'Delivery',
                                      f'The site could not be reached: {exc}',
                                      'Check DNS, the server and the firewall.')],
                'headers': {}, 'html': ''}


def _check_certificate(url):
    """TLS certificate validity and how long is left on it."""
    host = urlparse(url).hostname
    findings, rows = [], []
    if not host:
        return {'name': 'Certificate', 'score': None, 'measured': False,
                'note': 'No hostname to check.', 'rows': [], 'findings': []}
    try:
        ctx = ssl.create_default_context()
        with socket.create_connection((host, 443), timeout=TIMEOUT) as sock:
            with ctx.wrap_socket(sock, server_hostname=host) as ssock:
                cert = ssock.getpeercert()
        not_after = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
        days = (not_after - datetime.utcnow()).days
        issuer = dict(x[0] for x in cert['issuer']).get('organizationName', 'unknown')
        rows = [('Issued by', issuer),
                ('Expires', not_after.strftime('%d %B %Y')),
                ('Days remaining', str(days))]
        score = 100
        if days < 0:
            score = 0
            findings.append(_finding('high', 'Certificate',
                                     'The certificate has expired.',
                                     'Renew it now: visitors are seeing a security warning.'))
        elif days < 14:
            score = 50
            findings.append(_finding('high', 'Certificate',
                                     f'The certificate expires in {days} days.',
                                     'Confirm automatic renewal is working.'))
        elif days < 30:
            score = 80
            findings.append(_finding('medium', 'Certificate',
                                     f'The certificate expires in {days} days.',
                                     'Renewal should happen automatically; worth confirming.'))
        return {'name': 'Certificate', 'score': score, 'measured': True,
                'note': 'Read directly from the live TLS handshake.',
                'rows': rows, 'findings': findings}
    except Exception as exc:
        return {'name': 'Certificate', 'score': 0, 'measured': True,
                'note': 'The certificate could not be read.',
                'rows': [('Result', 'unavailable')],
                'findings': [_finding('high', 'Certificate',
                                      f'The certificate could not be checked: {exc}',
                                      'Confirm HTTPS is configured and the certificate is valid.')]}


SECURITY_HEADERS = [
    ('Strict-Transport-Security', 'high',
     'Tells browsers to use HTTPS only, which prevents downgrade attacks.'),
    ('X-Content-Type-Options', 'medium',
     'Stops browsers guessing file types, a common route to script injection.'),
    ('X-Frame-Options', 'medium',
     'Prevents the site being framed by another, which is how clickjacking works.'),
    ('Referrer-Policy', 'low',
     'Controls how much address information leaks to other sites.'),
    ('Content-Security-Policy', 'low',
     'Restricts what a page may load. Powerful, and needs care to configure.'),
]


def _check_headers(headers):
    """Security headers present on the live response."""
    findings, rows = [], []
    present = 0
    for name, severity, why in SECURITY_HEADERS:
        got = headers.get(name) or headers.get(name.lower())
        rows.append((name, 'present' if got else 'missing'))
        if got:
            present += 1
        else:
            findings.append(_finding(severity, 'Security headers',
                                     f'{name} is not set. {why}',
                                     f'Add the {name} header at the web server or in Django.'))
    score = int(present / len(SECURITY_HEADERS) * 100)
    return {'name': 'Security headers', 'score': score, 'measured': bool(headers),
            'note': f'{present} of {len(SECURITY_HEADERS)} recommended headers present.',
            'rows': rows, 'findings': findings}


def _check_platform(tenant):
    """Versions this install reports, against the current release."""
    latest = getattr(settings, 'CMS_VERSION', '')
    reported = getattr(tenant, 'reported_version', '') or ''
    findings = []
    rows = [
        ('FT OS', f'v{reported}' if reported else 'not reported'),
        ('Latest release', f'v{latest}' if latest else 'unknown'),
        ('Server OS', getattr(tenant, 'os_label', '') or getattr(tenant, 'os_name', '') or 'not reported'),
        ('Python', getattr(tenant, 'python_version', '') or 'not reported'),
        ('Django', getattr(tenant, 'django_version', '') or 'not reported'),
        ('Database', (getattr(tenant, 'db_engine', '') or 'not reported').upper()),
    ]
    score = 100
    if not reported:
        score = None
        findings.append(_finding('info', 'Platform',
                                 'This site has not reported its versions.',
                                 'Check the check-in schedule is running.'))
    elif reported != latest:
        same_year = reported[:2] == latest[:2]
        score = 80 if same_year else 55
        findings.append(_finding(
            'medium' if same_year else 'high', 'Platform',
            f'Running v{reported} while the current release is v{latest}.',
            'Plan an upgrade. Later releases carry security and stability fixes.'))
    return {'name': 'Platform', 'score': score, 'measured': bool(reported),
            'note': 'Reported by the site at its last check-in.',
            'rows': rows, 'findings': findings}


# ── assembly ─────────────────────────────────────────────────────────────────

def _check_domains(tenant):
    """
    Every domain this site answers on, and whether each one is actually wired up.

    A client often buys several domains and assumes they all reach the site.
    They frequently do not: the extra ones sit unconnected for months. This
    checks each, and reports whether Cloudflare is in front of it, since an
    unproxied domain is exposed directly to the origin.
    """
    import requests

    names = [(getattr(tenant, 'primary_domain', '') or '').strip()]
    for extra in (getattr(tenant, 'extra_domains', '') or '').splitlines():
        extra = extra.strip()
        if extra and extra not in names:
            names.append(extra)
    names = [n for n in names if n]

    if not names:
        return {'name': 'Domains', 'score': None, 'measured': False,
                'note': 'No domains recorded for this site.', 'rows': [], 'findings': []}

    rows, findings = [], []
    connected, behind_cf = 0, 0
    for name in names:
        try:
            r = requests.get(f'https://{name}', timeout=TIMEOUT, allow_redirects=True)
            hdrs = {k.lower(): v for k, v in r.headers.items()}
            cf = 'cf-ray' in hdrs or 'cloudflare' in (hdrs.get('server', '') or '').lower()
            ok = r.status_code < 400
            if ok:
                connected += 1
            if cf:
                behind_cf += 1
            state = 'Connected' if ok else f'Answers {r.status_code}'
            state += ', via Cloudflare' if cf else ', direct to origin'
            rows.append((name, state))
            if not ok:
                findings.append(_finding(
                    'medium', 'Domains',
                    f'{name} is registered against this site but answers {r.status_code}.',
                    'Point its DNS at the site, or remove it so it stops being advertised.'))
            elif not cf:
                findings.append(_finding(
                    'low', 'Domains',
                    f'{name} reaches the site without Cloudflare in front of it.',
                    'Proxying it hides the server address and absorbs attack traffic.'))
        except Exception as exc:
            rows.append((name, 'Not reachable'))
            findings.append(_finding(
                'medium', 'Domains',
                f'{name} could not be reached: {exc}',
                'Either connect it properly or drop it from the record.'))

    total = len(names)
    score = int(connected / total * 100) if total else None
    if score is not None and behind_cf < connected:
        score = max(score - 10, 0)

    # Say the Cloudflare position in one sentence at the top of the section,
    # rather than leaving it to be worked out from a table of rows.
    if behind_cf == 0:
        cf_line = ('No domain on this site is behind Cloudflare. Every request reaches the '
                   'server directly, so the server address is public and there is nothing '
                   'absorbing an attack.')
        findings.append(_finding(
            'medium', 'Domains',
            'Cloudflare is not in front of this site.',
            'Proxying through Cloudflare hides the origin address, filters bad traffic and '
            'takes load off the server.'))
    elif behind_cf < total:
        cf_line = (f'{behind_cf} of {total} domains are behind Cloudflare. The rest reach the '
                   f'server directly, which defeats the point of protecting the others.')
    else:
        cf_line = 'Every domain on this site is behind Cloudflare.'

    rows.insert(0, ('Domains recorded', str(total)))
    rows.insert(1, ('Answering', f'{connected} of {total}'))
    rows.insert(2, ('Behind Cloudflare', f'{behind_cf} of {total}'))
    return {'name': 'Domains', 'score': score, 'measured': True,
            'note': 'Each domain fetched live at the time of this report. ' + cf_line,
            'rows': rows, 'findings': findings}


def _check_mobile(url, html=''):
    """
    Whether the site is built for a phone.

    Not a rendering test, which needs a browser. It checks the things that are
    visible in the markup and that reliably break a phone when missing.
    """
    import re as _re
    if not html:
        return {'name': 'Mobile', 'score': None, 'measured': False,
                'note': 'The homepage could not be read, so mobile could not be assessed.',
                'rows': [], 'findings': []}

    rows, findings = [], []
    score = 100

    viewport = _re.search(r'<meta[^>]+name=["\']viewport["\'][^>]*>', html, _re.I)
    has_dw = bool(viewport and 'width=device-width' in viewport.group(0).lower())
    rows.append(('Viewport tag', 'Present' if viewport else 'Missing'))
    if not viewport:
        score -= 50
        findings.append(_finding(
            'high', 'Mobile',
            'The homepage has no viewport tag, so phones render it at desktop width and zoom out.',
            'Add a responsive viewport meta tag. Without it nothing else about mobile matters.'))
    elif not has_dw:
        score -= 25
        findings.append(_finding(
            'medium', 'Mobile',
            'The viewport tag does not set width=device-width.',
            'Set it, or the layout will not follow the screen size.'))

    if viewport and 'user-scalable=no' in viewport.group(0).lower():
        score -= 10
        findings.append(_finding(
            'low', 'Mobile',
            'Pinch zoom is disabled on this site.',
            'Blocking zoom fails accessibility guidance and frustrates older visitors.'))

    media = len(_re.findall(r'@media[^{]*\(', html, _re.I))
    rows.append(('Media queries in the page', str(media)))

    fixed = _re.findall(r'width\s*:\s*(\d{3,})px', html, _re.I)
    wide = [w for w in fixed if int(w) > 480]
    rows.append(('Fixed widths over 480px', str(len(wide))))
    if len(wide) > 3:
        score -= 15
        findings.append(_finding(
            'medium', 'Mobile',
            f'{len(wide)} fixed pixel widths wider than a phone screen were found in the page.',
            'These are the usual cause of sideways scrolling on a phone.'))

    return {'name': 'Mobile', 'score': max(score, 0), 'measured': True,
            'note': 'Read from the homepage markup, not from a rendered browser.',
            'rows': rows, 'findings': findings}


# ── what we can do about it ──────────────────────────────────────────────────

# Each rule reads the findings and offers the thing that actually fixes it.
# Written as help rather than a pitch: the client can act on every one of these
# themselves, and the offer is that we do it for them.
_RECOMMENDATIONS = [
    ('Mobile', ('Mobile optimisation',
                'Rebuild the templates so the site follows the screen instead of fighting it. '
                'Over half of visitors arrive on a phone, and a site that scrolls sideways '
                'loses most of them before the first scroll.')),
    ('Domains', ('Domain and edge setup',
                 'Connect every domain you own to the site and put Cloudflare in front of it. '
                 'Unconnected domains earn nothing, and an unproxied one exposes the server '
                 'address to anyone who looks.')),
    ('Availability', ('Managed monitoring and support',
                      'Continuous checks with alerting, so an outage reaches a person within '
                      'minutes rather than being discovered by a customer.')),
    ('Delivery', ('Performance tuning',
                  'Caching, image handling and server tuning. Every second of load time costs '
                  'conversions and search position.')),
    ('Certificate', ('Certificate management',
                     'Automatic renewal and expiry alerting, so a certificate never lapses '
                     'and takes the site down with it.')),
    ('Security headers', ('Security hardening',
                          'Add the missing response headers, tighten the origin and close the '
                          'gaps this report lists. A morning of work that removes a whole class '
                          'of attack.')),
    ('Platform', ('Platform upgrade',
                  'Move to the current release. Newer versions carry the security fixes and the '
                  'features added since, and upgrades get harder the longer they are left.')),
]


def _build_recommendations(findings, sections):
    """Turn findings into offers, worst area first, without inventing problems."""
    hit = {}
    for f in findings:
        if f['severity'] in ('high', 'medium'):
            hit.setdefault(f['area'], []).append(f)
    out = []
    for area, (title, blurb) in _RECOMMENDATIONS:
        if area in hit:
            out.append({
                'title': title,
                'why': f"{len(hit[area])} finding{'s' if len(hit[area]) != 1 else ''} in {area}.",
                'blurb': blurb,
            })
    # A site with nothing wrong still deserves an honest next step.
    if not out:
        low = [s for s in sections if s.get('score') is not None and s['score'] < 100]
        if low:
            out.append({
                'title': 'Ongoing care',
                'why': 'Nothing urgent was found.',
                'blurb': 'Everything measured here is in good order. The value now is keeping it '
                         'that way: monitoring, updates and a periodic audit like this one.',
            })
    return out


def run_audit(tenant):
    """Run every check and return one structure the report builder can render."""
    url = f"https://{getattr(tenant, 'primary_domain', '') or ''}".rstrip('/')

    availability = _check_availability(tenant)
    delivery = _check_delivery(url) if url != 'https://' else {
        'name': 'Delivery', 'score': None, 'measured': False,
        'note': 'No domain recorded for this site.', 'rows': [], 'findings': [],
        'headers': {}, 'html': ''}
    certificate = _check_certificate(url) if url != 'https://' else {
        'name': 'Certificate', 'score': None, 'measured': False,
        'note': 'No domain recorded.', 'rows': [], 'findings': []}
    headers = _check_headers(delivery.get('headers', {}))
    platform = _check_platform(tenant)
    domains = _check_domains(tenant)
    mobile = _check_mobile(url, delivery.get('html', ''))

    sections = [availability, delivery, mobile, certificate, headers, domains, platform]

    scored = [s['score'] for s in sections if s.get('score') is not None]
    overall = int(sum(scored) / len(scored)) if scored else 0

    # A plain average lets one catastrophic area hide behind five good ones.
    # Security headers at 0 with everything else at 100 averaged to 80 and
    # printed "Good", which is not true of a site with no security headers.
    # The worst area now caps the grade, and the cap is stated in the report.
    cap_reason = ''
    if scored:
        worst = min(scored)
        worst_name = next(s['name'] for s in sections
                          if s.get('score') is not None and s['score'] == worst)
        if worst == 0:
            overall = min(overall, 45)
            cap_reason = f'{worst_name} scored zero, so the overall grade is held down regardless of the other areas.'
        elif worst < 40:
            overall = min(overall, 60)
            cap_reason = f'{worst_name} scored {worst}, so the overall grade is capped.'
        elif worst < 70:
            overall = min(overall, 79)
            cap_reason = f'{worst_name} scored {worst}, which limits the overall grade.'

    letter, label = _grade(overall)

    findings = []
    for s in sections:
        findings.extend(s.get('findings', []))
    order = {'high': 0, 'medium': 1, 'low': 2, 'info': 3}
    findings.sort(key=lambda f: order.get(f['severity'], 9))

    # Monitoring may be switched off, in which case the history below stops at
    # the last check rather than at today. Say so, or the report reads as if it
    # describes the site as it is right now.
    monitored = bool(getattr(tenant, 'monitor_enabled', True))
    # A tenant row is not a connection. Without a check-in the panel knows
    # nothing about the software behind the domain, and the report must say so
    # rather than implying it inspected the site.
    linked = bool(getattr(tenant, 'last_checkin_at', None)) or bool(
        getattr(tenant, 'reported_version', '') or '')
    last_check = None
    try:
        from apps.ftlink.models import UptimeSample
        _last = UptimeSample.objects.filter(tenant=tenant).order_by('-checked_at').first()
        last_check = timezone.localtime(_last.checked_at) if _last else None
    except Exception:
        last_check = None

    return {
        'tenant': tenant,
        'site': getattr(tenant, 'primary_domain', '') or getattr(tenant, 'name', ''),
        'client': getattr(tenant, 'name', ''),
        'generated_at': timezone.localtime(),
        'monitored': monitored,
        'last_check': last_check,
        'linked': linked,
        'monochrome': not monitored,
        'cap_reason': cap_reason,
        'overall': overall,
        'grade': letter,
        'grade_label': label,
        'sections': sections,
        'findings': findings,
        'uptime_series': availability.get('series', []),
        'recommendations': _build_recommendations(findings, sections),
    }
