"""CountryLedger: offline country reports using IP2Location CSV databases."""
import argparse
from bisect import bisect_right
from collections import Counter
import csv
import html
import ipaddress
import json
from pathlib import Path
import re
import sys


class CountryDatabase:
    def __init__(self, path, version=4):
        self.starts, self.ends, self.countries = [], [], []
        limit = 2 ** (32 if version == 4 else 128)
        self.version = version
        with Path(path).open(encoding='utf-8-sig', newline='') as source:
            for number, row in enumerate(csv.reader(source), 1):
                if not row:
                    continue
                if number == 1 and row[0].lower() == 'ip_from':
                    continue
                try:
                    start, end = int(row[0]), int(row[1])
                    code, name = row[2], row[3]
                    if not 0 <= start <= end < limit:
                        raise ValueError()
                    if self.ends and start <= self.ends[-1]:
                        raise ValueError()
                    if not re.fullmatch(r'[A-Z]{2}|-', code):
                        raise ValueError()
                except (ValueError, IndexError):
                    raise ValueError(f'Invalid, overlapping or unsorted database range at row {number}') from None
                self.starts.append(start)
                self.ends.append(end)
                self.countries.append((code, name))
        if not self.starts:
            raise ValueError('Database contains no ranges')

    def lookup(self, address):
        value = int(address)
        index = bisect_right(self.starts, value) - 1
        if index >= 0 and value <= self.ends[index]:
            return self.countries[index]
        return None


COMBINED = re.compile(r'^(\S+)\s+\S+\s+\S+\s+\[[^\]]+\]\s+"[^"\r\n]*"\s+(\d{3})\s+')


def records(path, format):
    with Path(path).open(encoding='utf-8-sig', errors='replace', newline='') as source:
        if format == 'csv':
            reader = csv.DictReader(source)
            if not {'ip', 'status'} <= set(reader.fieldnames or []):
                raise ValueError('Traffic CSV requires ip and status columns')
            for row in reader:
                yield row.get('ip'), row.get('status')
        else:
            for line in source:
                match = COMBINED.match(line)
                yield match.groups() if match else (None, None)


def summarize(path, databases, format='combined', threshold=10):
    if threshold < 2:
        raise ValueError('Country-label threshold must be at least 2 requests')
    countries, statuses = Counter(), Counter()
    skipped = unknown = nonpublic = total = 0
    for raw_ip, raw_status in records(path, format):
        try:
            address = ipaddress.ip_address(raw_ip)
            status = int(raw_status)
            if not 100 <= status <= 599:
                raise ValueError()
        except (ValueError, TypeError):
            skipped += 1
            continue
        if address.version == 6 and address.ipv4_mapped:
            address = address.ipv4_mapped
        total += 1
        statuses[f'{status // 100}xx'] += 1
        if not address.is_global:
            nonpublic += 1
            continue
        database = databases.get(address.version)
        country = database.lookup(address) if database else None
        if country is None or country[0] == '-':
            unknown += 1
        else:
            countries[country] += 1
    visible = [{'code': code, 'country': name, 'requests': count}
               for (code, name), count in sorted(countries.items(), key=lambda item: (-item[1], item[0]))
               if count >= threshold]
    hidden = sum(count for count in countries.values() if count < threshold)
    return {'schema_version': 1, 'requests': total, 'skipped_rows': skipped,
            'country_label_threshold': threshold, 'countries': visible,
            'requests_in_hidden_countries': hidden, 'unmapped_requests': unknown,
            'nonpublic_requests': nonpublic, 'status_classes': dict(sorted(statuses.items())),
            'privacy_note': 'Raw IPs, URLs, timestamps and user agents are not exported. Counts are requests, not people. Small-country labels are hidden; this is not an anonymity guarantee.',
            'geolocation_note': 'IP geolocation estimates network location, not a person\u2019s precise location. VPNs and proxies can affect results.',
            'attribution': 'Country lookup uses IP2Location data. https://www.ip2location.com/'}


