"""Recompute complete bounded previews before applying any calendar mutation."""
from datetime import date,timedelta
import hashlib,json
from fastapi import HTTPException
from .store import one,many
from . import recurrence_models as m
from .recurrence_dates import applicable,scheduled,candidate,invalid_months,exclusions,covered_end
from .scheduling import decode,prepare

MAX_ROWS=1000

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

def canonical(value):return json.dumps(value,sort_keys=True,separators=(',',':'))
def digest(value):return hashlib.sha256(canonical(value).encode()).hexdigest()
def dates(start,end):
    value=date.fromisoformat(start);last=date.fromisoformat(end)
    while value<=last:yield value.isoformat();value+=timedelta(days=1)

def rules_for(db,cid):return [{**r,'content':json.loads(r['content'])} for r in many(db,'SELECT * FROM recurrence_rules WHERE contract_id=? ORDER BY revision',(cid,))]
def occurrences_for(db,cid):
    return {r['nominal_date']:{**r,'rule_snapshot':json.loads(r['rule_snapshot']),'exclusion_ids':json.loads(r['exclusion_ids'])} for r in many(db,'SELECT * FROM recurrence_occurrences WHERE contract_id=? ORDER BY nominal_date',(cid,))}

def check_binding(db,contract,*,active_job=False):
    if contract['kind']=='job':
        job=required(one(db,'SELECT * FROM jobs WHERE id=?',(contract['job_id'],)))
        from .customer_moves import retained_association
        if job['customer_id']!=contract['customer_id'] or (contract.get('id') and retained_association(db,job['id'],'recurring_contracts',contract['id'])):fail('The original contract customer/job link changed. Its retained definition cannot restart after a reviewed transfer, including a return.')
        if active_job and job['status'] in ('completed','cancelled','on_hold'):fail('This job is completed, cancelled or on hold. Review the job before scheduling more work.')
        return job
    return None

def check_rule(db,contract,rule,effective,today):
    if effective<today or effective>rule.until_date:fail('Apply a rule from today or a future date within its finite horizon.',422)
    if contract['kind']=='job' and rule.whole_team:fail('Name the people for customer work.',422)
    for rid in rule.resource_ids:
        person=required(one(db,'SELECT * FROM team_resources WHERE id=?',(rid,)))
        if person['archived']:fail('A selected person is archived. Review the named resources.')

def work_reasons(db,contract):
    job=check_binding(db,contract)
    if not job:return []
    reasons=[]
    if job['status'] in ('in_progress','completed','cancelled','on_hold'):reasons.append('Job stage is '+job['status']+'.')
    for table,label in [('field_sessions','timer history'),('field_logs','field logs'),('time_entries','labour'),('job_photos','photos'),('staff_updates','staff work notes'),('job_materials','materials'),('quotes','quotes'),('invoices','invoices'),('document_sources','document source history')]:
        if one(db,'SELECT id FROM '+table+' WHERE job_id=? LIMIT 1',(job['id'],)):reasons.append('This job has '+label+'.')
    return reasons

def protected_reasons(contract_reasons,old,booking,today):
    reasons=list(contract_reasons)
    if old['nominal_date']<today or booking and booking['start_date']<today:reasons.insert(0,'Past visit is retained.')
    return reasons

def booking_fields(booking):return {key:booking[key] for key in (*m.BookingFields.model_fields,'resource_snapshot','start_utc','end_utc')}
def active_conflict(a,b):return a['start_utc']<b['end_utc'] and b['start_utc']<a['end_utc'] and (a['whole_team'] or b['whole_team'] or bool(set(a['resource_ids'])&set(b['resource_ids'])))

