"""Local captures -> private feedback -> exact consent -> deliberate publication.

A feedback code is not a portal credential. It never travels in a URL. Only
feedback-specific context is returned; no contact, job or financial details.
"""
import json,re,secrets,time
from html import escape
from pathlib import Path
from fastapi import HTTPException,Request,Query
from fastapi.responses import HTMLResponse
from .store import one,many,uid,now,digest,audit
from .feedback_models import SCHEMA,CONSENT_WORDING,CONSENT_VERSION,Prepare,Action,Reissue,Publish,Code,Submission,Permission,TokenSubmission,TokenPermission
from .sales_outputs import snapshot_hash


def fail(message,status=409):raise HTTPException(status,message)

def request_row(db,rid):
    r=one(db,'SELECT * FROM feedback_requests WHERE id=?',(rid,))
    if not r:fail('Feedback request not found.',404)
    return r

def snapshot(db,rid):
    result={'request':request_row(db,rid),'response':one(db,'SELECT * FROM feedback_responses WHERE request_id=?',(rid,)),'consent':one(db,'SELECT * FROM feedback_consents WHERE request_id=?',(rid,)),'publication':one(db,'SELECT * FROM feedback_publications WHERE request_id=?',(rid,))}
    if result['response']:result['response']['answers']=json.loads(result['response']['answers'])
    if result['publication']:result['publication']['projection']=json.loads(result['publication']['projection'])
    return result

def record(db,rid,action,actor,detail=''):
    v=snapshot(db,rid)
    db.execute('INSERT INTO feedback_history(request_id,version,action,actor,snapshot,detail,created_at) VALUES(?,?,?,?,?,?,?)',(rid,v['request']['version'],action,actor,json.dumps(v,ensure_ascii=False),detail,now()))
    audit(db,'feedback.'+action,rid)
    return {'id':rid,'version':v['request']['version']}

def bump(db,rid):db.execute('UPDATE feedback_requests SET version=version+1 WHERE id=?',(rid,))

def current_links(db,r):
    from .workflow import feedback_basis,metadata
    c=one(db,'SELECT version FROM customers WHERE id=?',(r['customer_id'],));j=one(db,'SELECT customer_id,version,status,service FROM jobs WHERE id=?',(r['job_id'],))
    archived=metadata(db,r['job_id'])['archived'] if db.execute("SELECT 1 FROM sqlite_schema WHERE name='job_workflow'").fetchone() else False
    return bool(c and j and c['version']==r['customer_version'] and j['customer_id']==r['customer_id'] and feedback_basis(db,r['job_id'])==r['job_version'] and j['status']=='completed' and j['service']==r['service'] and not archived)

def active(db,r):return r['state'] in ('pending','responded') and current_links(db,r)

def check_version(r,version):
    if r['version']!=version:fail('This feedback changed. Reload and review before continuing.')

def hide(db,rid):db.execute("UPDATE feedback_publications SET state='hidden' WHERE request_id=?",(rid,))

def suspend(db,rid):
    hide(db,rid)
    db.execute("UPDATE feedback_consents SET state='suspended',version=version+1,created_at=? WHERE request_id=? AND state='approved'",(now(),rid))

def invalidate(db,*,actor,detail,customer_id=None,job_id=None):
    column,value=('customer_id',customer_id) if customer_id else ('job_id',job_id)
    for r in many(db,'SELECT * FROM feedback_requests WHERE '+column+'=?',(value,)):
        if r['state'] in ('pending','responded'):
            db.execute("UPDATE feedback_requests SET state='revoked',access_version=access_version+1,version=version+1 WHERE id=?",(r['id'],));suspend(db,r['id']);record(db,r['id'],'record.changed',actor,detail)

def capture_path(store,tid):
    if not re.fullmatch(r'[a-f0-9]{32}',tid):fail('Capture not found.',404)
    folder=store.root/'.feedback-capture'
    if folder.is_symlink():fail('Unsafe capture folder.')
    folder.mkdir(exist_ok=True,mode=0o700);folder.chmod(0o700)
    path=folder/(tid+'.json')
    if path.is_symlink():fail('Unsafe capture file.')
    return path

