"""
Fetch nginx ingress logs — from the Kubernetes API (production) or a local file (dev/testing).
Deduplicates by request_id so overlapping windows don't double-insert.
"""
import ipaddress
import logging
from datetime import timedelta

from django.db import transaction
from django.utils import timezone as dj_tz

from .models import NginxSettings, NginxAccessLog
from .parser import parse_lines
from .geo import enrich_geo_batch

logger = logging.getLogger(__name__)

_OVERLAP = 60  # extra seconds to avoid missing entries near boundaries


def fetch_and_store() -> int:
    """
    Pull new log lines, parse, deduplicate, and save.
    Returns the number of new rows inserted.
    """
    settings = NginxSettings.get()
    if not settings.enabled:
        return 0

    raw = _read_file(settings) if settings.log_file_path else _k8s_logs(settings)
    if raw is None:
        return 0

    entries = parse_lines(raw)
    if not entries:
        _touch(settings)
        return 0

    excluder = _build_excluder(settings.excluded_ips)
    since_seconds = settings.fetch_interval_seconds + _OVERLAP
    existing_ids = set(
        NginxAccessLog.objects.filter(
            timestamp__gte=dj_tz.now() - timedelta(seconds=since_seconds + 10),
        ).values_list('request_id', flat=True)
    )

    new_logs, seen = [], set()
    for e in entries:
        if excluder(e['remote_addr']):
            continue
        key = e['request_id'] or (
            f"{e['timestamp'].isoformat()}|{e['remote_addr']}|{e['request_uri']}"
        )
        if key in existing_ids or key in seen:
            continue
        seen.add(key)
        new_logs.append(NginxAccessLog(
            timestamp=e['timestamp'],
            remote_addr=e['remote_addr'],
            method=e['method'],
            request_uri=e['request_uri'],
            protocol=e['protocol'],
            status=e['status'],
            body_bytes_sent=e['body_bytes_sent'],
            http_referer=e['http_referer'],
            http_user_agent=e['http_user_agent'],
            request_length=e['request_length'],
            request_time=e['request_time'],
            service=e['service'],
            upstream_addr=e['upstream_addr'],
            upstream_response_time=e['upstream_response_time'],
            upstream_status=e['upstream_status'],
            request_id=e['request_id'],
        ))

    if new_logs:
        with transaction.atomic():
            NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True)
        logger.info('nginxmon: inserted %d new log entries', len(new_logs))
        enrich_geo_batch(new_logs)
        if settings.auto_ban_enabled:
            auto_ban_auth_scanners(settings)
            auto_ban_php_scanners(settings)
            auto_ban_404_flood(settings)

    _touch(settings)
    return len(new_logs)


def ingest_raw(text: str) -> int:
    """
    Parse and store log lines from a raw string (used by the paste-logs UI
    and the management command). Returns the number of new rows inserted.
    """
    entries = parse_lines(text)
    if not entries:
        return 0

    settings = NginxSettings.get()
    excluder = _build_excluder(settings.excluded_ips)

    existing_ids = set(
        NginxAccessLog.objects.values_list('request_id', flat=True)
    )
    new_logs, seen = [], set()
    for e in entries:
        if excluder(e['remote_addr']):
            continue
        key = e['request_id'] or (
            f"{e['timestamp'].isoformat()}|{e['remote_addr']}|{e['request_uri']}"
        )
        if key in existing_ids or key in seen:
            continue
        seen.add(key)
        new_logs.append(NginxAccessLog(
            timestamp=e['timestamp'],
            remote_addr=e['remote_addr'],
            method=e['method'],
            request_uri=e['request_uri'],
            protocol=e['protocol'],
            status=e['status'],
            body_bytes_sent=e['body_bytes_sent'],
            http_referer=e['http_referer'],
            http_user_agent=e['http_user_agent'],
            request_length=e['request_length'],
            request_time=e['request_time'],
            service=e['service'],
            upstream_addr=e['upstream_addr'],
            upstream_response_time=e['upstream_response_time'],
            upstream_status=e['upstream_status'],
            request_id=e['request_id'],
        ))

    if new_logs:
        with transaction.atomic():
            NginxAccessLog.objects.bulk_create(new_logs, ignore_conflicts=True)
        logger.info('nginxmon: ingested %d log entries from raw text', len(new_logs))
        enrich_geo_batch(new_logs)
        if settings.auto_ban_enabled:
            auto_ban_auth_scanners(settings)
            auto_ban_php_scanners(settings)
            auto_ban_404_flood(settings)

    return len(new_logs)


