"""Fact-based owner pipeline and review queue. No delivery or background jobs."""
import json
from datetime import date,datetime,timedelta
from zoneinfo import ZoneInfo
from fastapi import HTTPException,Request,Query
from .store import one,many,now,digest,audit
from .workflow_models import SCHEMA,Metadata,WorkflowEdit,Settings,SettingsEdit,Review,calendar_day

LONDON=ZoneInfo('Europe/London')
CLOSED={'completed','cancelled'}
JOB_FIELDS=('id','customer_id','service','message','status','notes','scheduled_date','version')

def today():return datetime.now(LONDON).date().isoformat()
def local_day(stamp):return datetime.fromisoformat(stamp).astimezone(LONDON).date().isoformat()
def fail(message,status=409):raise HTTPException(status,message)
def job(db,jid):
    j=one(db,'SELECT * FROM jobs WHERE id=?',(jid,))
    if not j:fail('Job not found.',404)
    return j
def metadata(db,jid):
    row=one(db,'SELECT content FROM job_workflow WHERE job_id=?',(jid,))
    return json.loads(row['content']) if row else Metadata().model_dump()
def feedback_basis(db,jid):
    # Older pinned recovery formats have no workflow table. Their binding is
    # still the original job version; migration never revives old permission.
    if db.execute("SELECT 1 FROM sqlite_schema WHERE name='job_workflow' AND type='table'").fetchone():
        row=one(db,'SELECT feedback_version FROM job_workflow WHERE job_id=?',(jid,))
        if row:return row['feedback_version']
    return job(db,jid)['version']
def workflow_snapshot(db,jid):
    j=job(db,jid);w=one(db,'SELECT * FROM job_workflow WHERE job_id=?',(jid,))
    return {'job':{k:j[k] for k in JOB_FIELDS},'metadata':json.loads(w['content']),'feedback_version':w['feedback_version'],'version':w['version']}
def record_job(db,jid,actor):
    v=workflow_snapshot(db,jid)
    db.execute('INSERT INTO job_workflow_history(job_id,version,job_version,actor,snapshot,created_at) VALUES(?,?,?,?,?,?)',(jid,v['version'],v['job']['version'],actor,json.dumps(v,ensure_ascii=False),now()))
def register_job(db,jid):
    from .customer_review import exists,register
    if exists(db):register(db,job(db,jid)['customer_id'])
    if not one(db,'SELECT job_id FROM job_workflow WHERE job_id=?',(jid,)):
        db.execute('INSERT INTO job_workflow VALUES(?,?,?,1)',(jid,json.dumps(Metadata().model_dump()),job(db,jid)['version']))
        record_job(db,jid,'initialisation')
def setup(db):
    db.executescript(SCHEMA)
    for row in many(db,'SELECT id FROM jobs'):register_job(db,row['id'])
    db.execute('INSERT OR IGNORE INTO opportunity_settings VALUES(1,?,1)',(json.dumps(Settings().model_dump()),))
def enrich(db,rows):
    return [{**r,'workflow':metadata(db,r['id'])} for r in rows]

