"""Local memberships and mock account recovery. No network or real delivery.

Global account credentials are private identity. Business membership is the only
staff authority; a calendar resource is never implicitly a login account.
"""
from pathlib import Path
import json
import os
import re
import secrets
import shutil
import sqlite3
import time
import uuid
from typing import Literal
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import Field, field_validator
from .models import Strict, Version, PasswordChange
from .store import uid, now, digest, password_hash, password_matches, one, many

STAFF_COOKIE='shs_demo_staff'
PORTAL_COOKIE='shs_demo_portal'
MAX_BUSINESSES=25
RESERVED={'api','site','preview','portal','workspace','assets','documents','materials','account-access','admin','www','static','health'}


def valid_slug(value):
    return isinstance(value,str) and re.fullmatch(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*',value) is not None and 3<=len(value)<=60 and value not in RESERVED


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


def identity_audit(db,bid,actor,target,action,detail=None):
    db.execute('INSERT INTO identity_audit(business_id,actor_id,target_id,action,detail,created_at) VALUES(?,?,?,?,?,?)',(bid,actor,target,action,json.dumps(detail or {},sort_keys=True),now()))


class BusinessCreate(Strict):
    name:str=Field(min_length=2,max_length=120)
    slug:str=Field(min_length=3,max_length=60)
    area:str=Field(min_length=2,max_length=200)
    @field_validator('slug')
    @classmethod
    def slug_shape(cls,v):
        if not valid_slug(v):raise ValueError('Use a unique lowercase name with letters, numbers and single hyphens.')
        return v


class MembershipChange(Version):
    role:Literal['owner','staff']
    enabled:bool=Field(strict=True)
    resource_id:str|None=Field(default=None,pattern=r'^[a-f0-9]{32}$')


class Invitation(Strict):
    email:str=Field(max_length=180)
    role:Literal['owner','staff']='staff'
    resource_id:str|None=Field(default=None,pattern=r'^[a-f0-9]{32}$')
    @field_validator('email')
    @classmethod
    def invented_email(cls,v):
        if not re.fullmatch(r'[^\s@]+@[^\s@]+\.example',v.lower()):raise ValueError('Use an invented email ending in .example in this local prototype.')
        return v.lower()


class InviteAccept(Strict):
    code:str=Field(min_length=1,max_length=200)
    password:str=Field(min_length=12,max_length=200)
    display_name:str=Field(min_length=2,max_length=100)


class ResetRequest(Strict):
    email:str=Field(min_length=1,max_length=180)


class ResetAccept(Strict):
    code:str=Field(min_length=1,max_length=200)
    new_password:str=Field(min_length=12,max_length=200)


class Profile(Strict):
    display_name:str=Field(min_length=2,max_length=100)


class Switch(Strict):
    membership_id:str=Field(pattern=r'^[a-f0-9]{32}$')


class Setup(Version):
    reviewed_publication_id:int|None=Field(default=None,ge=1,strict=True)
    solo_confirmed:bool=Field(strict=True)
    guide_read:bool=Field(strict=True)


class WorkNote(Strict):
    note:str=Field(min_length=1,max_length=3000)


class Identity:
    def __init__(self,store,rate_limit):
        self.store=store;self.rate_limit=rate_limit
        self.provision_hook=lambda point:None  # failure injection in isolated tests only

    def session(self,request,kind='staff',csrf=False):
        cookie=STAFF_COOKIE if kind=='staff' else PORTAL_COOKIE
        with self.store.registry() as db:
            s=one(db,'SELECT s.*,b.slug,b.name FROM sessions s JOIN businesses b ON b.id=s.business_id WHERE s.token=? AND s.kind=? AND s.expires>? AND b.enabled=1',(digest(request.cookies.get(cookie,'')),kind,time.time()))
            if s and kind=='staff':
                member=one(db,'SELECT m.id AS membership_id,m.role,m.resource_id,m.version AS membership_version,u.email,u.display_name FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.user_id=? AND m.business_id=? AND m.enabled=1 AND u.disabled=0',(s['subject'],s['business_id']))
                if not member:s=None
                else:s.update(member)
        if not s:fail(401,'Please sign in to continue.')
        if kind=='portal':
            from .portal_access import check_session
            with self.store.tenant(s['business_id']) as db:check_session(db,s)
        expected=request.headers.get('x-business-id')
        if expected and expected!=s['business_id']:fail(409,'The active business changed. Reload before continuing.')
        if csrf and not secrets.compare_digest(request.headers.get('x-csrf-token',''),s['csrf']):fail(403,'Please refresh your session before saving.')
        request.state.operation_subject=kind+':'+s['business_id']+':'+s['subject']
        return s

    def active_member(self,db,bid,user,role=None):
        member=one(db,'SELECT m.* FROM memberships m JOIN users u ON u.id=m.user_id JOIN businesses b ON b.id=m.business_id WHERE m.business_id=? AND m.user_id=? AND m.enabled=1 AND u.disabled=0 AND b.enabled=1',(bid,user))
        if not member or (role and member['role']!=role):fail(403,'This membership no longer permits that action.')
        return member

    def new_session(self,bid,subject,kind,portal_version=0,old_token=None):
        token,csrf=secrets.token_urlsafe(32),secrets.token_urlsafe(32)
        with self.store.registry() as db:
            db.execute('BEGIN IMMEDIATE')
            if kind=='staff':self.active_member(db,bid,subject)
            db.execute('DELETE FROM sessions WHERE expires<?',(time.time(),))
            if old_token:db.execute('DELETE FROM sessions WHERE token=? AND kind=?',(digest(old_token),kind))
            db.execute('INSERT INTO sessions VALUES(?,?,?,?,?,?,?)',(digest(token),bid,subject,kind,csrf,time.time()+8*3600,portal_version))
        response=JSONResponse({'csrf':csrf,'business_id':bid})
        response.set_cookie(STAFF_COOKIE if kind=='staff' else PORTAL_COOKIE,token,max_age=8*3600,httponly=True,samesite='strict',secure=False,path='/')
        return response

    def current(self,request):
        s=self.session(request)
        from .websites import identity
        with self.store.tenant(s['business_id']) as db:brand=identity(db)
        with self.store.registry() as db:
            memberships=many(db,'SELECT m.id,m.business_id,m.role,b.name,b.slug FROM memberships m JOIN businesses b ON b.id=m.business_id WHERE m.user_id=? AND m.enabled=1 AND b.enabled=1 ORDER BY b.name,m.id',(s['subject'],))
        return {**{k:s[k] for k in ('business_id','slug','csrf','membership_id','role','email','display_name')},'name':brand['business_name'],'colour':brand['colour'],'logo_id':brand['logo_id'] if s['role']=='owner' else None,'memberships':memberships}

    def check_resource(self,db,bid,rid,excluding=None):
        if rid is None:return
        with self.store.tenant(bid) as tenant:r=one(tenant,'SELECT id FROM team_resources WHERE id=? AND archived=0',(rid,))
        if not r:fail(422,'Choose an active scheduling resource from this business.')
        linked=one(db,'SELECT id FROM memberships WHERE business_id=? AND resource_id=?',(bid,rid))
        if linked and linked['id']!=excluding:fail(409,'That scheduling resource already has a membership link. Unlink it explicitly first.')

    def provision(self,s,request,payload):
        key=request.headers.get('idempotency-key','')
        try:uuid.UUID(key)
        except ValueError:fail(400,'A unique operation ID is required.')
        fingerprint=digest(s['business_id']+json.dumps(payload.model_dump(),sort_keys=True))
        bid=uid();pending=self.store.root/'.provisioning';stage=pending/bid;final=self.store.root/bid;marker=pending/(bid+'.json')
        committed=False;created_marker=False;created_stage=False;moved=False
        try:
            with self.store.registry() as db:
                db.execute('BEGIN IMMEDIATE');self.active_member(db,s['business_id'],s['subject'],'owner')
                prior=one(db,'SELECT * FROM registry_operations WHERE key=? AND user_id=?',(key,s['subject']))
                if prior:
                    if prior['fingerprint']!=fingerprint:fail(409,'That operation ID was already used for a different business.')
                    return json.loads(prior['result'])
                if db.execute('SELECT 1 FROM businesses WHERE slug=?',(payload.slug,)).fetchone():fail(409,'That local website address is already in use.')
                if db.execute('SELECT COUNT(*) FROM businesses').fetchone()[0]>=MAX_BUSINESSES:fail(409,'This local prototype supports up to 25 invented businesses.')
                if stage.exists() or final.exists() or marker.exists() or db.execute('SELECT 1 FROM businesses WHERE id=?',(bid,)).fetchone():fail(409,'Could not reserve a new local storage identity. Retry the creation.')
                dataset_id=db.execute('SELECT dataset_id FROM dataset_identity').fetchone()[0]
                if pending.is_symlink():raise ValueError('Unsafe provisioning folder.')
                pending.mkdir(exist_ok=True,mode=0o700)
                from .recovery import private_write,sync_directory
                private_write(marker,(json.dumps({'business_id':bid,'dataset_id':dataset_id})+'\n').encode());created_marker=True;sync_directory(pending)
                stage.mkdir(mode=0o700);created_stage=True
                self.store.initialise_folder(stage,{'id':bid,'name':payload.name,'area':payload.area,'email':'','colour':'#245648'},dataset_id)
                self.provision_hook('staged')
                os.rename(stage,final);moved=True;sync_directory(self.store.root)
                self.provision_hook('renamed')
                mid=uid()
                db.execute('INSERT INTO businesses(id,slug,name) VALUES(?,?,?)',(bid,payload.slug,payload.name))
                db.execute('INSERT INTO memberships(id,business_id,user_id,role,created_at) VALUES(?,?,?,?,?)',(mid,bid,s['subject'],'owner',now()))
                identity_audit(db,bid,s['subject'],mid,'business.provisioned',{'slug':payload.slug})
                result={'business_id':bid,'slug':payload.slug,'membership_id':mid}
                db.execute('INSERT INTO registry_operations VALUES(?,?,?,?)',(key,s['subject'],fingerprint,json.dumps(result)))
                self.provision_hook('before_commit')
            committed=True
            marker.unlink(missing_ok=True);sync_directory(pending)
            return result
        finally:
            if not committed:
                # Only this request's random, journalled paths. Existing data is never reused.
                if created_stage and stage.exists():shutil.rmtree(stage)
                if moved and final.exists():shutil.rmtree(final)
                if created_marker:marker.unlink(missing_ok=True)

    def capture_path(self,token_id):return self.store.root/'.local-capture'/(token_id+'.json')

    def remove_captures(self,ids):
        for token_id in ids:
            try:self.capture_path(token_id).unlink(missing_ok=True)
            except OSError:pass  # consumed hashes cannot be reused even if cleanup fails

    def change_password(self,s,payload):
        with self.store.registry() as db:
            db.execute('BEGIN IMMEDIATE');self.active_member(db,s['business_id'],s['subject'])
            user=one(db,'SELECT * FROM users WHERE id=?',(s['subject'],))
            if not password_matches(payload.current_password,user['password']):fail(403,'Current password is incorrect.')
            db.execute('UPDATE users SET password=? WHERE id=?',(password_hash(payload.new_password),s['subject']))
            self.revoke_account_sessions(db,s['subject'])
            ids=[r[0] for r in db.execute("SELECT id FROM access_tokens WHERE kind='reset' AND user_id=?",(s['subject'],))]
            db.execute("UPDATE access_tokens SET used=1 WHERE kind='reset' AND user_id=?",(s['subject'],))
            identity_audit(db,s['business_id'],s['subject'],s['subject'],'password.changed')
        self.remove_captures(ids)
        response=JSONResponse({'ok':True});response.delete_cookie(STAFF_COOKIE,path='/');return response

    def revoke_account_sessions(self,db,user):db.execute("DELETE FROM sessions WHERE subject=? AND kind='staff'",(user,))


def assigned(db,s,job_id=None):
    if not s['resource_id'] or not db.execute('SELECT 1 FROM team_resources WHERE id=? AND archived=0',(s['resource_id'],)).fetchone():return []
    query="SELECT b.id,b.job_id,b.kind,b.start_date,b.end_date,b.start_time,b.end_time,b.all_day FROM calendar_bookings b WHERE b.archived=0 AND (EXISTS(SELECT 1 FROM json_each(b.resource_ids) WHERE value=?) OR (b.kind='unavailable' AND b.whole_team=1))"
    bookings=many(db,query,(s['resource_id'],))
    return [b for b in bookings if job_id is None or b['job_id']==job_id]


def mount_identity(app,auth,once):
    store=auth.store;session=auth.session

    @app.post('/api/businesses')
    def create_business(payload:BusinessCreate,request:Request):return auth.provision(session(request,csrf=True),request,payload)

    @app.post('/api/business/switch')
    def switch(payload:Switch,request:Request):
        s=session(request,csrf=True)
        with store.registry() as db:
            member=one(db,'SELECT business_id FROM memberships WHERE id=? AND user_id=? AND enabled=1',(payload.membership_id,s['subject']))
            if not member:fail(404,'Membership not available.')
        return auth.new_session(member['business_id'],s['subject'],'staff',old_token=request.cookies.get(STAFF_COOKIE))

    @app.put('/api/account/profile')
    def profile(payload:Profile,request:Request):
        s=session(request,csrf=True)
        with store.registry() as db:
            db.execute('UPDATE users SET display_name=? WHERE id=?',(payload.display_name,s['subject']))
            identity_audit(db,s['business_id'],s['subject'],s['subject'],'profile.changed')
        return {'ok':True}

    @app.get('/api/memberships')
    def members(request:Request):
        s=session(request)
        with store.registry() as db:
            members=many(db,'SELECT m.id,m.role,m.enabled,m.version,m.resource_id,u.email,u.display_name,u.disabled FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.business_id=? ORDER BY m.created_at,m.id',(s['business_id'],))
            invites=many(db,"SELECT id,email,role,resource_id,expires,used,created_at FROM access_tokens WHERE kind='invite' AND business_id=? ORDER BY created_at DESC,id",(s['business_id'],))
        with store.tenant(s['business_id']) as db:resources=many(db,'SELECT id,name,archived FROM team_resources ORDER BY name,id')
        return {'members':members,'invitations':invites,'resources':resources,'now':time.time()}

    @app.put('/api/memberships/{membership_id}')
    def change_member(membership_id:str,payload:MembershipChange,request:Request):
        s=session(request,csrf=True)
        with store.registry() as db:
            db.execute('BEGIN IMMEDIATE');auth.active_member(db,s['business_id'],s['subject'],'owner')
            member=one(db,'SELECT * FROM memberships WHERE id=? AND business_id=?',(membership_id,s['business_id']))
            if not member:fail(404,'Membership not found in this business.')
            if member['version']!=payload.version:fail(409,'This membership changed. Reload before saving.')
            if member['enabled'] and member['role']=='owner' and (not payload.enabled or payload.role!='owner'):
                owners=db.execute("SELECT COUNT(*) FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.business_id=? AND m.enabled=1 AND m.role='owner' AND u.disabled=0 AND m.id<>?",(s['business_id'],membership_id)).fetchone()[0]
                if owners<1:fail(409,'Keep at least one active owner. Add and accept another owner invitation first.')
            if payload.resource_id!=member['resource_id']:auth.check_resource(db,s['business_id'],payload.resource_id,membership_id)
            db.execute('UPDATE memberships SET role=?,enabled=?,resource_id=?,version=version+1 WHERE id=?',(payload.role,int(payload.enabled),payload.resource_id,membership_id))
            db.execute("DELETE FROM sessions WHERE business_id=? AND subject=? AND kind='staff'",(s['business_id'],member['user_id']))
            if not payload.enabled or payload.role!='owner':db.execute("UPDATE access_tokens SET used=1 WHERE kind='invite' AND business_id=? AND created_by=?",(s['business_id'],member['user_id']))
            identity_audit(db,s['business_id'],s['subject'],membership_id,'membership.changed',payload.model_dump())
        return {'ok':True,'session_ended':member['user_id']==s['subject']}

    @app.get('/api/memberships/audit')
    def member_audit(request:Request):
        s=session(request)
        with store.registry() as db:return many(db,'SELECT id,actor_id,target_id,action,detail,created_at FROM identity_audit WHERE business_id=? ORDER BY id DESC LIMIT 200',(s['business_id'],))

    @app.post('/api/invitations')
    def invite(payload:Invitation,request:Request):
        s=session(request,csrf=True);auth.rate_limit('invite:'+s['business_id'],30,3600)
        code=secrets.token_urlsafe(32);tid=uid();expires=time.time()+86400
        with store.registry() as db:
            db.execute('BEGIN IMMEDIATE');auth.active_member(db,s['business_id'],s['subject'],'owner')
            if db.execute('SELECT 1 FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.business_id=? AND u.email=?',(s['business_id'],payload.email)).fetchone():fail(409,'This business already has that membership. Manage it in Members.')
            auth.check_resource(db,s['business_id'],payload.resource_id)
            db.execute("UPDATE access_tokens SET used=1 WHERE kind='invite' AND business_id=? AND email=?",(s['business_id'],payload.email))
            db.execute('INSERT INTO access_tokens(id,token_hash,kind,business_id,email,role,resource_id,expires,created_at,created_by) VALUES(?,?,?,?,?,?,?,?,?,?)',(tid,digest(code),'invite',s['business_id'],payload.email,payload.role,payload.resource_id,expires,now(),s['subject']))
            identity_audit(db,s['business_id'],s['subject'],tid,'invitation.issued',{'role':payload.role,'resource_id':payload.resource_id})
        return {'id':tid,'code':code,'expires':expires,'delivery':'local-capture-only'}

    @app.delete('/api/invitations/{invitation_id}')
    def revoke_invitation(invitation_id:str,request:Request):
        s=session(request,csrf=True)
        with store.registry() as db:
            if not db.execute("UPDATE access_tokens SET used=1 WHERE id=? AND business_id=? AND kind='invite'",(invitation_id,s['business_id'])).rowcount:fail(404,'Invitation not found in this business.')
            identity_audit(db,s['business_id'],s['subject'],invitation_id,'invitation.revoked')
        return {'ok':True}

    @app.post('/api/account/invitations/accept')
    def accept_invitation(payload:InviteAccept,request:Request):
        auth.rate_limit('accept-invite:'+request.client.host,30)
        with store.registry() as db:
            db.execute('BEGIN IMMEDIATE')
            token=one(db,"SELECT t.* FROM access_tokens t JOIN businesses b ON b.id=t.business_id WHERE t.token_hash=? AND t.kind='invite' AND t.used=0 AND t.expires>? AND b.enabled=1",(digest(payload.code),time.time()))
            if not token:password_matches(payload.password,'');fail(400,'The invitation or account details are not valid.')
            user=one(db,'SELECT * FROM users WHERE email=?',(token['email'],))
            if user:
                if not password_matches(payload.password,user['password']) or user['disabled']:fail(400,'The invitation or account details are not valid.')
                user_id=user['id']
            else:
                user_id=uid()
                db.execute('INSERT INTO users(id,business_id,email,password,display_name) VALUES(?,?,?,?,?)',(user_id,token['business_id'],token['email'],password_hash(payload.password),payload.display_name))
            if db.execute('SELECT 1 FROM memberships WHERE business_id=? AND user_id=?',(token['business_id'],user_id)).fetchone():fail(400,'The invitation or account details are not valid.')
            auth.check_resource(db,token['business_id'],token['resource_id'])
            mid=uid();db.execute('INSERT INTO memberships(id,business_id,user_id,role,resource_id,created_at) VALUES(?,?,?,?,?,?)',(mid,token['business_id'],user_id,token['role'],token['resource_id'],now()))
            db.execute('UPDATE access_tokens SET used=1 WHERE id=?',(token['id'],))
            identity_audit(db,token['business_id'],user_id,mid,'invitation.accepted',{'invitation_id':token['id']})
        return {'ok':True,'message':'Invitation accepted. Sign in with your account password.'}

    @app.post('/api/account/reset/request')
    def request_reset(payload:ResetRequest,request:Request):
        started=time.monotonic();email=payload.email.lower()
        auth.rate_limit('reset-ip:'+request.client.host,30)
        auth.rate_limit('reset-email:'+email,5)
        new_id=None;old_ids=[]
        try:
            with store.registry() as db:
                db.execute('BEGIN IMMEDIATE')
                user=one(db,'SELECT u.* FROM users u WHERE email=? AND disabled=0 AND EXISTS(SELECT 1 FROM memberships m JOIN businesses b ON b.id=m.business_id WHERE m.user_id=u.id AND m.enabled=1 AND b.enabled=1)',(email,))
                if user:
                    new_id=uid();code=secrets.token_urlsafe(32);expires=time.time()+1800
                    old_ids=[r[0] for r in db.execute("SELECT id FROM access_tokens WHERE user_id=? AND kind='reset'",(user['id'],))]
                    db.execute("UPDATE access_tokens SET used=1 WHERE user_id=? AND kind='reset'",(user['id'],))
                    db.execute('INSERT INTO access_tokens(id,token_hash,kind,user_id,email,expires,created_at) VALUES(?,?,?,?,?,?,?)',(new_id,digest(code),'reset',user['id'],email,expires,now()))
                    folder=store.root/'.local-capture'
                    if folder.is_symlink():raise OSError('Unsafe capture folder.')
                    folder.mkdir(exist_ok=True,mode=0o700);folder.chmod(0o700)
                    from .recovery import private_write
                    private_write(auth.capture_path(new_id),(json.dumps({'delivery':'local-only-no-email','email':email,'code':code,'expires':expires})+'\n').encode())
                    identity_audit(db,None,None,user['id'],'password.reset_requested')
            auth.remove_captures(old_ids)
        except OSError:
            if new_id:auth.remove_captures([new_id])
            # Same public outcome for missing accounts and unavailable local delivery.
        finally:
            time.sleep(max(0,0.15-(time.monotonic()-started)))
        return {'ok':True,'message':'If an eligible local account exists, a private recovery capture is available to the trusted Mac operator. No email is sent.'}

    @app.post('/api/account/reset/complete')
    def complete_reset(payload:ResetAccept,request:Request):
        auth.rate_limit('complete-reset:'+request.client.host,30)
        with store.registry() as db:
            db.execute('BEGIN IMMEDIATE')
            token=one(db,"SELECT t.* FROM access_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=? AND t.kind='reset' AND t.used=0 AND t.expires>? AND u.disabled=0 AND EXISTS(SELECT 1 FROM memberships m JOIN businesses b ON b.id=m.business_id WHERE m.user_id=u.id AND m.enabled=1 AND b.enabled=1)",(digest(payload.code),time.time()))
            if not token:fail(400,'The recovery code is invalid or has expired.')
            db.execute('UPDATE users SET password=? WHERE id=?',(password_hash(payload.new_password),token['user_id']))
            auth.revoke_account_sessions(db,token['user_id'])
            ids=[r[0] for r in db.execute("SELECT id FROM access_tokens WHERE user_id=? AND kind='reset'",(token['user_id'],))]
            db.execute("UPDATE access_tokens SET used=1 WHERE user_id=? AND kind='reset'",(token['user_id'],))
            identity_audit(db,None,token['user_id'],token['user_id'],'password.reset_completed')
        auth.remove_captures(ids)
        return {'ok':True,'message':'Password changed. All account sessions have ended. Sign in again.'}

    def setup_state(s):
        with store.tenant(s['business_id']) as db:
            progress=one(db,'SELECT * FROM setup_progress WHERE id=1')
            publication=db.execute('SELECT MAX(id) FROM site_history').fetchone()[0]
            checks={'website_reviewed':progress['reviewed_publication_id']==publication,'first_job':bool(db.execute('SELECT 1 FROM jobs').fetchone()),'shared_quote':bool(db.execute('SELECT 1 FROM quote_links WHERE shared_at IS NOT NULL').fetchone()),'issued_invoice':bool(db.execute('SELECT 1 FROM invoices WHERE issued_at IS NOT NULL').fetchone()),'guide_read':bool(progress['guide_read'])}
        with store.registry() as db:
            other=db.execute('SELECT COUNT(*) FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.business_id=? AND m.enabled=1 AND u.disabled=0 AND m.user_id<>?',(s['business_id'],s['subject'])).fetchone()[0]
            checks['team_choice']=bool(progress['solo_confirmed'] or other)
        return {'progress':progress,'publication_id':publication,'checks':checks,'external':{'subscription':'not available','verified_domain':'not available','real_email':'not available','training_videos':'not available'}}

    @app.get('/api/setup')
    def get_setup(request:Request):return setup_state(session(request))

    @app.put('/api/setup')
    def save_setup(payload:Setup,request:Request):
        s=session(request,csrf=True)
        with store.tenant(s['business_id']) as db:
            def action():
                publication=db.execute('SELECT MAX(id) FROM site_history').fetchone()[0]
                if payload.reviewed_publication_id not in (None,publication):fail(409,'The published website changed. Review it again.')
                if not db.execute('UPDATE setup_progress SET reviewed_publication_id=?,solo_confirmed=?,guide_read=?,version=version+1 WHERE id=1 AND version=?',(payload.reviewed_publication_id,int(payload.solo_confirmed),int(payload.guide_read),payload.version)).rowcount:fail(409,'Setup changed. Reload it before saving.')
                return {'ok':True}
            return once(db,request,payload.model_dump(),action)

    @app.get('/api/staff/work')
    def staff_work(request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            from .recurrence import calendar_origin
            bookings=[calendar_origin(db,b,staff=True) for b in assigned(db,s)];ids=sorted({b['job_id'] for b in bookings if b['job_id']})
            jobs=[]
            for jid in ids:
                row=one(db,'SELECT j.id,j.service,j.status,c.name,c.phone,c.address FROM jobs j JOIN customers c ON c.id=j.customer_id WHERE j.id=?',(jid,))
                if row:
                    row['updates']=many(db,'SELECT id,note,created_at FROM staff_updates WHERE job_id=? AND membership_id=? ORDER BY created_at,id',(jid,s['membership_id']))
                    jobs.append(row)
        return {'bookings':bookings,'jobs':jobs,'resource_linked':bool(s['resource_id'])}

    @app.post('/api/staff/jobs/{job_id}/updates')
    def staff_note(job_id:str,payload:WorkNote,request:Request):
        s=session(request,csrf=True)
        with store.tenant(s['business_id']) as db:
            def action():
                if not assigned(db,s,job_id):fail(404,'Assigned job not available.')
                nid=uid();db.execute('INSERT INTO staff_updates VALUES(?,?,?,?,?,?,?)',(nid,job_id,s['membership_id'],s['subject'],s['resource_id'],payload.note,now()))
                return {'id':nid}
            return once(db,request,payload.model_dump(),action)

    @app.get('/api/jobs/{job_id}/staff-updates')
    def owner_updates(job_id:str,request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            if not db.execute('SELECT 1 FROM jobs WHERE id=?',(job_id,)).fetchone():fail(404,'Job not found in this business.')
            return many(db,'SELECT w.id,w.note,w.created_at,w.membership_id,r.name AS resource_name FROM staff_updates w JOIN team_resources r ON r.id=w.resource_id WHERE job_id=? ORDER BY w.created_at,w.id',(job_id,))