# Auth-probe paths that signal someone trying to log in / brute-force OAuth
_AUTH_PROBE_PREFIXES = ('/oauth2/', '/authorize')


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _push_bans(settings: NginxSettings, candidate_ips: list[str],
               ban_source: str, make_reason, make_note,
               window_start) -> list[str]:
    """
    Common routine:
      1. Filter out excluded / private IPs.
      2. Load ConfigMap, find new offenders, write back.
      3. Create BannedIP records.
      4. Annotate matching log rows.
    Returns list of newly banned IPs.
    """
    from django.db.models import Q
    from .models import BannedIP

    excluder = _build_excluder(settings.excluded_ips)
    candidate_ips = [ip for ip in candidate_ips
                     if not excluder(ip) and not _is_private_ip(ip)]
    if not candidate_ips:
        return []

    try:
        from links.mini_apps_views import _read_blocked_ips, _write_blocked_ips
        blocked = _read_blocked_ips()
    except Exception as exc:
        logger.error('nginxmon auto_ban: could not read ConfigMap: %s', exc)
        return []

    already_in_db = set(
        BannedIP.objects.filter(ip__in=candidate_ips).values_list('ip', flat=True)
    )
    newly_banned = [ip for ip in candidate_ips
                    if ip not in blocked and ip not in already_in_db]
    if not newly_banned:
        return []

    # Push to ConfigMap
    try:
        _write_blocked_ips(blocked + newly_banned)
        logger.info('nginxmon auto_ban [%s]: banned %d IPs: %s',
                    ban_source, len(newly_banned), newly_banned)
    except Exception as exc:
        logger.error('nginxmon auto_ban: could not write ConfigMap: %s', exc)
        return []

    # Persist BannedIP records and annotate log rows
    for ip in newly_banned:
        reason = make_reason(ip, window_start)
        note = f'Auto banned by system. Reason: {reason}.'

        # Geo from cache
        from .models import IPGeoCache
        geo = IPGeoCache.objects.filter(ip=ip).first()
        BannedIP.objects.get_or_create(
            ip=ip,
            defaults=dict(
                ban_source=ban_source,
                reason=reason,
                request_count=NginxAccessLog.objects.filter(
                    remote_addr=ip, timestamp__gte=window_start).count(),
                country=geo.country if geo else '',
                city=geo.city if geo else '',
            ),
        )

        # Annotate all matching log rows for this IP in the window
        NginxAccessLog.objects.filter(
            remote_addr=ip, timestamp__gte=window_start,
        ).update(note=note)

    return newly_banned


# ---------------------------------------------------------------------------
# Detector: repeated auth-probe failures
# ---------------------------------------------------------------------------

def auto_ban_auth_scanners(settings: NginxSettings) -> list[str]:
    """
    Ban IPs with > threshold *failed* (4xx/5xx) requests to /oauth2/ or /authorize
    within the rolling window.
    """
    from django.db.models import Count, Q
    from datetime import timedelta

    window_start = dj_tz.now() - timedelta(hours=settings.auto_ban_window_hours)

    probe_filter = Q()
    for prefix in _AUTH_PROBE_PREFIXES:
        probe_filter |= Q(request_uri__startswith=prefix)

    candidates = list(
        NginxAccessLog.objects
        .filter(probe_filter, timestamp__gte=window_start, status__gte=400)
        .values('remote_addr')
        .annotate(cnt=Count('id'))
        .filter(cnt__gt=settings.auto_ban_threshold)
        .values_list('remote_addr', flat=True)
    )

    def make_reason(ip, ws):
        paths = list(
            NginxAccessLog.objects.filter(
                probe_filter, remote_addr=ip, timestamp__gte=ws, status__gte=400,
            ).values_list('request_uri', flat=True)[:20]
        )
        hit = sorted({p for prefix in _AUTH_PROBE_PREFIXES for p in paths if p.startswith(prefix)})
        return 'repeated failed auth-probe requests to: ' + (', '.join(hit) or ', '.join(_AUTH_PROBE_PREFIXES))

    return _push_bans(settings, candidates, 'auto_auth_probe', make_reason, None, window_start)