def edit_job(db,jid,payload,actor):
    old=job(db,jid)
    if old['version']!=payload.version:fail('This job changed. Reload and review before saving.')
    if payload.status=='scheduled' and not payload.scheduled_date:fail('Choose a target date for a scheduled job.',422)
    register_job(db,jid);before=metadata(db,jid)
    after=payload.metadata.model_dump() if isinstance(payload,WorkflowEdit) else dict(before)
    if after['last_contact_date']>today() or after['completed_date']>today():fail('Recorded contact and completion dates cannot be in the future.',422)
    if payload.status=='completed' and old['status']!='completed' and not after['completed_date']:after['completed_date']=today()
    if payload.status!='completed':after['completed_date']=''
    if after['archived'] and payload.status not in CLOSED:fail('Complete or cancel the job before archiving it; unarchive it before reopening.')
    if after['archived'] and not before['archived']:
        guards=[("SELECT 1 FROM field_sessions WHERE job_id=? AND state IN ('running','paused','interrupted','stopped','reviewed')",(jid,)),
                ("SELECT 1 FROM recurring_contracts WHERE job_id=? AND state IN ('active','paused')",(jid,)),
                ('SELECT 1 FROM calendar_bookings WHERE job_id=? AND archived=0 AND end_date>=?',(jid,today())),
                ("SELECT 1 FROM document_sources WHERE job_id=? AND state='reserved'",(jid,)),
                ("SELECT 1 FROM invoices WHERE job_id=? AND status='draft'",(jid,))]
        if any(db.execute(sql,args).fetchone() for sql,args in guards):fail('Review open capture, repeat work, future bookings or draft billing before archiving this job.')
    material=old['status']!=payload.status or old['scheduled_date']!=payload.scheduled_date or any(before[k]!=after[k] for k in ('completed_date','archived'))
    db.execute('UPDATE jobs SET status=?,notes=?,scheduled_date=?,version=version+1 WHERE id=?',(payload.status,payload.notes,payload.scheduled_date,jid))
    db.execute('UPDATE job_workflow SET content=?,feedback_version=CASE WHEN ? THEN ? ELSE feedback_version END,version=version+1 WHERE job_id=?',(json.dumps(after),material,old['version']+1,jid))
    if material:
        from .feedback import invalidate
        invalidate(db,job_id=jid,actor=actor,detail='Job stage, work date, completion date or archive status changed; review feedback permission again.')
    record_job(db,jid,actor);audit(db,'job.workflow_updated',jid)
    return {'ok':True,'id':jid,'version':old['version']+1}

def settings(db):
    row=one(db,'SELECT * FROM opportunity_settings WHERE id=1')
    return {**json.loads(row['content']),'version':row['version']}
def pipeline(db,*,q='',stage='all',priority='all',tag='',archive='active',due='all'):
    rows=enrich(db,many(db,'SELECT j.*,c.name,c.email,c.phone,c.address,c.version AS customer_version FROM jobs j JOIN customers c ON c.id=j.customer_id ORDER BY j.created_at DESC,j.rowid DESC'))
    total=len(rows);needle=q.casefold().strip();current=today()
    # Keep each filter independent; the due filter cannot bypass search/status.
    def selected(j):
        m=j['workflow'];text=' '.join(str(j[k]) for k in ('id','customer_id','name','email','phone','address','service','message','notes'))+' '+m['next_action']+' '+' '.join(m['tags'])
        due_ok=due=='all' or bool(m['next_action_date']) and (m['next_action_date']<current if due=='overdue' else m['next_action_date']<=current)
        return (not needle or needle in text.casefold()) and (stage=='all' or j['status']==stage) and (priority=='all' or m['priority']==priority) and (not tag or tag in m['tags']) and (archive=='all' or m['archived']==(archive=='archived')) and due_ok
    selected_rows=[j for j in rows if selected(j)]
    return {'jobs':selected_rows,'matching':len(selected_rows),'stored':total,'stages':{s:sum(j['status']==s for j in selected_rows) for s in ('new','site_visit','accepted','scheduled','in_progress','completed','on_hold','cancelled')},'tags':sorted({t for j in rows for t in j['workflow']['tags']}),'today':current}

