"""Canonical full-text TextGrid subset reader/writer. No Praat or forced alignment run."""
import json,re,math,copy,hashlib
def number(x):
    if type(x)not in [int,float]or not math.isfinite(x):raise ValueError('finite authored number')
    s=str(x) if type(x)is int else repr(x)
    return s[:-2] if s.endswith('.0') else s
def quoted(s):
    if not isinstance(s,str)or any(c in s for c in ['\n','\r']):raise ValueError('single-line string subset')
    return '"'+s.replace('"','""')+'"'
def validate(g):
    lo,hi=g['xmin'],g['xmax'];number(lo);number(hi)
    if lo>=hi or len(g['tiers'])!=2:raise ValueError('positive grid and exactly two declared tiers')
    for i,t in enumerate(g['tiers']):
        if not isinstance(t['entries'],list)or len(t['entries'])>100:raise ValueError('bounded authored entry count, at most100 before writing or reading')
        if t['xmin']!=lo or t['xmax']!=hi:raise ValueError('strict local tier coverage, not universal Praat validity rule')
        if t['class']!=['IntervalTier','TextTier'][i]:raise ValueError('one interval followed by one point tier subset')
        quoted(t['name'])
        if i==0:
            if not t['entries']or t['entries'][0]['xmin']!=lo or t['entries'][-1]['xmax']!=hi:raise ValueError('strict local interval coverage')
            last=lo
            for x in t['entries']:
                number(x['xmin']);number(x['xmax']);quoted(x['text'])
                if x['xmin']!=last or x['xmin']>=x['xmax']:raise ValueError('positive adjacent intervals required')
                last=x['xmax']
        else:
            last=None
            for x in t['entries']:
                number(x['number']);quoted(x['mark'])
                if not lo<=x['number']<=hi or last is not None and x['number']<=last:raise ValueError('strictly ordered points within local domain')
                last=x['number']
def serialize(g):
    validate(g);lines=['File type = "ooTextFile"','Object class = "TextGrid"','',f"xmin = {number(g['xmin'])}",f"xmax = {number(g['xmax'])}",'tiers? <exists>','size = 2','item []:']
    for i,t in enumerate(g['tiers'],1):
        lines += [f'    item [{i}]:',f"        class = {quoted(t['class'])}",f"        name = {quoted(t['name'])}",f"        xmin = {number(t['xmin'])}",f"        xmax = {number(t['xmax'])}"]
        label='intervals'if i==1 else'points';lines.append(f"        {label}: size = {len(t['entries'])}")
        for j,x in enumerate(t['entries'],1):
            lines.append(f'        {label} [{j}]:')
            for k in (['xmin','xmax','text']if i==1 else['number','mark']):lines.append('            '+k+' = '+(quoted(x[k])if k in ['text','mark']else number(x[k])))
    return '\n'.join(lines)+'\n'
def parse(text):
    # Labels and ordering are deliberately stricter than Praat's general reader.
    lines=[x.strip()for x in text.splitlines()if x.strip()];pos=0
    def expect(value):
        nonlocal pos
        if pos>=len(lines)or lines[pos]!=value:raise ValueError('unsupported canonical full-text subset near '+value)
        pos+=1
    def field(label,kind):
        nonlocal pos
        if pos>=len(lines)or not lines[pos].startswith(label+' = '):raise ValueError('missing '+label)
        raw=lines[pos][len(label)+3:];pos+=1
        if kind=='str':
            if not re.fullmatch(r'"(?:""|[^"])*"',raw):raise ValueError('quoted string with doubled quotes required')
            return raw[1:-1].replace('""','"')
        if not re.fullmatch(r'-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?',raw):raise ValueError('finite numeric token required')
        x=float(raw);number(x)
        if kind=='int'and(x!=int(x)or not 0<=x<=100):raise ValueError('bounded authored entry count')
        return int(x)if kind=='int'else x
    expect('File type = "ooTextFile"');expect('Object class = "TextGrid"');g={'xmin':field('xmin','num'),'xmax':field('xmax','num'),'tiers':[]};expect('tiers? <exists>');expect('size = 2');expect('item []:')
    for i in [1,2]:
        expect(f'item [{i}]:');t={'class':field('class','str'),'name':field('name','str'),'xmin':field('xmin','num'),'xmax':field('xmax','num'),'entries':[]};label='intervals'if i==1 else'points';n=field(label+': size','int')
        for j in range(1,n+1):
            expect(f'{label} [{j}]:');entry={k:field(k,'str'if k in ['text','mark']else'num')for k in (['xmin','xmax','text']if i==1 else['number','mark'])};t['entries'].append(entry)
        g['tiers'].append(t)
    if pos!=len(lines):raise ValueError('unexpected trailing records')
    validate(g);return g
def demo():
    g={'xmin':0,'xmax':2,'tiers':[{'class':'IntervalTier','name':'واژه','xmin':0,'xmax':2,'entries':[{'xmin':0,'xmax':1,'text':'سلام "دوست"'},{'xmin':1,'xmax':2,'text':'پایان'}]},{'class':'TextTier','name':'رخداد','xmin':0,'xmax':2,'entries':[{'number':0.25,'mark':'شروع'},{'number':1.5,'mark':'تأکید'}]}]}
    text=serialize(g);parsed=parse(text);assert parsed==g and serialize(parsed)==text and '"سلام ""دوست"""'in text
    assert text.encode('utf-8').decode('utf-8')==text
    precise=copy.deepcopy(g);precise['tiers'][1]['entries']=[{'number':0.25000000000004,'mark':'اول'},{'number':0.25000000000005,'mark':'دوم'}]
    assert parse(serialize(precise))==precise
    hundred=copy.deepcopy(g);hundred['tiers'][1]['entries']=[{'number':i/100,'mark':str(i)}for i in range(100)]
    assert parse(serialize(hundred))==hundred
    invalid={}
    for name,mutate in [('zero_interval',lambda x:x['tiers'][0]['entries'][0].update(xmax=0)),('gap',lambda x:x['tiers'][0]['entries'][1].update(xmin=1.1)),('unordered_points',lambda x:x['tiers'][1]['entries'].reverse()),('strict_local_tier_coverage',lambda x:x['tiers'][1].update(xmax=1.9)),('over100_points',lambda x:x['tiers'][1].update(entries=[{'number':i/100,'mark':str(i)}for i in range(101)]))]:
        other=copy.deepcopy(g);mutate(other)
        try:serialize(other)
        except ValueError as e:invalid[name]=str(e)
        else:raise AssertionError('mutation accepted')
    short='File type = "ooTextFile"\nObject class = "TextGrid"\n0\n2\n<exists>\n0\n'
    try:parse(short)
    except ValueError:short_unsupported=True
    else:raise AssertionError('short format accepted by full-only reader')
    return {'authored_reference':g,'full_textgrid_utf8':text,'utf8_sha256':hashlib.sha256(text.encode()).hexdigest(),'parsed':parsed,'roundtrip_exact':True,'close_distinct_float_roundtrip':precise,'hundred_points_roundtrip':True,'invalid_local_contract_cases':invalid,'short_format_unsupported_not_inherently_corrupt':short_unsupported,'praat_or_audio_alignment_executed':False,'scope':'Canonical full-text subset only, exactly IntervalTier then TextTier, single-line labels, strict local grid/tier/interval coverage, at most100entries per tier on both write/read, repr roundtrip-safe floats. General Praat ignores many labels and permits other full/short/intermediate formats and weak-invariant violations; our rejection is not a universal corruption verdict. UTF16 reader not implemented.'}
if __name__=='__main__':print(json.dumps(demo(),ensure_ascii=False,indent=2))