def issue(db,store,r,slug):
    tid=uid();code=secrets.token_urlsafe(32)
    db.execute('INSERT INTO feedback_tokens VALUES(?,?,?,?,?,?)',(tid,r['id'],digest(code),r['access_version'],r['expires'],now()))
    body={'mode':'local-capture','to':r['delivery_email'],'path':'/feedback/'+slug,'code':code,'expires':r['expires'],'purpose':'Feedback only. No email sent or address verified. Paste this code on the local feedback page.'}
    with capture_path(store,tid).open('x') as f:
        capture_path(store,tid).chmod(0o600);json.dump(body,f)
    return tid

def token_request(db,code):
    t=one(db,'SELECT * FROM feedback_tokens WHERE token_hash=? AND expires>?',(digest(code),time.time()))
    r=request_row(db,t['request_id']) if t else None
    if not r or t['access_version']!=r['access_version'] or r['expires']<=time.time() or not active(db,r):fail('This feedback code is unavailable, expired or revoked.',401)
    return r

def projection(consent,response):
    return {'display_name':consent['display_name'],'statement':consent['statement'],'rating':response['answers']['overall_rating'] if consent['share_rating'] else None}

def public_reviews(db):
    output=[]
    for rid, in db.execute("SELECT request_id FROM feedback_publications WHERE state='published' ORDER BY published_at DESC,request_id"):
        v=snapshot(db,rid);r=v['request'];c=v['consent'];p=v['publication']
        if active(db,r) and r['state']=='responded' and c and c['state']=='approved' and p['consent_version']==c['version'] and p['projection']==projection(c,v['response']):output.append(p['projection'])
    return {'label':'Selected customer testimonials','selection_notice':'These are customer-approved statements selected by the business, not all feedback. No overall average is calculated.','count':len(output),'testimonials':output}

def customer_view(db,r):
    v=snapshot(db,r['id']);c=v['consent'];response=v['response']
    history=[]
    for h in many(db,'SELECT action,created_at,snapshot FROM feedback_history WHERE request_id=? ORDER BY version',(r['id'],)):
        old=json.loads(h['snapshot']);history.append({'action':h['action'],'created_at':h['created_at'],'consent':{k:old['consent'][k] for k in ('state','display_name','statement','share_rating','version')} if old['consent'] else None})
    return {'version':r['version'],'state':r['state'],'expires':r['expires'],'can_submit':active(db,r) and r['state']=='pending' and r['expires']>time.time(),'can_approve':active(db,r) and r['state']=='responded','response':{'answers':response['answers'],'created_at':response['created_at']} if response else None,'consent':{k:c[k] for k in ('state','display_name','statement','share_rating','version','wording_version','created_at')} if c else None,'published':bool(v['publication'] and v['publication']['state']=='published' and active(db,r) and c['state']=='approved'),'history':history,'consent_wording':CONSENT_WORDING,'wording_version':CONSENT_VERSION}

def save_consent(db,rid,choice,withdraw=False):
    old=one(db,'SELECT version FROM feedback_consents WHERE request_id=?',(rid,));version=old['version']+1 if old else 1
    state='approved' if choice.allow_public else 'withdrawn' if withdraw else 'private'
    db.execute('INSERT INTO feedback_consents VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(request_id) DO UPDATE SET state=excluded.state,display_name=excluded.display_name,statement=excluded.statement,share_rating=excluded.share_rating,version=excluded.version,wording_version=excluded.wording_version,created_at=excluded.created_at',(rid,state,choice.display_name,choice.statement,int(choice.share_rating),version,CONSENT_VERSION,now()))
    hide(db,rid)

