"""Offline reference codec/validator, NOT an installable receiver or writer."""
import hashlib
import json
import uuid
from datetime import datetime, timezone

PROTOCOL = 'yachtmemory.signalk-catalog/2'
TYPES = frozenset(('number', 'string', 'boolean', 'position', 'object', 'array', 'null', 'unknown'))
KINDS = frozenset(('path', 'observation', 'source'))
MAX_CHUNKS = 256
MAX_RECORDS_PER_CHUNK = 100
MAX_CHUNK_BYTES = 16384


def canonical(obj):
    return json.dumps(obj, sort_keys=True, separators=(',', ':'), ensure_ascii=False, allow_nan=False).encode('utf-8')


def digest(obj):
    return hashlib.sha256(canonical(obj)).hexdigest()


def nonblank(s, max_chars):
    return isinstance(s, str) and 0 < len(s) <= max_chars and '\x00' not in s


def record_key(r):
    if not isinstance(r, dict) or r.get('kind') not in KINDS:
        raise ValueError('invalid kind')
    kind = r['kind']
    if kind == 'path':
        if not nonblank(r.get('path'), 255) or r.get('value_type') not in TYPES:
            raise ValueError('invalid path record')
        if 'metadata' in r:
            m = r['metadata']
            if not isinstance(m, dict):
                raise ValueError('invalid path metadata')
            for key, limit in (('units', 80), ('displayName', 255), ('description', 2048)):
                if key in m and m[key] is not None and not isinstance(m[key], str):
                    raise ValueError('invalid metadata field')
                if isinstance(m.get(key), str) and len(m[key]) > limit:
                    raise ValueError('metadata too long')
        return (kind, r['path'], '')
    if kind == 'observation':
        if not nonblank(r.get('path'), 255) or not nonblank(r.get('source'), 255):
            raise ValueError('invalid observation')
        return (kind, r['path'], r['source'])
    if not nonblank(r.get('source'), 255):
        raise ValueError('invalid source')
    for key in ('type', 'label', 'src'):
        if r.get(key) is not None and (not isinstance(r[key], str) or len(r[key]) > 255):
            raise ValueError('invalid source metadata')
    return (kind, r['source'], '')


def validate_records(records):
    seen = set()
    for r in records:
        key = record_key(r)
        if key in seen:
            raise ValueError('duplicate record')
        seen.add(key)
    paths = {r['path'] for r in records if r['kind'] == 'path'}
    for r in records:
        if r['kind'] == 'observation' and r['path'] not in paths:
            raise ValueError('observation path missing')
    return True


def _utc_timestamp(s):
    if not isinstance(s, str) or not s.endswith('Z'):
        raise ValueError('timestamp must be UTC Z')
    try:
        dt = datetime.fromisoformat(s.replace('Z', '+00:00'))
    except ValueError as exc:
        raise ValueError('invalid timestamp') from exc
    if dt.utcoffset() != timezone.utc.utcoffset(None):
        raise ValueError('invalid UTC')
    return dt


def build(vessel_id, snapshot_id, revision, timestamp, records, chunk_size=50):
    uuid.UUID(vessel_id)
    uuid.UUID(snapshot_id)
    _utc_timestamp(timestamp)
    if type(revision) is not int or not 1 <= revision <= 2**32-1:
        raise ValueError('invalid revision')
    if type(chunk_size) is not int or not 1 <= chunk_size <= MAX_RECORDS_PER_CHUNK:
        raise ValueError('invalid chunk size')
    records = sorted(records, key=record_key)
    validate_records(records)
    # Even an empty observation set needs a deliberate single empty chunk.
    parts = [records[i:i+chunk_size] for i in range(0, len(records), chunk_size)] or [[]]
    if len(parts) > MAX_CHUNKS:
        raise ValueError('too many chunks')
    chunks = []
    for i, part in enumerate(parts):
        body = {'records': part}
        if len(canonical(body)) > MAX_CHUNK_BYTES:
            raise ValueError('chunk exceeds size limit')
        chunks.append({'protocol': PROTOCOL, 'vessel_id': vessel_id, 'snapshot_id': snapshot_id,
                       'revision': revision, 'chunk_index': i, 'records': part,
                       'payload_sha256': digest(body)})
    header = {'protocol': PROTOCOL, 'vessel_id': vessel_id, 'snapshot_id': snapshot_id,
              'revision': revision, 'discovered_at_utc': timestamp,
              'scope': 'current-selected-sources', 'inventory_coverage': 'selected-only',
              'transport_complete': True, 'chunk_count': len(chunks),
              'record_count': len(records), 'document_sha256': digest({'records': records})}
    return header, chunks


def reconstruct(header, chunks):
    required = ('protocol', 'vessel_id', 'snapshot_id', 'revision')
    if header.get('protocol') != PROTOCOL or header.get('scope') != 'current-selected-sources' or header.get('inventory_coverage') != 'selected-only' or header.get('transport_complete') is not True:
        raise ValueError('invalid snapshot scope/completeness')
    uuid.UUID(header['vessel_id'])
    uuid.UUID(header['snapshot_id'])
    _utc_timestamp(header['discovered_at_utc'])
    n = header.get('chunk_count')
    if type(n) is not int or not 1 <= n <= MAX_CHUNKS or len(chunks) < n:
        raise ValueError('incomplete snapshot')
    indexed = {}
    for c in chunks:
        if any(c.get(k) != header.get(k) for k in required):
            raise ValueError('cross snapshot chunk')
        i = c.get('chunk_index')
        if type(i) is not int or i < 0 or i >= n:
            raise ValueError('invalid chunk index')
        if i in indexed:
            if canonical(indexed[i]) != canonical(c):
                raise ValueError('conflicting chunk')
            raise ValueError('duplicate chunk')
        part = c.get('records')
        if not isinstance(part, list) or len(part) > MAX_RECORDS_PER_CHUNK:
            raise ValueError('invalid chunk size')
        body = {'records': part}
        if len(canonical(body)) > MAX_CHUNK_BYTES or digest(body) != c.get('payload_sha256'):
            raise ValueError('invalid chunk hash or size')
        indexed[i] = c
    records = [r for i in range(n) for r in indexed[i]['records']]
    if type(header.get('record_count')) is not int or len(records) != header['record_count']:
        raise ValueError('wrong record count')
    if records != sorted(records, key=record_key):
        raise ValueError('noncanonical record order')
    validate_records(records)
    if digest({'records': records}) != header.get('document_sha256'):
        raise ValueError('document hash mismatch')
    return records