def render(summary):
    escape = html.escape
    rows = ''.join(f'<tr><td>{escape(c["country"])} <small>{escape(c["code"])}</small></td>'
                   f'<td><meter min="0" max="{max(summary["requests"], 1)}" value="{c["requests"]}"></meter></td>'
                   f'<td class="number">{c["requests"]:,}</td></tr>' for c in summary['countries'])
    if not rows:
        rows = '<tr><td colspan="3">No country meets the display threshold.</td></tr>'
    status = ' · '.join(f'{escape(key)}: {count:,}' for key, count in summary['status_classes'].items())
    return f'''<!doctype html><html lang="en"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">
<title>CountryLedger · Traffic without the trail</title>
<style>body{{margin:0;background:#f3f1e9;color:#193d36;font:17px system-ui,sans-serif}}main{{max-width:880px;margin:auto;padding:48px 24px}}h1{{font-size:clamp(32px,6vw,58px);line-height:1.1;letter-spacing:-2px}}.eyebrow{{text-transform:uppercase;letter-spacing:3px;font-size:12px}}.cards{{display:flex;gap:16px;flex-wrap:wrap;margin:32px 0}}.card{{background:white;padding:24px;flex:1;min-width:140px;border-radius:12px}}strong{{display:block;font-size:32px}}table{{width:100%;border-collapse:collapse}}td,th{{text-align:left;padding:16px 8px;border-bottom:1px solid #d3d9cd}}.number{{text-align:right}}meter{{width:100%;accent-color:#277c60}}small{{color:#53685d}}aside{{margin-top:28px;border-left:3px solid #b78531;padding-left:20px}}footer{{font-size:13px;margin-top:40px}}a{{color:inherit}}</style>
<main><div class="eyebrow">CountryLedger / Offline traffic report</div><h1>Where requests come from.<br>Without keeping the trail.</h1>
<p>Country-level patterns from local logs, powered by IP2Location.</p>
<div class="cards"><div class="card"><strong>{summary['requests']:,}</strong>valid requests</div><div class="card"><strong>{len(summary['countries'])}</strong>countries shown</div><div class="card"><strong>{summary['skipped_rows']:,}</strong>unreadable rows skipped</div></div>
<h2>Country distribution</h2><p>Labels appear only at {summary['country_label_threshold']} or more requests.</p>
<table><thead><tr><th>Country</th><th>Share of all requests</th><th class="number">Requests</th></tr></thead><tbody>{rows}</tbody></table>
<p>Hidden country labels: {summary['requests_in_hidden_countries']:,} requests · Unmapped: {summary['unmapped_requests']:,} · Non-public addresses: {summary['nonpublic_requests']:,}</p>
<h2>Response classes</h2><p>{status or 'No valid requests.'}</p>
<aside><p>{escape(summary['privacy_note'])}</p><p>{escape(summary['geolocation_note'])}</p><p>Original input files remain on your disk. Review them separately under your own retention policy.</p></aside>
<footer>Created locally with CountryLedger. No external assets, tracking scripts or network requests.<br>This report uses <a href="https://www.ip2location.com/">IP2Location</a> geolocation data.</footer></main></html>'''


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('traffic', type=Path)
    parser.add_argument('--db4', type=Path, required=True, help='IP2Location IPv4 CSV (DB1 or richer)')
    parser.add_argument('--db6', type=Path, help='Optional IP2Location IPv6 CSV')
    parser.add_argument('--format', choices=['combined', 'csv'], default='combined')
    parser.add_argument('--minimum-requests', type=int, default=10)
    parser.add_argument('--output', type=Path, default=Path('countryledger-report'))
    args = parser.parse_args()
    databases = {4: CountryDatabase(args.db4)}
    if args.db6:
        databases[6] = CountryDatabase(args.db6, 6)
    # An existing output directory is rejected to avoid replacing unrelated files.
    if args.output.exists():
        raise ValueError('Output directory already exists; choose a new directory')
    result = summarize(args.traffic, databases, args.format, args.minimum_requests)
    args.output.mkdir(parents=True)
    (args.output / 'report.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8')
    (args.output / 'index.html').write_text(render(result), encoding='utf-8')
    print(f'Report created: {result["requests"]} valid requests; {result["skipped_rows"]} rows skipped.')


if __name__ == '__main__':
    try:
        main()
    except (ValueError, OSError):
        print('Report could not be created. Check the input format, sorted database ranges, and output directory. Raw input details were not logged.', file=sys.stderr)
        raise SystemExit(1)
