"""Pure manifest/address logic, shared by the builder and property tests."""
from __future__ import annotations
import json
import re

IDENT=re.compile(r'[A-Za-z][A-Za-z0-9._-]*\Z')
ALIAS=re.compile(r'[A-Za-z][A-Za-z0-9:._-]*\Z')

def identifier(value):
    if not isinstance(value,str) or not IDENT.fullmatch(value):raise ValueError('Invalid ASCII ID: '+repr(value))
    return value

def private(cp):
    return type(cp) is int and (0xE000<=cp<=0xF8FF or 0xF0000<=cp<=0xFFFFD or 0x100000<=cp<=0x10FFFD)

def allocate(keys,previous=None):
    keys=list(keys)
    if len(keys)!=len(set(keys)):raise ValueError('Duplicate glyph IDs')
    for k in keys:identifier(k)
    previous=previous or {'schema':'openglyph.allocation.v1','addresses':{}}
    if previous.get('schema')!='openglyph.allocation.v1':raise ValueError('Unsupported allocation schema')
    addresses=dict(previous['addresses'])
    for k,v in addresses.items():
        identifier(k)
        if not private(v):raise ValueError('Invalid font-local private-use address')
    if len(set(addresses.values()))!=len(addresses):raise ValueError('Conflicting old addresses')
    used=set(addresses.values())
    points=(cp for lo,hi in [(0xE000,0xF8FF),(0xF0000,0xFFFFD),(0x100000,0x10FFFD)] for cp in range(lo,hi+1) if cp not in used)
    for key in sorted(keys):
        if key not in addresses:addresses[key]=next(points)
    return dict(schema='openglyph.allocation.v1',addresses=addresses,retired_keys=sorted(set(addresses)-set(keys)))

def validate_project(value):
    if not isinstance(value,dict) or value.get('schema')!='openglyph.project.v1':raise ValueError('Expected openglyph.project.v1')
    if set(value)-{'schema','namespace','glyphs'}:raise ValueError('Unknown project fields')
    namespace=identifier(value.get('namespace'))
    rows=value.get('glyphs')
    if not isinstance(rows,list) or not rows:raise ValueError('Project needs nonempty glyphs')
    ids=[]
    for row in rows:
        if not isinstance(row,dict) or not {'id','base','edits'}<=set(row) or set(row)-{'id','base','edits','modules','select'}:raise ValueError('Each glyph needs id, base and edits; optional modules/select')
        ids.append(identifier(row['id']))
        if not isinstance(row['base'],str) or not isinstance(row['edits'],list):raise ValueError('Invalid glyph definition')
    if len(set(ids))!=len(ids):raise ValueError('Duplicate glyph IDs')
    return value

def catalog_tex(namespace,font_filename,addresses,*,trusted_entries=False):
    identifier(namespace)
    if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*\.(?:otf|ttf)',font_filename):raise ValueError('Unsafe font filename')
    lines=['% Generated by OpenGlyph; addresses are font-local, not Unicode identity.',
           '\\OpenGlyphDeclareFont{'+namespace+'}{'+font_filename+'}']
    for key,cp in sorted(addresses.items()):
        identifier(key)
        if type(cp) is not int or not 0<=cp<=0x10ffff or 0xd800<=cp<=0xdfff:raise ValueError('Invalid scalar')
        command='\\OpenGlyphDeclareEntry' if trusted_entries else '\\OpenGlyphDeclare'
        lines.append(command+'{'+namespace+':'+key+'}{'+namespace+'}{'+f'{cp:04X}'+'}')
    return '\n'.join(lines)+'\n'

def read_json(path):
    def unique(pairs):
        result={}
        for k,v in pairs:
            if k in result:raise ValueError('Duplicate JSON field: '+k)
            result[k]=v
        return result
    return json.loads(path.read_text(encoding='utf-8'),object_pairs_hook=unique,
        parse_constant=lambda v:(_ for _ in ()).throw(ValueError('Nonfinite JSON number')))
