"""Private field records. All elapsed work is server-confirmed and owner reviewed."""
import json,time
from datetime import datetime,timezone,timedelta
from zoneinfo import ZoneInfo
from fastapi import HTTPException,Request
from . import field_models as m
from .store import uid,now,one,many
from .photos import job_access,photo_access
from .identity import assigned
from .workforce import labour_cost,day_capacity,log as workforce_log

LONDON=ZoneInfo('Europe/London');ACTIVE=('accepted','scheduled','in_progress');SILENCE=120

def fail(message,status=409):raise HTTPException(status,message)
def required(row):
    if not row:fail('Field record not found in this business.',404)
    return row

def instant(value):return datetime.fromisoformat(value.replace('Z','+00:00')).astimezone(timezone.utc)
def stamp(value):return value.astimezone(timezone.utc).isoformat(timespec='milliseconds')
def milliseconds(delta):return (delta.days*86400+delta.seconds)*1000+delta.microseconds//1000
def duration_ms(intervals):return sum(milliseconds(instant(i['end'])-instant(i['start'])) for i in intervals)

def split_days(intervals):
    """Split actual UTC elapsed time at London midnights, including 23/25-hour days."""
    days={}
    for i in intervals:
        start,end=instant(i['start']),instant(i['end'])
        while start<end:
            local=start.astimezone(LONDON);day=local.date();boundary=datetime.combine(day+timedelta(days=1),datetime.min.time(),LONDON).astimezone(timezone.utc);stop=min(end,boundary)
            days[str(day)]=days.get(str(day),0)+milliseconds(stop-start);start=stop
    return days

def access(db,s,job_id,active=False):
    job_access(db,s,job_id);job=required(one(db,'SELECT id,customer_id,status,service FROM jobs WHERE id=?',(job_id,)))
    if (active or s['role']=='staff') and job['status'] not in ACTIVE:fail('Field work is available for accepted, scheduled or in-progress jobs.',404)
    return job

def timer_access(db,s,job_id,tid,write=False):
    job=access(db,s,job_id);row=required(one(db,'SELECT * FROM field_sessions WHERE id=? AND job_id=? AND customer_id=?',(tid,job_id,job['customer_id'])))
    if s['role']!='owner' and write and (row['author_id']!=s['subject'] or s['resource_id'] not in [r['id'] for r in json.loads(row['resources'])]):fail('Only the timer author or owner can control this timer.',403)
    if s['role']=='staff' and write:team(db,s,job_id,[p['id'] for p in json.loads(row['resources'])])
    return row

def team(db,s,job_id,ids):
    people=[]
    for rid in ids:
        p=required(one(db,'SELECT * FROM team_resources WHERE id=? AND archived=0',(rid,)))
        if s['role']=='staff' and not assigned(db,{**s,'resource_id':rid},job_id):fail('Each selected person must be assigned to this job.',403)
        people.append({k:p[k] for k in ('id','name','role','version','hourly_cost_pence')})
    if s['role']=='staff' and s['resource_id'] not in ids:fail('Include your own assigned team resource.',403)
    return people

def event(db,tid,action,s=None,detail=None):
    row=required(one(db,'SELECT * FROM field_sessions WHERE id=?',(tid,)));snapshot={'session':row,'intervals':many(db,'SELECT * FROM field_intervals WHERE session_id=? ORDER BY started_at,id',(tid,))}
    db.execute('INSERT INTO field_history(session_id,action,actor_id,membership_id,snapshot,detail,created_at) VALUES(?,?,?,?,?,?,?)',(tid,action,s['subject'] if s else None,s['membership_id'] if s else None,json.dumps(snapshot),json.dumps(detail or {}),now()))

def log_event(db,lid,action,s,reason=''):
    row=required(one(db,'SELECT * FROM field_logs WHERE id=?',(lid,)))
    db.execute('INSERT INTO field_log_history(log_id,action,actor_id,membership_id,snapshot,reason,created_at) VALUES(?,?,?,?,?,?,?)',(lid,action,s['subject'],s['membership_id'],json.dumps(row),reason,now()))

def intervals(db,tid):return [{'start':r['started_at'],'end':r['ended_at'] or r['last_seen_at']} for r in many(db,'SELECT * FROM field_intervals WHERE session_id=? ORDER BY started_at,id',(tid,)) if (r['ended_at'] or r['last_seen_at'])>r['started_at']]