def submit(db,r,payload,actor):
    check_version(r,payload.version)
    if not active(db,r) or r['expires']<=time.time() or r['state']!='pending':fail('This request is closed or already answered.')
    db.execute('INSERT INTO feedback_responses VALUES(?,?,?,?,?)',(r['id'],json.dumps(payload.answers.model_dump(),ensure_ascii=False),actor,r['version'],now()))
    save_consent(db,r['id'],payload.consent)
    db.execute("UPDATE feedback_requests SET state='responded',version=version+1 WHERE id=?",(r['id'],))
    return record(db,r['id'],'submitted',actor)

def change_consent(db,r,payload,actor):
    check_version(r,payload.version)
    if not one(db,'SELECT 1 FROM feedback_responses WHERE request_id=?',(r['id'],)):fail('Submit feedback before changing publication permission.')
    if payload.consent.allow_public and (not active(db,r) or r['state']!='responded'):fail('This request is closed. Ask the business to prepare renewed access.')
    save_consent(db,r['id'],payload.consent,withdraw=True);bump(db,r['id'])
    return record(db,r['id'],'consent.approved' if payload.consent.allow_public else 'consent.withdrawn',actor)

def preview(db,rid):
    v=snapshot(db,rid);r=v['request'];c=v['consent']
    allowed=bool(active(db,r) and r['state']=='responded' and c and c['state']=='approved')
    p=projection(c,v['response']) if allowed else None
    return {'version':r['version'],'projection':p,'projection_hash':snapshot_hash({'version':r['version'],'consent_version':c['version'],'projection':p}) if p else None,'can_publish':allowed,'label':'Selected customer testimonial','notice':'Only this exact customer-approved text and optional rating will be displayed. Private answers are excluded.'}


def eligibility_rows(db):
    from .workflow import metadata
    result=[]
    for j in many(db,'SELECT j.id,j.service,j.status,j.version AS job_version,j.customer_id,c.version AS customer_version,c.name,c.email FROM jobs j JOIN customers c ON c.id=j.customer_id ORDER BY j.created_at DESC,j.rowid DESC'):
        previous=many(db,'SELECT id,state,expires FROM feedback_requests WHERE job_id=? ORDER BY created_at DESC,rowid DESC',(j['id'],))
        reasons=[]
        from .customer_review import active_access
        if not active_access(db,j['customer_id']):reasons.append('Archived or merged customer records do not prepare new feedback.')
        if metadata(db,j['id'])['archived']:reasons.append('Archived jobs do not prepare new feedback.')
        if j['status']!='completed':reasons.append('Mark the job completed before preparing feedback.')
        if not j['email'].endswith('.example'):reasons.append('Local captures require an invented .example email address.')
        if any(p['state']=='responded' or (p['state']=='pending' and p['expires']>time.time()) for p in previous):reasons.append('An existing request or response needs review. Archive or revoke it before preparing another.')
        result.append({**j,'previous':previous,'eligible':not reasons,'reasons':reasons})
    return result

