"""Validate field provenance and source reservations in owned local backups."""
import json,math,re,sqlite3
from . import field_models as m
from .fieldwork import instant,split_days
from .workforce import labour_cost
from .models import Line


def validate(db,require):
    previous=db.row_factory;db.row_factory=sqlite3.Row
    try:
        rows=lambda table:[dict(r) for r in db.execute('SELECT * FROM '+table)]
        indexed=lambda table:{r['id']:r for r in rows(table)}
        jobs=indexed('jobs');people=indexed('team_resources');photos=indexed('job_photos');logs=indexed('field_logs');sessions=indexed('field_sessions');times=indexed('time_entries');materials=indexed('job_materials');claims=indexed('document_sources')
        identity=lambda v:isinstance(v,str) and re.fullmatch('[a-f0-9]{32}',v) is not None
        require(len(logs)<=5000 and len(sessions)<=2000,'Field record limits exceeded.')
        require(all(identity(i) for i in [*logs,*sessions,*claims]),'Invalid field/source identity.')
        from .customer_review import historical_owner
        def binding(row):require(row['job_id'] in jobs and historical_owner(db,row['job_id'],row['customer_id']),'Field/source customer binding is inconsistent.')
        def log_value(row):
            binding(row);fields={k:row[k] for k in m.LogFields.model_fields};fields['photo_ids']=json.loads(fields['photo_ids']);m.LogFields.model_validate(fields)
            require(type(row['version']) is int and row['version']>=1 and row['reviewed'] in (0,1) and row['archived'] in (0,1),'Invalid field log state.')
            require(all(pid in photos and photos[pid]['job_id']==row['job_id'] and historical_owner(db,row['job_id'],photos[pid]['customer_id']) for pid in fields['photo_ids']),'Field log has a photo from another job/customer.')
        for row in logs.values():log_value(row)
        log_history={i:[] for i in logs}
        for h in rows('field_log_history'):
            require(h['log_id'] in logs and h['action'] in ('created','edited','review','archive','restore','material.converted','customer.transferred'),'Invalid field log history.')
            snap=json.loads(h['snapshot']);current=logs[h['log_id']];require(set(snap)==set(current),'Unsupported field log history shape.');log_value(snap)
            require(all(snap[k]==current[k] for k in ('id','job_id','author_id','membership_id','created_at')),'Field log immutable identity changed.');log_history[h['log_id']].append((h['id'],snap))
        for lid,history in log_history.items():
            values=[s for _,s in sorted(history)];require(bool(values) and values[-1]==logs[lid] and [s['version'] for s in values]==list(range(1,logs[lid]['version']+1)),'Field log history is incomplete.')
        for link in rows('field_material_links'):
            require(link['log_id'] in logs and link['material_id'] in materials and logs[link['log_id']]['kind']=='materials' and logs[link['log_id']]['job_id']==materials[link['material_id']]['job_id'],'Field material conversion crosses job boundaries.')
        def session_value(row):
            binding(row);resources=json.loads(row['resources']);require(1<=len(resources)<=2 and len({p['id'] for p in resources})==len(resources),'Invalid timer team attribution.')
            for p in resources:
                require(set(p)=={'id','name','role','version','hourly_cost_pence'} and p['id'] in people and type(p['hourly_cost_pence']) is int and 0<=p['hourly_cost_pence']<=100_000_000 and type(p['version']) is int and 1<=p['version']<=people[p['id']]['version'],'Invalid saved field resource/rate.')
            require(row['state'] in ('running','paused','stopped','interrupted','reviewed','converted','discarded') and type(row['version']) is int and row['version']>=1 and type(row['generation']) is int and row['generation']>=0,'Invalid timer state.')
            if row['reviewed_intervals'] is not None:
                values=json.loads(row['reviewed_intervals']);require(0<len(values)<=100,'Invalid reviewed interval count.')
                for i,value in enumerate(values):
                    m.Interval.model_validate(value)
                    require(i==0 or value['start']>=values[i-1]['end'],'Reviewed intervals overlap.')
            if row['state'] in ('reviewed','converted'):require(row['reviewed_intervals'] is not None and len(row['review_reason'])>=3,'Reviewed timer is missing its owner review.')
            return resources
        for row in sessions.values():session_value(row)
        intervals=rows('field_intervals');by_timer={i:[] for i in sessions}
        def interval_value(i):
            require(identity(i['id']) and i['session_id'] in sessions and identity(i['boot_id']) and isinstance(i['last_monotonic'],(int,float)) and math.isfinite(i['last_monotonic']),'Invalid captured interval identity.')
            start=instant(i['started_at']);last=instant(i['last_seen_at']);require(start<=last,'Captured clock runs backwards.')
            require(i['ended_at'] is None or instant(i['ended_at'])==last,'Closed interval differs from last confirmed timestamp.')
        for i in intervals:interval_value(i);by_timer[i['session_id']].append(i)
        for tid,values in by_timer.items():
            require(0<len(values)<=100,'Missing or excessive timer intervals.');ordered=sorted(values,key=lambda i:(i['started_at'],i['id']))
            require(sum(i['ended_at'] is None for i in values)==int(sessions[tid]['state']=='running'),'Timer open/closed state is inconsistent.')
            require(all(ordered[n-1]['ended_at'] is not None and ordered[n]['started_at']>=ordered[n-1]['ended_at'] for n in range(1,len(ordered))),'Captured timer intervals overlap.')
        actual_locks={(r['resource_id'],r['session_id']) for r in rows('field_resource_locks')};expected_locks={(p['id'],tid) for tid,row in sessions.items() if row['state']=='running' for p in json.loads(row['resources'])};require(actual_locks==expected_locks,'Running resource reservations are inconsistent.')
        history={i:[] for i in sessions};conversions={}
        for h in rows('field_history'):
            require(h['session_id'] in sessions and h['action'] in ('started','heartbeat','pause','stop','resume','interrupted','reviewed','converted','discarded','customer.transferred'),'Invalid timer history action.')
            require((h['actor_id'] is None)==(h['membership_id'] is None) and (h['actor_id'] is not None or h['action']=='interrupted'),'Invalid system timer event.')
            value=json.loads(h['snapshot']);require(set(value)=={'session','intervals'},'Invalid timer history shape.');snap=value['session'];current=sessions[h['session_id']];require(set(snap)==set(current),'Invalid timer snapshot fields.');session_value(snap)
            require(all(snap[k]==current[k] for k in ('id','job_id','author_id','membership_id','resources','note','created_at')),'Timer history changes original attribution.')
            for interval in value['intervals']:interval_value(interval);require(interval['session_id']==snap['id'],'Timer history contains another timer interval.')
            history[h['session_id']].append((h['id'],value))
            if h['action']=='converted':conversions[(snap['id'],snap['generation'])]=(snap,json.loads(h['detail'])['time_ids'])
        for tid,values in history.items():
            ordered=[v for _,v in sorted(values)];require(bool(ordered) and [v['session']['version'] for v in ordered]==list(range(1,sessions[tid]['version']+1)) and ordered[-1]['session']==sessions[tid],'Timer history is incomplete.')
            require(sorted(ordered[-1]['intervals'],key=lambda i:i['id'])==sorted(by_timer[tid],key=lambda i:i['id']),'Timer history differs from captured intervals.')
        mapped=set();generation_links={}
        for link in rows('field_time_links'):
            key=(link['session_id'],link['generation']);require(link['time_id'] in times and key in conversions and link['resource_id'] in people and link['active'] in (0,1),'Invalid labour conversion link.')
            snap,_=conversions[key];t=times[link['time_id']];resources={p['id']:p for p in json.loads(snap['resources'])};days=split_days(json.loads(snap['reviewed_intervals']));require(link['resource_id'] in resources and link['work_date'] in days,'Generated labour differs from reviewed dates/team.')
            resource=resources[link['resource_id']];minutes=(days[link['work_date']]+30000)//60000;active=int(sessions[link['session_id']]['state']=='converted' and sessions[link['session_id']]['generation']==link['generation'])
            require(1<=minutes<=1440 and t['job_id']==snap['job_id'] and t['resource_id']==link['resource_id'] and t['work_date']==link['work_date'] and t['duration_minutes']==minutes and t['hourly_cost_pence']==resource['hourly_cost_pence'] and t['cost_pence']==labour_cost(minutes,resource['hourly_cost_pence']) and json.loads(t['resource_snapshot'])==resource,'Generated labour changed its reviewed time or saved rate.')
            require(link['active']==active and t['archived']==1-active,'Generated labour active/archive state is inconsistent.');mapped.add(t['id']);generation_links.setdefault(key,[]).append(link)
        for key,(snap,ids) in conversions.items():
            links=generation_links.get(key,[]);require({l['time_id'] for l in links}==set(ids) and len(links)==len(json.loads(snap['resources']))*len(split_days(json.loads(snap['reviewed_intervals']))),'Labour conversion is missing mapped entries.')
        # Link saved source contents back to retained versions, including corrected sources.
        def normalized(value):
            value=dict(value)
            for key in ('resource_snapshot','supplier_links','catalogue_snapshot'):
                if isinstance(value.get(key),str):value[key]=json.loads(value[key])
            return value
        def versions(current,table,kinds):
            result={(r['id'],r['version']):normalized(r) for r in current.values()}
            for audit in rows(table):
                if audit['kind'] not in kinds:continue
                for key in ('before_value','after_value'):
                    if audit[key]:
                        value=json.loads(audit[key]);identity=(value['id'],value['version']);value=normalized(value)
                        require(identity not in result or result[identity]==value,'Source audit disagrees with its saved version.')
                        result[identity]=value
            return result
        time_versions=versions(times,'workforce_audit',('time',))
        material_versions=versions(materials,'material_audit',('tracker','material'))
        session_versions={(v['session']['id'],v['session']['version']):v['session'] for values in history.values() for _,v in values}
        documents={'quote':indexed('quotes'),'invoice':indexed('invoices')}
        def claim_value(row):
            binding(row);require(row['kind'] in documents and row['document_id'] in documents[row['kind']],'Document source target is missing.');doc=documents[row['kind']][row['document_id']]
            require(row['job_id']==doc['job_id'] and row['customer_id']==doc['customer_id'] and row['state'] in ('reserved','issued','released') and type(row['source_version']) is int and row['source_version']>=1,'Document source ownership/state is invalid.')
            candidates={'field':sessions,'time':times,'material':materials};require(row['source_kind'] in candidates and row['source_id'] in candidates[row['source_kind']],'Document source record is missing.');current=candidates[row['source_kind']][row['source_id']]
            require(current['job_id']==row['job_id'] and row['source_version']<=current['version'] and not (row['source_kind']=='time' and row['source_id'] in mapped),'Document source crosses jobs or double-counts field labour.')
            value=json.loads(row['source_snapshot']);original=value['record'] if row['source_kind']=='field' else value
            require(original['id']==row['source_id'] and original['job_id']==row['job_id'] and original['version']==row['source_version'],'Source snapshot identity/version changed.')
            key=(original['id'],original['version'])
            if row['source_kind']=='field':
                require(session_versions.get(key)==original and original['state']=='converted','Field source snapshot differs from reviewed history.')
                require(set(value)=={'record','time_entries'},'Unexpected field source snapshot fields.')
                conversion=conversions.get((original['id'],original['generation']))
                require(conversion is not None and {t['id'] for t in value['time_entries']}==set(conversion[1]) and len(value['time_entries'])==len(conversion[1]),'Field source omits or duplicates generated labour.')
                require(all(time_versions.get((t['id'],t['version']))==normalized(t) and t['archived']==0 for t in value['time_entries']),'Field source time or cost differs from saved history.')
            else:
                expected=time_versions if row['source_kind']=='time' else material_versions
                require(expected.get(key)==normalized(original),'Source contents differ from retained source history.')
            line=Line.model_validate_json(row['line_snapshot']).model_dump();require(type(row['line_index']) is int and row['line_index']>=0,'Invalid source line position.')
            if row['state']!='released':
                items=json.loads(doc['snapshot'])['items'];require(row['line_index']<len(items) and line==items[row['line_index']],'Reserved source line differs from document snapshot.')
                expected='issued' if row['kind']=='invoice' and doc['status']=='issued' else 'reserved';allowed=('draft','shared','accepted','declined') if row['kind']=='quote' else ('draft','issued');require(doc['status'] in allowed and row['state']==expected,'Closed document retains an active source claim.')
        for row in claims.values():claim_value(row)
        histories={i:[] for i in claims}
        for h in rows('source_history'):
            require(h['claim_id'] in claims and h['action'] in ('reserved','released','transferred','issued','draft.review_replaced','customer.transferred'),'Invalid source reservation history.')
            snap=json.loads(h['snapshot']);require(set(snap)==set(claims[h['claim_id']]),'Invalid source history shape.')
            current=claims[h['claim_id']];require(all(snap[k]==current[k] for k in current if k not in ('state','updated_at','customer_id')) and historical_owner(db,snap['job_id'],snap['customer_id']),'Source provenance changed in place.');histories[h['claim_id']].append((h['id'],snap))
        for cid,values in histories.items():require(bool(values) and sorted(values)[0][1]['state']=='reserved' and sorted(values)[-1][1]==claims[cid],'Source reservation audit is incomplete.')
        return {'logs':len(logs),'timers':len(sessions),'source_claims':len(claims)}
    finally:db.row_factory=previous