def release_locks(db,tid):db.execute('DELETE FROM field_resource_locks WHERE session_id=?',(tid,))
def lock_people(db,tid,people):
    for p in people:
        if db.execute('SELECT 1 FROM field_resource_locks WHERE resource_id=?',(p['id'],)).fetchone():fail('A selected person already has a running timer on another job. Pause or resolve it first.')
        db.execute('INSERT INTO field_resource_locks VALUES(?,?)',(p['id'],tid))

def interrupt(db,row,reason):
    db.execute('UPDATE field_intervals SET ended_at=last_seen_at,end_reason=? WHERE session_id=? AND ended_at IS NULL',(reason,row['id']))
    db.execute("UPDATE field_sessions SET state='interrupted',version=version+1,updated_at=? WHERE id=?",(now(),row['id']));release_locks(db,row['id']);event(db,row['id'],'interrupted',detail={'reason':reason,'cutoff':'last confirmed server timestamp'})

def interrupt_open(store,reason):
    with store.registry() as registry:businesses=[r['id'] for r in many(registry,'SELECT id FROM businesses')]
    for bid in businesses:
        with store.tenant(bid) as db:
            db.execute('BEGIN IMMEDIATE')
            for row in many(db,"SELECT * FROM field_sessions WHERE state='running'"):interrupt(db,row,reason)

class Clock:
    def __call__(self):return datetime.now(timezone.utc),time.monotonic()

class FieldService:
    def __init__(self,app):self.app=app;self.boot=uid()
    def tick(self):
        utc,mono=self.app.state.field_clock();return utc.astimezone(timezone.utc),float(mono)
    def sweep(self,db):
        if not db.in_transaction:db.execute('BEGIN IMMEDIATE')
        utc,mono=self.tick()
        for row in many(db,"SELECT * FROM field_sessions WHERE state='running'"):
            i=required(one(db,'SELECT * FROM field_intervals WHERE session_id=? AND ended_at IS NULL',(row['id'],)));gap=(utc-instant(i['last_seen_at'])).total_seconds()
            if i['boot_id']!=self.boot or gap<0 or abs(gap-(mono-i['last_monotonic']))>2 or gap>SILENCE:interrupt(db,row,'server clock change or lost heartbeat')
    def begin(self,db,s,row,people):
        if db.execute('SELECT COUNT(*) FROM field_intervals WHERE session_id=?',(row['id'],)).fetchone()[0]>=100:fail('A timer supports up to 100 intervals. Stop it and start a new record.')
        utc,mono=self.tick();lock_people(db,row['id'],people)
        db.execute('INSERT INTO field_intervals VALUES(?,?,?,?,NULL,?,?,NULL)',(uid(),row['id'],stamp(utc),stamp(utc),self.boot,mono))
    def prepare(self,db,s,job_id,tid=None):
        if tid:timer_access(db,s,job_id,tid,True)
        else:access(db,s,job_id)
        self.sweep(db);db.commit()