def candidates(db):
    from .feedback import eligibility_rows
    cfg=settings(db);day=today();date_today=date.fromisoformat(day)
    jobs=pipeline(db,archive='all')['jobs'];by_id={j['id']:j for j in jobs};result={}
    active_recurrence={r['customer_id'] for r in many(db,"SELECT customer_id FROM recurring_contracts WHERE state IN ('active','paused') AND customer_id IS NOT NULL")}
    from .customer_review import active_access
    def excluded(j):return not active_access(db,j['customer_id']) or j['workflow']['archived'] or j['workflow']['followup_excluded'] or bool(set(j['workflow']['tags'])&set(cfg['excluded_tags']))
    def add(kind,j,generation,reason,*,quote=None,related=None,age=None):
        # A reviewed source generation stays historical after an identity transfer.
        # Changing the customer ID alone must never revive its prior decision.
        from .customer_moves import epoch, retained_association
        ownership_epoch=epoch(db,j['id']);current_generation=generation+':ownership:'+ownership_epoch if ownership_epoch else generation
        for prior in many(db,'SELECT id,customer_id,generation FROM opportunity_reviews WHERE kind=? AND job_id=? AND quote_id IS ?', (kind,j['id'],quote['id'] if quote else None)):
            if prior['generation'].split(':ownership:')[0]==generation and (prior['customer_id']!=j['customer_id'] or retained_association(db,j['id'],'opportunity_reviews',prior['id'])):return
        generation=current_generation
        source=quote['id'] if quote else j['id'];key=digest(kind+':'+source+':'+j['customer_id']+':'+generation)[:32]
        row={'id':key,'kind':kind,'generation':generation,'job_id':j['id'],'customer_id':j['customer_id'],'quote_id':quote['id'] if quote else None,'customer_name':j['name'],'service':j['service'],'reference':quote['reference'] if quote else None,'source_version':quote['version'] if quote else j['version'],'reason':reason,'age_days':age,'total_pence':quote['total_pence'] if quote else None,'due_date':j['workflow']['next_action_date'] if kind=='next_action' else '', 'related_job_id':related['id'] if related else None,'postcode_group':j['workflow']['postcode_group'] if kind=='postcode' else '', 'eligible':True}
        context={**row,'job_version':j['version'],'customer_version':j['customer_version'],'settings_version':cfg['version'],'related_version':related['version'] if related else None}
        # Age and today's date are display facts, not dismissal generations.
        row['context_hash']=digest(json.dumps(context,sort_keys=True));row['context']=context
        text={'quote':f"Hello {j['name']}, would you like to discuss quote {row['reference']} for {j['service']}?",'repeat':f"Hello {j['name']}, would you like to discuss any further work following {j['service']}?",'review':f"Hello {j['name']}, we would welcome your feedback about {j['service']}. All feedback is welcome; public sharing is optional.",'postcode':f"Hello {j['name']}, we have planned work in postcode group {row['postcode_group']}. Would you like to discuss any work?",'next_action':j['workflow']['next_action']}[kind]
        row['suggested_draft']=text;result[key]=row
    for j in jobs:
        m=j['workflow']
        if not excluded(j) and j['status'] not in CLOSED and m['next_action'] and (not m['next_action_date'] or m['next_action_date']<=day):add('next_action',j,digest(m['next_action']+'|'+m['next_action_date']),m['next_action']+' · '+(m['next_action_date'] or 'No due date saved'))
    for q in many(db,"SELECT q.*,l.shared_at,l.current FROM quotes q JOIN quote_links l ON l.quote_id=q.id WHERE q.status='shared' AND l.current=1"):
        j=by_id[q['job_id']];valid=json.loads(q['snapshot']).get('valid_until','')
        if excluded(j) or j['status'] in CLOSED or j['customer_id']!=q['customer_id'] or j['customer_id'] in active_recurrence or valid and valid<day or not q['shared_at']:continue
        shared=local_day(q['shared_at']);contact=j['workflow']['last_contact_date'];basis=max(shared,contact)
        age=(date_today-date.fromisoformat(basis)).days
        if age>=cfg['quote_days']:add('quote',j,q['id'],f"Current shared quote; {age} days since sharing or the last recorded contact ({basis}).",quote=q,age=age)
    groups={cid:[j for j in jobs if j['customer_id']==cid] for cid in {j['customer_id'] for j in jobs}}
    repeat=[]
    for cid,rows in groups.items():
        if cid in active_recurrence or any(j['status'] not in CLOSED and not j['workflow']['archived'] for j in rows):continue
        complete=[j for j in rows if j['status']=='completed' and j['workflow']['completed_date']]
        if not complete:continue
        j=max(complete,key=lambda j:(j['workflow']['completed_date'],j['created_at'],j['id']))
        if excluded(j):continue
        basis=max(j['workflow']['completed_date'],max(x['workflow']['last_contact_date'] for x in rows));age=(date_today-date.fromisoformat(basis)).days
        if age>=cfg['repeat_days']:
            repeat.append(j);add('repeat',j,j['id']+':'+j['workflow']['completed_date'],f"Latest recorded completed work was {j['workflow']['completed_date']}; {age} days since work or recorded contact. No open job or active/paused repeat contract.",age=age)
    for e in eligibility_rows(db):
        j=by_id[e['id']]
        if e['eligible'] and not e['previous'] and not excluded(j):add('review',j,j['id'],'Completed exact job with no prior feedback request. All ratings are welcome; public permission is separate.')
    if cfg['nearby_enabled']:
        for j in repeat:
            group=j['workflow']['postcode_group']
            if not group:continue
            visits=[v for v in jobs if not excluded(v) and v['id']!=j['id'] and v['customer_id']!=j['customer_id'] and v['status']=='scheduled' and v['workflow']['postcode_group']==group and day<=v['scheduled_date']<=(date_today+timedelta(days=cfg['nearby_horizon_days'])).isoformat()]
            if visits:
                visit=min(visits,key=lambda x:(x['scheduled_date'],x['id']));add('postcode',j,visit['id']+':'+visit['scheduled_date'],f"Same explicitly saved postcode group {group} as planned job {visit['id'][:8]} on {visit['scheduled_date']}. Group match only; no distance or map lookup.",related=visit)
    return result

