#!/usr/bin/env python3
"""Convert a complete Data-Shield IPv4 feed to a Stormshield custom services CSV.

Runs offline on an administrator workstation. Never connects to a firewall.
License: GPL-3.0-only. Maintainer: duggytuxy (Laurent Minne).
"""
import argparse
import csv
import hashlib
import io
import ipaddress
import os
import re
import tempfile
from datetime import datetime, timezone
from pathlib import Path

MAX_FEED_BYTES = 12 * 1024 * 1024
MAX_EXPORT_BYTES = 64 * 1024 * 1024
HEADER = ['#name', '#ip/fqdn', '#date', '#revision', '#comment']


def bounded_read(path, limit):
    with path.open('rb') as handle:
        raw = handle.read(limit + 1)
    if len(raw) > limit:
        raise ValueError('Input exceeds the size limit')
    return raw


def addresses(raw):
    if not raw or not raw.endswith(b'\n') or len(raw) > MAX_FEED_BYTES:
        raise ValueError('Expected a nonempty, complete LF-terminated IPv4 feed')
    result, seen = [], set()
    for index, line in enumerate(raw[:-1].split(b'\n'), 1):
        if not re.fullmatch(rb'[0-9.]+', line):
            raise ValueError(f'Invalid IPv4-only record on line {index}')
        address = ipaddress.IPv4Address(line.decode('ascii'))
        if str(address).encode() != line or not address.is_global or address.is_multicast or address.is_reserved:
            raise ValueError(f'Noncanonical or non-public IPv4 on line {index}')
        if address in seen:
            raise ValueError(f'Duplicate IPv4 on line {index}')
        seen.add(address)
        result.append(str(address))
    return result


def convert(raw, existing, profile, maximum):
    if profile not in ('standard', 'critical') or not 1 <= maximum <= 1000000:
        raise ValueError('Invalid profile or model capacity')
    ips = addresses(raw)
    name = 'datashield' + profile
    preserved = []
    for row in csv.reader(io.StringIO(existing, newline=''), strict=True):
        if not row or row == HEADER:
            continue
        if len(row) != 5 or not row[0] or not row[1]:
            raise ValueError('Existing export must contain five fields per service row')
        if any(any(ord(char) < 32 or ord(char) == 127 for char in field) for field in row):
            raise ValueError('Control characters in existing export')
        if row[0].lower() != name:
            preserved.append(row)
    total = len(preserved) + len(ips)
    if total > maximum:
        raise ValueError(f'Capacity exceeded: {total} rows require more than the specified {maximum}; no truncation is allowed')
    date = datetime.now(timezone.utc).strftime('%Y/%m/%d %H:%M')
    output = io.StringIO(newline='')
    writer = csv.writer(output, lineterminator='\n')
    writer.writerows(preserved)
    writer.writerows([name, address, date, '', 'Data-Shield IPv4 ' + profile.title()] for address in ips)
    output.write('\n')
    return output.getvalue(), len(ips), len(preserved)


def write_new(path, text):
    """Publish a fully written file without replacing an existing destination."""
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', newline='', dir=path.parent, prefix='.data-shield-', delete=False) as handle:
            temporary = Path(handle.name)
            handle.write(text)
            handle.flush()
            os.fsync(handle.fileno())
        os.link(temporary, path)
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--input', type=Path, required=True, help='Downloaded complete IPv4 TXT feed')
    parser.add_argument('--profile', choices=('standard', 'critical'), required=True)
    source = parser.add_mutually_exclusive_group(required=True)
    source.add_argument('--existing', type=Path, help='Current complete custom services CSV export')
    source.add_argument('--new-database', action='store_true', help='Confirm that no custom services database exists')
    parser.add_argument('--max-entries', type=int, required=True, help='Verified model limit for the entire custom database')
    parser.add_argument('--output', type=Path, required=True, help='New CSV filename; existing files are never overwritten')
    args = parser.parse_args()
    try:
        raw = bounded_read(args.input, MAX_FEED_BYTES)
        existing = bounded_read(args.existing, MAX_EXPORT_BYTES).decode('utf-8-sig') if args.existing else ''
        result, count, retained = convert(raw, existing, args.profile, args.max_entries)
        write_new(args.output, result)
    except (ValueError, OSError, UnicodeError, csv.Error) as error:
        parser.exit(1, 'Conversion failed: ' + str(error) + '\n')
    print(f'Validated IPv4 records: {count}; preserved existing rows: {retained}; output rows: {count + retained}')
    print('Source SHA-256: ' + hashlib.sha256(raw).hexdigest())
    print('Review the output and import it manually. No firewall configuration was changed.')


if __name__ == '__main__':
    main()