# ---------------------------------------------------------------------------
# Detector: PHP webshell / backdoor scanner
# ---------------------------------------------------------------------------

def auto_ban_php_scanners(settings: NginxSettings) -> list[str]:
    """
    Ban IPs probing random .php paths (classic webshell/backdoor scanners).
    Threshold reuses auto_ban_threshold (default 5). Any IP making > threshold
    requests to *.php paths (excluding the known auth endpoints) gets banned.
    """
    from django.db.models import Count, Q
    from datetime import timedelta

    window_start = dj_tz.now() - timedelta(hours=settings.auto_ban_window_hours)

    # Match paths ending in .php or containing .php? — but NOT auth endpoints
    auth_filter = Q()
    for prefix in _AUTH_PROBE_PREFIXES:
        auth_filter |= Q(request_uri__startswith=prefix)

    php_filter = Q(request_uri__iregex=r'\.php(\?|$|/)')

    candidates = list(
        NginxAccessLog.objects
        .filter(php_filter, timestamp__gte=window_start)
        .exclude(auth_filter)
        .values('remote_addr')
        .annotate(cnt=Count('id'))
        .filter(cnt__gt=settings.auto_ban_threshold)
        .values_list('remote_addr', flat=True)
    )

    def make_reason(ip, ws):
        sample = list(
            NginxAccessLog.objects.filter(
                php_filter, remote_addr=ip, timestamp__gte=ws,
            ).values_list('request_uri', flat=True)[:5]
        )
        cnt = NginxAccessLog.objects.filter(
            php_filter, remote_addr=ip, timestamp__gte=ws,
        ).count()
        return (f'PHP webshell/backdoor scanner — {cnt} probes to .php paths, '
                f'e.g.: {", ".join(sample[:3])}')

    return _push_bans(settings, candidates, 'auto_php_scan', make_reason, None, window_start)


# ---------------------------------------------------------------------------
# Detector: 404 flood (scraper / content scanner)
# ---------------------------------------------------------------------------

def auto_ban_404_flood(settings: NginxSettings) -> list[str]:
    """
    Ban IPs generating an abnormally high number of 404s (badge scrapers,
    content scanners, etc.). Threshold = 5 × auto_ban_threshold.
    """
    from django.db.models import Count
    from datetime import timedelta

    window_start = dj_tz.now() - timedelta(hours=settings.auto_ban_window_hours)
    threshold = settings.auto_ban_threshold * 5  # harsher: 25 by default

    candidates = list(
        NginxAccessLog.objects
        .filter(status=404, timestamp__gte=window_start)
        .values('remote_addr')
        .annotate(cnt=Count('id'))
        .filter(cnt__gt=threshold)
        .values_list('remote_addr', flat=True)
    )

    def make_reason(ip, ws):
        cnt = NginxAccessLog.objects.filter(
            remote_addr=ip, status=404, timestamp__gte=ws,
        ).count()
        sample = list(
            NginxAccessLog.objects.filter(
                remote_addr=ip, status=404, timestamp__gte=ws,
            ).values_list('request_uri', flat=True)[:3]
        )
        return (f'404 flood — {cnt} consecutive 404 responses, '
                f'e.g.: {", ".join(sample)}')

    return _push_bans(settings, candidates, 'auto_404_flood', make_reason, None, window_start)