def build(db,s,contract,payload,today):
    if contract['version']!=payload.version:fail('The recurring contract changed. Refresh and preview again.')
    operation=payload.operation;op=operation.model_dump();cid=contract['id'];working=dict(contract);rules=rules_for(db,cid);blocks=many(db,'SELECT * FROM recurrence_blocks WHERE contract_id=? ORDER BY id',(cid,));existing=occurrences_for(db,cid)
    calendars={b['id']:decode(b) for b in many(db,'SELECT * FROM calendar_bookings ORDER BY id')};reasons=work_reasons(db,contract);rows=[];errors=[];new_rule=None;new_block=None;block_change=None;metadata={};skipped=[]
    def row_for(day,rule=None):
        old=existing.get(day);booking=calendars.get(old['booking_id']) if old else None
        return {'nominal_date':day,'occurrence_id':old['id'] if old else None,'booking_id':booking['id'] if booking else None,'rule_id':rule['id'] if rule else old['rule_id'] if old else None,'rule_revision':rule['revision'] if rule else None,'status':'retained','action':'keep','reasons':[],'change':False,'target':None,'current_booking':booking},old,booking
    def set_target(row,rule,state,ids,reason,fields=None,override=0):
        row.update(change=True,status='ready',action='schedule' if state=='active' and not row['booking_id'] else 'restore' if state=='active' and row['current_booking']['archived'] else 'update' if state=='active' else state,
            target={'state':state,'overridden':override,'rule_id':rule['id'],'rule_snapshot':rule['content'],'exclusion_ids':ids,'reason':reason,'candidate':fields})
    if op['action']=='state':
        target=op['state'];state=contract['state']
        if target=='restore':
            if state!='retired':errors.append('Only a retired definition needs restoration.')
            target='paused'
        elif state=='retired':errors.append('Restore a retired definition as paused before activating or editing it.')
        if target==state:errors.append('The contract is already in this state.')
        if target=='active':
            try:check_binding(db,contract,active_job=True)
            except HTTPException as e:errors.append(e.detail)
        metadata={'state':target}
        for day,old in existing.items():
            if old['state']=='active':row,_,_=row_for(day);row['reasons']=['Existing bookings remain. Pause/retire changes future generation only.'];rows.append(row)
    else:
        if contract['state']=='retired':errors.append('Restore the retired contract as paused first.')
        if op['action'] in ('generate','override','restore') and contract['state']!='active':errors.append('Activate the contract before scheduling or restoring visits.')
        if op['action'] in ('generate','override','restore','revise'):
            try:check_binding(db,contract,active_job=op['action']!='revise')
            except HTTPException as e:errors.append(e.detail)
        days=set();start=end=None
        if op['action']=='generate':
            start,end=op['start_date'],op['end_date']
            if start<today:errors.append('Generate from today or a future date; past visits are never backfilled.')
            days.update(day for day in dates(start,end) if (r:=applicable(rules,day)) and scheduled(r,day));days.update(day for day in existing if start<=day<=end)
        elif op['action']=='revise':
            try:check_rule(db,contract,operation.rule,op['effective_from'],today)
            except HTTPException as e:errors.append(e.detail)
            metadata={k:op[k] for k in ('name','service_label','note')};working.update(metadata)
            new_rule={'id':digest({'contract':cid,'version':contract['version'],'operation':op,'kind':'rule'})[:32],'contract_id':cid,'revision':contract['version']+1,'effective_from':op['effective_from'],'content':op['rule']}
            rules.append(new_rule);start,end=op['effective_from'],op['window_end']
            days.update(day for day in dates(start,end) if (r:=applicable(rules,day)) and scheduled(r,day));days.update(day for day in existing if day>=start)
        elif op['action']=='block':
            start,end=op['start_date'],op['end_date'];new_block={'id':digest({'contract':cid,'version':contract['version'],'operation':op,'kind':'block'})[:32],'contract_id':cid,'start_date':start,'end_date':end,'reason':op['reason'],'active':1,'version':1};blocks.append(new_block)
            days.update(day for day in dates(start,end) if (r:=applicable(rules,day)) and scheduled(r,day))
            days.update(day for day,old in existing.items() if exclusions([new_block],day,calendars.get(old['booking_id'])))
        elif op['action']=='unblock':
            block=required(next((b for b in blocks if b['id']==op['block_id']),None))
            if block['version']!=op['block_version'] or not block['active']:fail('The exclusion block changed. Refresh before reviewing removal.')
            block_change={'id':block['id'],'active':0,'version':block['version']+1};start,end=block['start_date'],block['end_date'];block['active']=0
            days.update(day for day,old in existing.items() if block['id'] in old['exclusion_ids'] or block['start_date']<=day<=block['end_date'] or (b:=calendars.get(old['booking_id'])) and block['start_date']<=covered_end(b) and block['end_date']>=b['start_date'])
        else:
            day=op['nominal_date'];rule=applicable(rules,day);row,old,booking=row_for(day,rule)
            if not old and (not rule or not scheduled(rule,day)):fail('Choose a date in this contract pattern, or an existing occurrence.',422)
            if op['occurrence_version']!=(old['version'] if old else 0) or op['booking_version']!=(booking['version'] if booking else None):fail('The visit changed. Refresh its versions and preview again.')
            held=protected_reasons(reasons,old or {'nominal_date':day},booking,today)
            if day<today or booking and booking['start_date']<today:row['reasons'].append('Past generated visits remain read-only.')
            elif held and (day==today or booking and booking['start_date']==today):row['reasons'].append('Today\'s visit shares job work/history without exact visit links. It is review-blocked; changing its schedule cannot establish whether work has started.')
            elif held and not op['acknowledge_history']:row['reasons'].append('Review this future visit as unstarted, acknowledging that all existing job work/history will be retained.')
            else:
                try:
                    if op['action']=='exclude':
                        ids=exclusions(blocks,day,booking)
                        if not ids:fail('No active exclusion covers this visit. Add and review a date block first.')
                        if old and old['state']=='cancelled':fail('A manual cancellation remains cancelled until separately restored.')
                        set_target(row,rule or next(r for r in rules if r['id']==old['rule_id']),'excluded',ids,op['reason'],override=int(bool(old and old['overridden'])))
                    elif op['action']=='cancel':
                        if old and old['state']=='cancelled':row['reasons']=['This occurrence is already deliberately cancelled.']
                        else:set_target(row,rule or next(r for r in rules if r['id']==old['rule_id']),'cancelled',[],op['reason'],override=int(bool(old and old['overridden'])))
                    else:
                        if op['action']=='override':
                            if operation.booking.kind!=contract['kind'] or operation.booking.job_id!=contract['job_id']:fail('Keep the original contract job and booking kind.',422)
                            fields=prepare(db,operation.booking);chosen_rule=rule or next(r for r in rules if r['id']==old['rule_id']);override=1
                        elif booking:
                            fields=booking_fields(booking);chosen_rule=next(r for r in rules if r['id']==old['rule_id']);override=int(old['overridden'] or not rule or chosen_rule['id']!=rule['id'] or not scheduled(rule,day))
                            for rid in fields['resource_ids']:
                                if required(one(db,'SELECT archived FROM team_resources WHERE id=?',(rid,)))['archived']:fail('Restore the saved team member or explicitly override the visit.')
                        else:
                            if not rule or not scheduled(rule,day):fail('The current pattern does not schedule this date. Use an explicit single-date override.')
                            fields=candidate(db,working,rule,day);chosen_rule=rule;override=0
                        if fields['start_date']<today:fail('Schedule an override or restoration from today onward.',422)
                        if reasons and fields['start_date']==today:fail('Work/history on this job has no exact visit link today. Choose a clearly future unstarted date; today stays review-blocked.')
                        ids=exclusions(blocks,day,fields)
                        if ids:fail('An active exclusion still covers the nominal or actual visit date. Review every applicable block first.')
                        if old and old['state']=='active' and op['action']=='restore':row['reasons']=['The occurrence is already active.']
                        else:set_target(row,chosen_rule,'active',[],op['reason'],fields,override)
                except HTTPException as e:row['reasons'].append(str(e.detail))
            if row['reasons'] and not (old and ((op['action']=='cancel' and old['state']=='cancelled') or (op['action']=='restore' and old['state']=='active'))):row['status']='error'
            row['history_reasons']=held;rows.append(row)
        if len(days)>MAX_ROWS:fail('This preview exceeds 1,000 occurrences. Use a smaller window or review a smaller contract.',422)
        for day in sorted(days):
            rule=applicable(rules,day);row,old,booking=row_for(day,rule);ids=exclusions(blocks,day);fields=None;reason='';target='active';issue=None
            if old and old['state']=='cancelled':row['status']='cancelled';row['reasons']=['Deliberately cancelled; regeneration does not recreate it.'];rows.append(row);continue
            if old and old['overridden']:row['status']='retained_override';row['reasons']=['Manual override retained; review this visit separately.'];rows.append(row);continue
            if old and op['action']=='generate' and old['state']=='active':row['status']='already_scheduled';row['reasons']=['Existing occurrence retained; no duplicate booking.'];rows.append(row);continue
            if day<today and not old:row['status']='past';row['reasons']=['Past date is not generated.'];rows.append(row);continue
            if not rule or not scheduled(rule,day):target='excluded';reason='The current rule no longer schedules this nominal date.'
            elif ids:target='excluded';reason='Covered by active contract date exclusions.'
            elif contract['state']!='active' and not (op['action']=='revise' and old and old['state']=='active'):
                row['status']='retained_paused';row['reasons']=['Definition is paused; existing bookings remain and excluded visits do not resume.'];rows.append(row);continue
            else:
                try:
                    check_binding(db,contract,active_job=True);fields=candidate(db,working,rule,day);ids=exclusions(blocks,day,fields)
                    if ids:target='excluded';reason='Covered by an exclusion on a nominal or actual visit date.';fields=None
                except HTTPException as e:
                    if rule['content']['gap_policy']=='skip' and 'does not exist in UK time' in str(e.detail):target='excluded';reason='Explicit clock-gap skip: '+str(e.detail)
                    else:issue=str(e.detail)
            if issue:row['status']='error';row['reasons']=[issue];rows.append(row);continue
            if op['action']=='block' and not old:row['status']='future_excluded';row['reasons']=['The saved block will exclude this date when a generation window is reviewed.'];rows.append(row);continue
            if old:
                hold=protected_reasons(reasons,old,booking,today)
                if hold:row['status']='retained_history';row['reasons']=hold+['No existing booking or work record will change in this bulk action.'];rows.append(row);continue
                if old['state']==target and target=='excluded' and old['exclusion_ids']==ids and old['reason']==reason and old['rule_id']==(rule['id'] if rule else old['rule_id']):row['status']='excluded';row['reasons']=[reason];rows.append(row);continue
                if target=='active' and old['state']=='active' and booking and booking_fields(booking)==fields and old['rule_id']==rule['id']:row['status']='already_scheduled';row['reasons']=['Saved visit already matches.'];rows.append(row);continue
            if not old and not rule:continue
            chosen=rule or next(r for r in rules if r['id']==old['rule_id'])
            set_target(row,chosen,target,ids,reason or 'Owner-reviewed '+op['action'],fields);rows.append(row)
        if start and end and op['action'] in ('generate','revise'):skipped=invalid_months(rules,start,end)
    if len(rows)>MAX_ROWS:fail('This preview exceeds 1,000 visits. Review a smaller contract.',422)
    changed_ids={r['booking_id'] for r in rows if r['change'] and r['booking_id']}
    retained=[b for b in calendars.values() if not b['archived'] and b['id'] not in changed_ids]
    proposed=[r for r in rows if r['change'] and r['target']['state']=='active']
    for n,row in enumerate(proposed):
        value=row['target']['candidate']
        conflicts=[b for b in retained if active_conflict(value,b)]
        if conflicts:row['status']='error';row['reasons'].extend('Conflicts with '+b['title']+' on '+b['start_date']+' ('+b['id']+').' for b in conflicts)
        for prior in proposed[:n]:
            if active_conflict(value,prior['target']['candidate']):
                row['status']=prior['status']='error';row['reasons'].append('Conflicts with proposed occurrence '+prior['nominal_date']+'.');prior['reasons'].append('Conflicts with proposed occurrence '+row['nominal_date']+'.')
    new_count=sum(r['change'] and not r['occurrence_id'] for r in rows)
    if db.execute('SELECT COUNT(*) FROM recurrence_occurrences').fetchone()[0]+new_count>5000:errors.append('The local limit of 5,000 retained occurrences would be exceeded.')
    changes=sum(r['change'] for r in rows);retained_count=sum(r['status'] in ('retained_override','retained_history') for r in rows)
    has_changes=bool(changes or metadata or new_rule or new_block or block_change)
    plan={'contract_id':cid,'contract_version':contract['version'],'today_london':today,'operation':op,'rows':rows,'skipped_months':skipped,'errors':errors,'metadata':metadata,'new_rule':new_rule,'new_block':new_block,'block_change':block_change,'change_count':changes,'retained_for_separate_review':retained_count,'can_confirm':has_changes and not errors and all(r['status']!='error' for r in rows),'atomic':'Every shown ready change commits together, or none does. Protected/overridden visits are retained only with explicit acknowledgement.'}
    # Deliberately conservative dependency fence: any intervening calendar or resource
    # version, definition/exception or linked job/customer edit requires a fresh preview.
    dependency={'contract':contract,'rules':rules_for(db,cid),'blocks':many(db,'SELECT * FROM recurrence_blocks WHERE contract_id=? ORDER BY id',(cid,)),'occurrences':existing,'calendar':calendars,'resources':many(db,'SELECT * FROM team_resources ORDER BY id'),'job':check_binding(db,contract),'customer':one(db,'SELECT * FROM customers WHERE id=?',(contract['customer_id'],)) if contract['customer_id'] else None,'work_reasons':reasons,'actor':s['subject'],'membership':s['membership_id'],'business':s['business_id']}
    if contract['job_id']:
        dependency['work_versions']={table:many(db,'SELECT * FROM '+table+' WHERE job_id=? ORDER BY id',(contract['job_id'],)) for table in ('field_sessions','field_logs','time_entries','job_photos','staff_updates','job_materials','quotes','invoices','document_sources')}
    plan['preview_hash']=digest({'dependencies':dependency,'plan':plan});return plan