def mount_feedback(app,store,session,once,business,portal_session,rate_limit,static_dir):
    def owner(request):return session(request,csrf=request.method!='GET')
    def portal_guard(db,s,rid):
        from .portal_access import check_session
        check_session(db,s);r=request_row(db,rid)
        if r['customer_id']!=s['subject']:fail('Feedback request not found.',404)
        return r
    def throttle(request,slug,code):
        rate_limit('feedback-ip:'+request.client.host,80)
        rate_limit('feedback-code:'+slug+':'+digest(code),40)

    @app.get('/api/feedback/eligibility')
    def eligibility(request:Request):
        s=owner(request)
        with store.tenant(s['business_id']) as db:
            return eligibility_rows(db)

    @app.get('/api/feedback')
    def listing(request:Request,state:str=Query(default='all',pattern='^(all|pending|responded|revoked|archived|published)$')):
        s=owner(request)
        with store.tenant(s['business_id']) as db:
            all_rows=[snapshot(db,r['id']) for r in many(db,'SELECT id FROM feedback_requests ORDER BY created_at DESC,rowid DESC')]
            responses=[v['response'] for v in all_rows if v['response']]
            stats={'requests':len(all_rows),'responses':len(responses),'published':public_reviews(db)['count'],'average_overall_all_responses':round(sum(v['answers']['overall_rating'] for v in responses)/len(responses),2) if responses else None,'recommend_count':sum(v['answers']['recommend'] for v in responses),'scope':'All saved responses, including archived and private. Not a public rating.'}
            rows=[]
            for v in all_rows:
                r=v['request'];p=bool(v['publication'] and v['publication']['state']=='published' and active(db,r))
                if state=='published' and not p or state not in ('all','published') and r['state']!=state:continue
                c=one(db,'SELECT name FROM customers WHERE id=?',(r['customer_id'],))
                rows.append({'id':r['id'],'customer_id':r['customer_id'],'customer_name':c['name'],'job_id':r['job_id'],'service':r['service'],'state':r['state'],'expires':r['expires'],'version':r['version'],'published':p,'consent_state':v['consent']['state'] if v['consent'] else None,'created_at':r['created_at']})
            return {'requests':rows,'stats':stats}

    @app.post('/api/feedback/requests')
    def prepare(payload:Prepare,request:Request):
        s=owner(request)
        with store.tenant(s['business_id']) as db:
            def action():
                c=one(db,'SELECT * FROM customers WHERE id=?',(payload.customer_id,));j=one(db,'SELECT * FROM jobs WHERE id=? AND customer_id=?',(payload.job_id,payload.customer_id))
                if not c or not j:fail('Exact customer and job not found.',404)
                from .customer_review import active
                active(db,payload.customer_id)
                if c['version']!=payload.customer_version or j['version']!=payload.job_version:fail('Customer or job changed. Review eligibility again.')
                from .workflow import feedback_basis,metadata
                if metadata(db,j['id'])['archived']:fail('Archived jobs cannot prepare new feedback.')
                if j['status']!='completed' or not c['email'].endswith('.example'):fail('Use a completed job and an invented .example email address.',422)
                prior=many(db,'SELECT state,expires FROM feedback_requests WHERE job_id=?',(j['id'],))
                if any(p['state']=='responded' or (p['state']=='pending' and p['expires']>time.time()) for p in prior):fail('Review the existing request or response before preparing another.')
                if prior and len(payload.reason.strip())<5:fail('Explain why another request is needed.',422)
                rid=uid();db.execute('INSERT INTO feedback_requests VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(rid,j['id'],c['id'],c['version'],feedback_basis(db,j['id']),c['email'],j['service'],payload.reason,'pending',1,1,time.time()+payload.expires_days*86400,s['subject'],now()))
                issue(db,store,request_row(db,rid),s['slug']);return record(db,rid,'prepared',s['subject'],'Local capture prepared. No delivery or email verification.')
            return once(db,request,payload.model_dump(),action)

    @app.get('/api/feedback/requests/{rid}')
    def detail(rid:str,request:Request):
        s=owner(request)
        with store.tenant(s['business_id']) as db:
            v=snapshot(db,rid);v['preview']=preview(db,rid);v['history']=many(db,'SELECT version,action,actor,snapshot,detail,created_at FROM feedback_history WHERE request_id=? ORDER BY version',(rid,))
            for h in v['history']:h['snapshot']=json.loads(h['snapshot'])
            v['delivery']='Captured locally; not sent; address not verified.';return v

    @app.get('/api/feedback/requests/{rid}/capture')
    def capture(rid:str,request:Request):
        s=owner(request)
        with store.tenant(s['business_id']) as db:
            r=request_row(db,rid)
            t=one(db,'SELECT * FROM feedback_tokens WHERE request_id=? AND access_version=? AND expires>? ORDER BY rowid DESC LIMIT 1',(rid,r['access_version'],time.time()))
            if not t or not active(db,r):fail('No active local capture. Review and reissue access if appropriate.',404)
            path=capture_path(store,t['id'])
            if not path.is_file():fail('Capture unavailable. Reissue explicitly.',404)
            value=json.loads(path.read_text())
            if digest(value.get('code',''))!=t['token_hash']:fail('Capture does not match the saved credential.')
            return value

    @app.post('/api/feedback/requests/{rid}/reissue')
    def reissue(rid:str,payload:Reissue,request:Request):
        s=owner(request)
        with store.tenant(s['business_id']) as db:
            def action():
                r=request_row(db,rid);check_version(r,payload.version)
                if r['state']=='archived' or not current_links(db,r):fail('This request cannot be reopened. Review the current job/customer and prepare a new request.')
                others=many(db,'SELECT state,expires FROM feedback_requests WHERE job_id=? AND id!=?',(r['job_id'],rid))
                if any(p['state']=='responded' or (p['state']=='pending' and p['expires']>time.time()) for p in others):fail('Another active request or response already exists for this job.')
                state='responded' if one(db,'SELECT 1 FROM feedback_responses WHERE request_id=?',(rid,)) else 'pending'
                db.execute('UPDATE feedback_requests SET state=?,version=version+1,access_version=access_version+1,expires=? WHERE id=?',(state,time.time()+payload.expires_days*86400,rid));issue(db,store,request_row(db,rid),s['slug']);return record(db,rid,'reissued',s['subject'],payload.reason)
            return once(db,request,payload.model_dump(),action)

    def close(rid,payload,request,state):
        s=owner(request)
        with store.tenant(s['business_id']) as db:
            def action():
                r=request_row(db,rid);check_version(r,payload.version)
                if r['state']=='archived':fail('This request is archived.')
                db.execute('UPDATE feedback_requests SET state=?,version=version+1,access_version=access_version+1 WHERE id=?',(state,rid));suspend(db,rid);return record(db,rid,state,s['subject'],payload.reason)
            return once(db,request,payload.model_dump(),action)
    @app.post('/api/feedback/requests/{rid}/revoke')
    def revoke(rid:str,payload:Action,request:Request):return close(rid,payload,request,'revoked')
    @app.post('/api/feedback/requests/{rid}/archive')
    def archive(rid:str,payload:Action,request:Request):return close(rid,payload,request,'archived')

    @app.get('/api/feedback/requests/{rid}/preview')
    def read_preview(rid:str,request:Request):
        s=owner(request)
        with store.tenant(s['business_id']) as db:return preview(db,rid)
    @app.post('/api/feedback/requests/{rid}/publish')
    def publish(rid:str,payload:Publish,request:Request):
        s=owner(request)
        with store.tenant(s['business_id']) as db:
            def action():
                p=preview(db,rid);r=request_row(db,rid);check_version(r,payload.version)
                if not p['can_publish'] or p['projection_hash']!=payload.projection_hash:fail('Publication permission or preview changed. Review the latest customer-approved statement.')
                c=one(db,'SELECT version FROM feedback_consents WHERE request_id=?',(rid,))
                db.execute("INSERT INTO feedback_publications VALUES(?,?,?,'published',?) ON CONFLICT(request_id) DO UPDATE SET consent_version=excluded.consent_version,projection=excluded.projection,state='published',published_at=excluded.published_at",(rid,c['version'],json.dumps(p['projection'],ensure_ascii=False),now()));bump(db,rid);return record(db,rid,'published',s['subject'])
            return once(db,request,payload.model_dump(),action)
    @app.post('/api/feedback/requests/{rid}/hide')
    def unpublish(rid:str,payload:Action,request:Request):
        s=owner(request)
        with store.tenant(s['business_id']) as db:
            def action():
                r=request_row(db,rid);check_version(r,payload.version);hide(db,rid);bump(db,rid);return record(db,rid,'hidden',s['subject'],payload.reason)
            return once(db,request,payload.model_dump(),action)

    @app.post('/api/feedback/{slug}/access')
    def access(slug:str,payload:Code,request:Request):
        throttle(request,slug,payload.code);b=business(slug)
        with store.tenant(b['id']) as db:
            r=token_request(db,payload.code);view=customer_view(db,r)
            view['business_name']=json.loads(one(db,'SELECT published FROM site')['published'])['business_name'];return view
    @app.post('/api/feedback/{slug}/submit')
    def token_submit(slug:str,payload:TokenSubmission,request:Request):
        throttle(request,slug,payload.code);b=business(slug)
        with store.tenant(b['id']) as db:
            def guard():request.state.operation_subject='feedback:'+token_request(db,payload.code)['id']
            return once(db,request,payload.model_dump(),lambda:submit(db,token_request(db,payload.code),payload,'feedback-token:'+token_request(db,payload.code)['id']),check=guard)
    @app.post('/api/feedback/{slug}/consent')
    def token_consent(slug:str,payload:TokenPermission,request:Request):
        throttle(request,slug,payload.code);b=business(slug)
        with store.tenant(b['id']) as db:
            def guard():request.state.operation_subject='feedback:'+token_request(db,payload.code)['id']
            return once(db,request,payload.model_dump(),lambda:change_consent(db,token_request(db,payload.code),payload,'feedback-token:'+token_request(db,payload.code)['id']),check=guard)

    @app.get('/api/portal/{slug}/feedback')
    def portal_list(slug:str,request:Request):
        s=portal_session(request,slug)
        with store.tenant(s['business_id']) as db:
            return [{'id':r['id'],'job_id':r['job_id'],'service':r['service'],**customer_view(db,r)} for r in many(db,'SELECT * FROM feedback_requests WHERE customer_id=? ORDER BY created_at DESC,rowid DESC',(s['subject'],))]
    @app.get('/api/portal/{slug}/feedback/{rid}')
    def portal_detail(slug:str,rid:str,request:Request):
        s=portal_session(request,slug)
        with store.tenant(s['business_id']) as db:return customer_view(db,portal_guard(db,s,rid))
    @app.post('/api/portal/{slug}/feedback/{rid}/submit')
    def portal_submit(slug:str,rid:str,payload:Submission,request:Request):
        s=portal_session(request,slug,True)
        with store.tenant(s['business_id']) as db:return once(db,request,payload.model_dump(),lambda:submit(db,portal_guard(db,s,rid),payload,'portal:'+s['subject']),check=lambda:portal_guard(db,s,rid))
    @app.post('/api/portal/{slug}/feedback/{rid}/consent')
    def portal_consent(slug:str,rid:str,payload:Permission,request:Request):
        s=portal_session(request,slug,True)
        with store.tenant(s['business_id']) as db:return once(db,request,payload.model_dump(),lambda:change_consent(db,portal_guard(db,s,rid),payload,'portal:'+s['subject']),check=lambda:portal_guard(db,s,rid))

    @app.get('/api/public/{slug}/testimonials')
    def public(slug:str):
        with store.tenant(business(slug)['id']) as db:return public_reviews(db)
    if static_dir:
        @app.get('/site/{slug}/reviews/selected')
        def review_page(slug:str):
            with store.tenant(business(slug)['id']) as db:
                value=public_reviews(db);name=json.loads(one(db,'SELECT published FROM site')['published'])['business_name']
            cards=''.join('<article><h2>'+escape(t['display_name'])+'</h2><p>'+escape(t['statement']).replace('\n','<br>')+'</p>'+('<p>'+str(t['rating'])+' / 5</p>' if t['rating'] is not None else '')+'</article>' for t in value['testimonials']) or '<p>No testimonials have been published.</p>'
            return HTMLResponse('<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>Selected testimonials · '+escape(name)+'</title><style>body{font:18px/1.6 system-ui;max-width:860px;margin:2rem auto;padding:1rem;color:#153c30}article{border-top:1px solid #ddd;padding:1rem 0;overflow-wrap:anywhere}h1{line-height:1.2}a{color:inherit}</style></head><body><a href="/site/'+escape(slug)+'">← '+escape(name)+'</a><main><h1>'+value['label']+'</h1><p>'+value['selection_notice']+'</p>'+cards+'</main><p>Local prototype · No emails sent</p></body></html>')