def queue(db):
    current=candidates(db);saved={r['id']:r for r in many(db,'SELECT * FROM opportunity_reviews')};output=[]
    for key in sorted(set(current)|set(saved)):
        row=current.get(key);r=saved.get(key)
        if row is None:row={**json.loads(r['context']),'eligible':False,'context_hash':None,'suggested_draft':''}
        status=r['state'] if r else 'open'
        effective='open' if status=='snoozed' and r['snooze_until']<=today() else status
        output.append({**row,'state':status,'effective_state':effective,'version':r['version'] if r else 0,'draft':r['draft'] if r else '', 'note':r['note'] if r else '', 'snooze_until':r['snooze_until'] if r else '', 'delivery':'Not confirmed. Drafts and manual action records do not send messages.'})
    return sorted(output,key=lambda r:(r['due_date'] or '9999',r['kind'],r['id']))

def mount_workflow(app,store,session,once):
    @app.get('/api/pipeline')
    def listing(request:Request,q:str=Query('',max_length=200),stage:str=Query('all',pattern='^(all|new|site_visit|accepted|scheduled|in_progress|completed|on_hold|cancelled)$'),priority:str=Query('all',pattern='^(all|low|normal|high|urgent)$'),tag:str=Query('',max_length=40),archive:str=Query('active',pattern='^(active|archived|all)$'),due:str=Query('all',pattern='^(all|due|overdue)$')):
        s=session(request)
        with store.tenant(s['business_id']) as db:return pipeline(db,q=q,stage=stage,priority=priority,tag=tag,archive=archive,due=due)
    @app.put('/api/jobs/{jid}/workflow')
    def update(jid:str,payload:WorkflowEdit,request:Request):
        s=session(request,csrf=True)
        with store.tenant(s['business_id']) as db:return once(db,request,payload.model_dump(),lambda:edit_job(db,jid,payload,s['subject']))
    @app.get('/api/jobs/{jid}/workflow-history')
    def history(jid:str,request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            job(db,jid);return [{**r,'snapshot':json.loads(r['snapshot'])} for r in many(db,'SELECT version,job_version,actor,snapshot,created_at FROM job_workflow_history WHERE job_id=? ORDER BY version',(jid,))]
    @app.get('/api/opportunity-settings')
    def get_settings(request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:return settings(db)
    @app.put('/api/opportunity-settings')
    def put_settings(payload:SettingsEdit,request:Request):
        s=session(request,csrf=True)
        with store.tenant(s['business_id']) as db:
            def act():
                if settings(db)['version']!=payload.version:fail('Settings changed. Reload before saving.')
                content=json.dumps(payload.model_dump(exclude={'version'}));version=payload.version+1
                db.execute('UPDATE opportunity_settings SET content=?,version=? WHERE id=1',(content,version))
                db.execute('INSERT INTO opportunity_settings_history VALUES(?,?,?,?)',(version,content,s['subject'],now()));audit(db,'opportunities.settings_updated','1');return {'version':version}
            return once(db,request,payload.model_dump(),act)
    @app.get('/api/opportunities')
    def opportunities(request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            rows=queue(db);return {'items':rows,'today':today(),'ready':sum(r['eligible'] and r['effective_state'] in ('open','prepared') for r in rows),'settings':settings(db)}
    @app.get('/api/opportunities/{oid}')
    def detail(oid:str,request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            row=next((r for r in queue(db) if r['id']==oid),None)
            if not row:fail('Opportunity not found.',404)
            return {'item':row,'history':[{**r,'snapshot':json.loads(r['snapshot'])} for r in many(db,'SELECT version,action,actor,snapshot,created_at FROM opportunity_history WHERE opportunity_id=? ORDER BY version',(oid,))]}
    @app.post('/api/opportunities/{oid}/actions')
    def action(oid:str,payload:Review,request:Request):
        s=session(request,csrf=True)
        with store.tenant(s['business_id']) as db:
            def act():
                row=candidates(db).get(oid)
                if not row:
                    if one(db,'SELECT id FROM opportunity_reviews WHERE id=?',(oid,)):fail('This item is no longer eligible. Review its source records.')
                    fail('Opportunity not found.',404)
                if row['context_hash']!=payload.context_hash:fail('The source facts changed. Reload and review this item.')
                prior=one(db,'SELECT * FROM opportunity_reviews WHERE id=?',(oid,));version=prior['version'] if prior else 0
                if version!=payload.version:fail('This review changed. Reload before continuing.')
                if prior and prior['state'] in ('dismissed','completed') and payload.action!='reopen':fail('Reopen this review before taking another action.')
                if payload.action=='reopen' and (not prior or prior['state'] not in ('dismissed','completed','snoozed')):fail('Only a closed or snoozed review can be reopened.')
                if payload.action=='snooze' and not today()<payload.snooze_until<=(date.fromisoformat(today())+timedelta(days=366)).isoformat():fail('Snooze to a future UK date within one year.',422)
                state={'prepare':'prepared','complete':'completed','dismiss':'dismissed','snooze':'snoozed','reopen':'open'}[payload.action]
                draft=payload.draft if payload.action=='prepare' else prior['draft'] if prior else ''
                values=(oid,row['kind'],row['job_id'],row['customer_id'],row['quote_id'],row['generation'],state,payload.snooze_until,draft,payload.note,json.dumps(row['context']),version+1,now())
                db.execute('INSERT INTO opportunity_reviews VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET state=excluded.state,snooze_until=excluded.snooze_until,draft=excluded.draft,note=excluded.note,context=excluded.context,version=excluded.version,updated_at=excluded.updated_at',values)
                snapshot=one(db,'SELECT * FROM opportunity_reviews WHERE id=?',(oid,))
                db.execute('INSERT INTO opportunity_history(opportunity_id,version,action,actor,snapshot,created_at) VALUES(?,?,?,?,?,?)',(oid,version+1,payload.action,s['subject'],json.dumps(snapshot),now()));audit(db,'opportunity.'+payload.action,oid)
                return {'id':oid,'version':version+1,'state':state,'delivery':'not_confirmed'}
            return once(db,request,payload.model_dump(),act)