def timer_projection(db,row,owner):
    resources=json.loads(row['resources']);raw=intervals(db,row['id']);reviewed=json.loads(row['reviewed_intervals']) if row['reviewed_intervals'] else None
    preview=[{'resource_name':p['name'],'work_date':day,'actual_ms':ms,'minutes':(ms+30000)//60000,'hourly_cost_pence':p['hourly_cost_pence'],'cost_pence':labour_cost((ms+30000)//60000,p['hourly_cost_pence'])} for p in resources for day,ms in split_days(reviewed or raw).items()] if owner else []
    return {'labour_preview':preview,'id':row['id'],'job_id':row['job_id'],'state':row['state'],'note':row['note'],'version':row['version'],'team_size':len(resources),'resources':resources if owner else [{k:p[k] for k in ('id','name','role')} for p in resources],'confirmed_ms':duration_ms(raw),'raw_intervals':raw,'reviewed_intervals':reviewed,'review_reason':row['review_reason'] if owner else '','generation':row['generation'],'created_at':row['created_at'],'updated_at':row['updated_at']}

def log_projection(db,row,owner,s):
    fields=('id','job_id','kind','content','work_date','quantity','unit','reviewed','archived','version','created_at','updated_at');result={k:row[k] for k in fields};result['photo_ids']=json.loads(row['photo_ids']);result['converted']=bool(one(db,'SELECT log_id FROM field_material_links WHERE log_id=?',(row['id'],)));result['editable']=not row['archived'] and not result['converted'] and (owner or row['author_id']==s['subject'] and not row['reviewed']);return result

def check_photos(db,s,jid,ids):
    for pid in ids:photo_access(db,s,jid,pid)

def converted_guard(db,tid):
    if one(db,"SELECT id FROM document_sources WHERE source_kind='field' AND source_id=? AND state IN ('reserved','issued')",(tid,)):fail('This field work is reserved or included in a document. Resolve that document before correcting the work.')

def archive_conversion(db,s,tid):
    for link in many(db,'SELECT time_id FROM field_time_links WHERE session_id=? AND active=1',(tid,)):
        row=one(db,'SELECT * FROM time_entries WHERE id=?',(link['time_id'],))
        db.execute('UPDATE time_entries SET archived=1,version=version+1,updated_at=? WHERE id=?',(now(),link['time_id']))
        workforce_log(db,s,'time','field.correction_archived',row,one(db,'SELECT * FROM time_entries WHERE id=?',(link['time_id'],)))
    db.execute('UPDATE field_time_links SET active=0 WHERE session_id=?',(tid,))

def validate_intervals(db,row,selected,utc):
    if not selected or len(selected)>100:fail('Select at least one positive interval for review.',422)
    selected=sorted(selected,key=lambda i:i['start']);total=0
    for n,i in enumerate(selected):
        if instant(i['end'])>utc or i['end']<=i['start']:fail('Reviewed work must end after its start and cannot end in the future.',422)
        if n and i['start']<selected[n-1]['end']:fail('Reviewed intervals cannot overlap.',422)
        total+=duration_ms([i])
    if total>7*24*3600*1000:fail('Split work longer than seven actual days into separate records.',422)
    people={r['id'] for r in json.loads(row['resources'])}
    for other in many(db,"SELECT * FROM field_sessions WHERE id<>? AND state!='discarded'",(row['id'],)):
        if not people & {r['id'] for r in json.loads(other['resources'])}:continue
        values=json.loads(other['reviewed_intervals']) if other['reviewed_intervals'] else [{'start':i['started_at'],'end':i['ended_at'] or stamp(utc)} for i in many(db,'SELECT * FROM field_intervals WHERE session_id=?',(other['id'],))]
        if any(a['start']<b['end'] and b['start']<a['end'] for a in selected for b in values):fail('These times overlap another field record for a selected person. Review the other record first.')
    return selected



def update_log(db,s,job_id,log_id,payload):
    row=required(one(db,'SELECT * FROM field_logs WHERE id=? AND job_id=?',(log_id,job_id)))
    if not log_projection(db,row,s['role']=='owner',s)['editable']:fail('Only the author can edit an unreviewed note; converted or archived notes are preserved.',403)
    if row['version']!=payload.version:fail('This field log changed. Reload before saving.')
    if s['role']=='owner' and not payload.reason:fail('Record a correction reason.',422)
    check_photos(db,s,job_id,payload.photo_ids);fields=payload.model_dump(exclude={'version','reason'});fields['photo_ids']=json.dumps(fields['photo_ids'])
    db.execute('UPDATE field_logs SET '+','.join(k+'=?' for k in fields)+',reviewed=0,version=version+1,updated_at=? WHERE id=?',(*fields.values(),now(),log_id));log_event(db,log_id,'edited',s,payload.reason);return {'version':payload.version+1}

def mount_field(app,store,session,once):
    app.state.field_clock=Clock();service=FieldService(app);app.state.field_service=service;interrupt_open(store,'server restarted; owner review required')
    def mutate(request,payload,jid,action,tid=None,before_cache=None):
        s=session(request,csrf=True)
        with store.tenant(s['business_id']) as db:
            service.prepare(db,s,jid,tid)
            if before_cache:before_cache(db,s)
            def run():
                if tid:timer_access(db,s,jid,tid,True)
                else:access(db,s,jid)
                return action(db,s)
            return once(db,request,payload.model_dump(),run)

    @app.get('/api/field/jobs')
    def jobs(request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            service.sweep(db);rows=many(db,'SELECT j.id,j.customer_id,j.service,j.status,j.scheduled_date,c.name,c.address FROM jobs j JOIN customers c ON c.id=j.customer_id')
            return [{'id':j['id'],'service':j['service'],'status':j['status'],'scheduled_date':j['scheduled_date'],'name':j['name'],'address':j['address'],'active':j['status'] in ACTIVE,'needs_review':db.execute("SELECT COUNT(*) FROM field_sessions WHERE job_id=? AND state IN ('interrupted','stopped','reviewed')",(j['id'],)).fetchone()[0]} for j in rows if (j['status'] in ACTIVE or s['role']=='owner' and (db.execute('SELECT 1 FROM field_sessions WHERE job_id=?',(j['id'],)).fetchone() or db.execute('SELECT 1 FROM field_logs WHERE job_id=?',(j['id'],)).fetchone())) and (s['role']=='owner' or assigned(db,s,j['id']))]

    @app.get('/api/field/jobs/{job_id}')
    def detail(job_id:str,request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            job=access(db,s,job_id);service.sweep(db)
            people=many(db,'SELECT id,name,role FROM team_resources WHERE archived=0 ORDER BY name')
            if s['role']=='staff':people=[p for p in people if assigned(db,{**s,'resource_id':p['id']},job_id)]
            return {'job':{k:job[k] for k in ('id','status','service')},'resources':people,'own_resource_id':s.get('resource_id'),'server_time':stamp(service.tick()[0]),'logs':[log_projection(db,r,s['role']=='owner',s) for r in many(db,'SELECT * FROM field_logs WHERE job_id=? AND customer_id=? ORDER BY created_at DESC,id',(job_id,job['customer_id'])) if s['role']=='owner' or not r['archived']], 'timers':[dict(timer_projection(db,r,s['role']=='owner'),editable=s['role']=='owner' or r['author_id']==s['subject']) for r in many(db,'SELECT * FROM field_sessions WHERE job_id=? AND customer_id=? ORDER BY created_at DESC,id',(job_id,job['customer_id']))]}

    @app.post('/api/field/jobs/{job_id}/logs')
    def add_log(job_id:str,payload:m.LogFields,request:Request):
        def action(db,s):
            if db.execute('SELECT COUNT(*) FROM field_logs').fetchone()[0]>=5000:fail('The local limit of 5,000 field logs is reached.')
            job=access(db,s,job_id,True);check_photos(db,s,job_id,payload.photo_ids);lid=uid();data=payload.model_dump();data['photo_ids']=json.dumps(data['photo_ids'])
            db.execute('INSERT INTO field_logs(id,job_id,customer_id,author_id,membership_id,kind,content,work_date,quantity,unit,photo_ids,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',(lid,job_id,job['customer_id'],s['subject'],s['membership_id'],data['kind'],data['content'],data['work_date'],data['quantity'],data['unit'],data['photo_ids'],now(),now()));log_event(db,lid,'created',s);return {'id':lid,'version':1}
        return mutate(request,payload,job_id,action)

    @app.put('/api/field/jobs/{job_id}/logs/{log_id}')
    def edit_log(job_id:str,log_id:str,payload:m.LogEdit,request:Request):
        def action(db,s):
            return update_log(db,s,job_id,log_id,payload)
        return mutate(request,payload,job_id,action)

    @app.post('/api/field/jobs/{job_id}/logs/{log_id}/state/{action}')
    def log_state(job_id:str,log_id:str,action:str,payload:m.Review,request:Request):
        def change(db,s):
            if action not in ('review','archive','restore'):fail('Unknown log action.',404)
            row=required(one(db,'SELECT * FROM field_logs WHERE id=? AND job_id=?',(log_id,job_id)))
            if row['version']!=payload.version:fail('This log changed. Reload.')
            if action=='review' and row['archived']:fail('Restore the log before reviewing.')
            db.execute('UPDATE field_logs SET reviewed=?,archived=?,version=version+1,updated_at=? WHERE id=?',(int(action=='review'),int(action=='archive'),now(),log_id));log_event(db,log_id,action,s,payload.reason);return {'version':payload.version+1}
        return mutate(request,payload,job_id,change)

    @app.get('/api/field/jobs/{job_id}/logs/{log_id}/history')
    def log_history(job_id:str,log_id:str,request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            access(db,s,job_id);required(one(db,'SELECT id FROM field_logs WHERE id=? AND job_id=?',(log_id,job_id)));return [{**r,'snapshot':json.loads(r['snapshot'])} for r in many(db,'SELECT * FROM field_log_history WHERE log_id=? ORDER BY id DESC',(log_id,))]

    @app.post('/api/field/jobs/{job_id}/logs/{log_id}/material')
    def material(job_id:str,log_id:str,payload:m.MaterialConversion,request:Request):
        def action(db,s):
            from .material_rules import price,total_pence
            from .materials import log as material_log
            row=required(one(db,'SELECT * FROM field_logs WHERE id=? AND job_id=?',(log_id,job_id)))
            if row['version']!=payload.version or row['kind']!='materials' or row['archived'] or not row['reviewed']:fail('Review the current material observation first.')
            if one(db,'SELECT log_id FROM field_material_links WHERE log_id=?',(log_id,)):fail('This observation already has a material record. Correct it under Materials.')
            mid=uid();cost=price(payload.cost_price);date=row['work_date'];status=payload.status
            db.execute('INSERT INTO job_materials(id,job_id,name,quantity,unit,cost_pence,total_pence,supplier,supplier_sku,notes,status,ordered_date,received_date,supplier_links,created_at,updated_at) VALUES(?,?,?,?,?,?,?,\'\',\'\',?,?,?,?,\'[]\',?,?)',(mid,job_id,row['content'][:180],row['quantity'],row['unit'],cost,total_pence(row['quantity'],cost),'Reviewed field observation: '+payload.reason,status,date if status!='to_order' else '',date if status=='received' else '',now(),now()))
            db.execute('INSERT INTO field_material_links VALUES(?,?)',(log_id,mid));db.execute('UPDATE field_logs SET version=version+1,updated_at=? WHERE id=?',(now(),log_id));log_event(db,log_id,'material.converted',s,payload.reason);material_log(db,s['subject'],'material','field.converted',None,one(db,'SELECT * FROM job_materials WHERE id=?',(mid,)));return {'id':mid}
        return mutate(request,payload,job_id,action)

    @app.post('/api/field/jobs/{job_id}/timers')
    def start(job_id:str,payload:m.TimerStart,request:Request):
        def action(db,s):
            if db.execute('SELECT COUNT(*) FROM field_sessions').fetchone()[0]>=2000:fail('The local limit of 2,000 timer records is reached.')
            job=access(db,s,job_id,True);people=team(db,s,job_id,payload.resource_ids);tid=uid();utc,_=service.tick()
            db.execute("INSERT INTO field_sessions(id,job_id,customer_id,author_id,membership_id,resources,state,note,created_at,updated_at) VALUES(?,?,?,?,?,?,'running',?,?,?)",(tid,job_id,job['customer_id'],s['subject'],s['membership_id'],json.dumps(people),payload.note,stamp(utc),stamp(utc)));service.begin(db,s,{'id':tid},people);event(db,tid,'started',s);return {'id':tid,'version':1}
        return mutate(request,payload,job_id,action,before_cache=lambda db,s:(access(db,s,job_id,True),team(db,s,job_id,payload.resource_ids)))

    @app.post('/api/field/jobs/{job_id}/timers/{timer_id}/events/{action}')
    def timer_action(job_id:str,timer_id:str,action:str,payload:m.Version,request:Request):
        def change(db,s):
            row=timer_access(db,s,job_id,timer_id,True)
            if row['version']!=payload.version:fail('This timer changed. Refresh before continuing.')
            if action not in ('heartbeat','pause','stop','resume'):fail('Unknown timer action.',404)
            if action=='resume':
                access(db,s,job_id,True)
                if row['state']!='paused':fail('Only a paused timer can resume. Start a new timer after a stop; interrupted work requires owner review.')
                people=team(db,s,job_id,[p['id'] for p in json.loads(row['resources'])]);service.begin(db,s,row,people);state='running'
            else:
                if row['state'] not in (('running','paused') if action=='stop' else ('running',)):fail('This timer is not running. Interrupted work needs owner review.')
                if s['role']=='staff':team(db,s,job_id,[p['id'] for p in json.loads(row['resources'])])
                utc,mono=service.tick();state='running' if action=='heartbeat' else 'paused' if action=='pause' else 'stopped'
                db.execute('UPDATE field_intervals SET last_seen_at=?,last_monotonic=?,ended_at=?,end_reason=? WHERE session_id=? AND ended_at IS NULL',(stamp(utc),mono,None if action=='heartbeat' else stamp(utc),None if action=='heartbeat' else action,timer_id))
                if action!='heartbeat':release_locks(db,timer_id)
            db.execute('UPDATE field_sessions SET state=?,version=version+1,updated_at=? WHERE id=?',(state,now(),timer_id));event(db,timer_id,action,s);return timer_projection(db,one(db,'SELECT * FROM field_sessions WHERE id=?',(timer_id,)),s['role']=='owner')
        return mutate(request,payload,job_id,change,timer_id)

    @app.post('/api/field/jobs/{job_id}/timers/{timer_id}/reviewed')
    def review(job_id:str,timer_id:str,payload:m.TimerReview,request:Request):
        def action(db,s):
            row=timer_access(db,s,job_id,timer_id,True)
            if row['version']!=payload.version or row['state'] not in ('stopped','interrupted','reviewed','converted'):fail('Stop or resolve the current timer before review.')
            converted_guard(db,timer_id)
            selected=[i.model_dump() for i in payload.intervals] if payload.intervals is not None else intervals(db,timer_id);selected=validate_intervals(db,row,selected,service.tick()[0]);archive_conversion(db,s,timer_id)
            db.execute("UPDATE field_sessions SET state='reviewed',reviewed_intervals=?,review_reason=?,version=version+1,updated_at=? WHERE id=?",(json.dumps(selected),payload.reason,now(),timer_id));event(db,timer_id,'reviewed',s,{'reason':payload.reason,'raw_intervals_retained':True});return {'version':payload.version+1}
        return mutate(request,payload,job_id,action,timer_id)

    @app.post('/api/field/jobs/{job_id}/timers/{timer_id}/discard')
    def discard(job_id:str,timer_id:str,payload:m.Review,request:Request):
        def action(db,s):
            row=timer_access(db,s,job_id,timer_id,True)
            if row['version']!=payload.version or row['state']=='running':fail('Pause/stop the current timer before discarding it.')
            converted_guard(db,timer_id);archive_conversion(db,s,timer_id);release_locks(db,timer_id)
            db.execute("UPDATE field_sessions SET state='discarded',version=version+1,updated_at=? WHERE id=?",(now(),timer_id));event(db,timer_id,'discarded',s,{'reason':payload.reason});return {'version':payload.version+1}
        return mutate(request,payload,job_id,action,timer_id)

    @app.post('/api/field/jobs/{job_id}/timers/{timer_id}/convert')
    def convert(job_id:str,timer_id:str,payload:m.Review,request:Request):
        def action(db,s):
            row=timer_access(db,s,job_id,timer_id,True)
            if row['version']!=payload.version or row['state']!='reviewed':fail('Review the current stopped work before recording labour.')
            selected=validate_intervals(db,row,json.loads(row['reviewed_intervals']),service.tick()[0]);days=split_days(selected);generation=row['generation']+1;ids=[]
            for resource in json.loads(row['resources']):
                # Archived people may be explicitly included by an owner reviewing past work.
                required(one(db,'SELECT id FROM team_resources WHERE id=?',(resource['id'],)))
                for date,ms in days.items():
                    minutes=(ms+30000)//60000
                    if not 1<=minutes<=1440:fail('Each person/day must round to 1–1440 minutes. Review tiny or very long intervals first.',422)
                    day_capacity(db,resource['id'],date,minutes);tid=uid();rate=resource['hourly_cost_pence']
                    db.execute('INSERT INTO time_entries(id,job_id,resource_id,resource_snapshot,work_date,duration_minutes,hourly_cost_pence,cost_pence,note,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(tid,job_id,resource['id'],json.dumps(resource),date,minutes,rate,labour_cost(minutes,rate),'Reviewed field work: '+payload.reason,now(),now()))
                    db.execute('INSERT INTO field_time_links VALUES(?,?,?,?,?,1)',(tid,timer_id,generation,resource['id'],date));workforce_log(db,s,'time','field.converted',None,one(db,'SELECT * FROM time_entries WHERE id=?',(tid,)));ids.append(tid)
            db.execute("UPDATE field_sessions SET state='converted',generation=?,version=version+1,updated_at=? WHERE id=?",(generation,now(),timer_id));event(db,timer_id,'converted',s,{'time_ids':ids,'reason':payload.reason,'rounding':'nearest minute per person/London date; half up'});return {'time_ids':ids,'version':payload.version+1}
        return mutate(request,payload,job_id,action,timer_id)

    @app.get('/api/field/jobs/{job_id}/timers/{timer_id}/history')
    def timer_history(job_id:str,timer_id:str,request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            timer_access(db,s,job_id,timer_id);return [{**r,'snapshot':json.loads(r['snapshot']),'detail':json.loads(r['detail'])} for r in many(db,'SELECT * FROM field_history WHERE session_id=? ORDER BY id DESC',(timer_id,))]