def _k8s_logs(settings: NginxSettings) -> str | None:
    """Fetch pod logs via the Kubernetes Python client (works in-cluster and locally)."""
    since = settings.fetch_interval_seconds + _OVERLAP
    try:
        from kubernetes import client, config
        try:
            config.load_incluster_config()
        except config.ConfigException:
            config.load_kube_config()

        v1 = client.CoreV1Api()
        pods = v1.list_namespaced_pod(
            namespace=settings.namespace,
            label_selector=settings.pod_label,
        )
        if not pods.items:
            logger.warning('nginxmon: no pods found for selector %r in %r',
                           settings.pod_label, settings.namespace)
            return None

        lines = []
        for pod in pods.items:
            pod_name = pod.metadata.name
            try:
                log_text = v1.read_namespaced_pod_log(
                    name=pod_name,
                    namespace=settings.namespace,
                    container=settings.container,
                    since_seconds=since,
                )
                for line in log_text.splitlines():
                    if line.strip():
                        # Prepend "pod container" so the parser matches its expected format
                        lines.append(f'{pod_name} {settings.container} {line}')
            except Exception as exc:
                logger.warning('nginxmon: could not read logs from pod %s: %s', pod_name, exc)

        return '\n'.join(lines)

    except ImportError:
        logger.error('nginxmon: kubernetes package not installed — run: uv add kubernetes')
        return None
    except Exception as exc:
        logger.error('nginxmon: k8s logs error: %s', exc)
        return None


def _read_file(settings: NginxSettings) -> str | None:
    try:
        with open(settings.log_file_path, 'r', encoding='utf-8', errors='replace') as f:
            return f.read()
    except FileNotFoundError:
        logger.error('nginxmon: log file not found: %s', settings.log_file_path)
        return None
    except Exception as exc:
        logger.error('nginxmon: error reading log file: %s', exc)
        return None


def _touch(settings: NginxSettings):
    NginxSettings.objects.filter(pk=settings.pk).update(last_fetch_at=dj_tz.now())


def cleanup_old_logs(days: int = 7):
    from datetime import timedelta
    cutoff = dj_tz.now() - timedelta(days=days)
    deleted, _ = NginxAccessLog.objects.filter(timestamp__lt=cutoff).delete()
    if deleted:
        logger.info('nginxmon: pruned %d old log entries (>%d days)', deleted, days)


def _is_private_ip(ip: str) -> bool:
    """Return True for RFC-1918, loopback, link-local, and other private ranges."""
    try:
        return ipaddress.ip_address(ip).is_private
    except ValueError:
        return False


def _build_excluder(excluded_ips_text: str):
    """
    Build and return a callable(ip: str) -> bool that returns True when the
    given IP should be excluded from ingestion.

    Supported entry formats (one per line):
      192.168.1.218        exact IP
      192.168.1.x          wildcard — any IP whose first 3 octets match
      192.168.1.0/24       CIDR range
    """
    exact: set[str] = set()
    wildcards: list[str] = []      # prefixes like '192.168.1.'
    networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []

    for raw_line in (excluded_ips_text or '').splitlines():
        entry = raw_line.strip()
        if not entry or entry.startswith('#'):
            continue
        if entry.endswith('.x') or entry.endswith('.*'):
            # Wildcard: everything before .x/.* becomes a dotted prefix
            wildcards.append(entry[:-1])   # keep trailing '.'
        elif '/' in entry:
            try:
                networks.append(ipaddress.ip_network(entry, strict=False))
            except ValueError:
                logger.warning('nginxmon: invalid CIDR in excluded_ips: %r', entry)
        else:
            exact.add(entry)

    def _is_excluded(ip: str) -> bool:
        if ip in exact:
            return True
        for prefix in wildcards:
            if ip.startswith(prefix):
                return True
        if networks:
            try:
                addr = ipaddress.ip_address(ip)
                return any(addr in net for net in networks)
            except ValueError:
                pass
        return False

    return _is_excluded
