"""Exact-customer local portal identities. Captured messages never leave disk.

Customer names and emails are not login identities. Codes are consumed inside
the same tenant transaction as the credential change. Only hashes enter SQL.
"""
import json
import re
import secrets
import time
from pathlib import Path
from fastapi import HTTPException, Request
from pydantic import Field
from .models import Strict, Version
from .store import one, many, uid, now, digest, password_hash, password_matches

SCHEMA = '''
CREATE TABLE IF NOT EXISTS portal_accounts(customer_id TEXT PRIMARY KEY REFERENCES customers(id),
 portal_id TEXT UNIQUE NOT NULL, delivery_email TEXT NOT NULL, password TEXT NOT NULL DEFAULT '',
 state TEXT NOT NULL CHECK(state IN ('pending','enabled','legacy','revoked')), version INTEGER NOT NULL,
 created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS portal_tokens(id TEXT PRIMARY KEY, customer_id TEXT NOT NULL REFERENCES portal_accounts(customer_id),
 token_hash TEXT UNIQUE NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('setup','reset','legacy')),
 account_version INTEGER NOT NULL, expires REAL NOT NULL, used_at TEXT, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS portal_events(id INTEGER PRIMARY KEY AUTOINCREMENT,
 customer_id TEXT NOT NULL REFERENCES customers(id), action TEXT NOT NULL, actor TEXT NOT NULL,
 snapshot TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS portal_migrations(id INTEGER PRIMARY KEY CHECK(id=1), completed_at TEXT NOT NULL);
'''
TABLES = ('portal_tokens','portal_events','portal_accounts','portal_migrations')
COOKIE = 'shs_demo_portal'


def fail(message='Portal details are incorrect or access has ended.', status=401):
    raise HTTPException(status, message)


def setup(db):
    db.executescript(SCHEMA)
    if not db.execute('SELECT 1 FROM portal_migrations').fetchone():
        # Old permanent grants must not silently survive the identity upgrade.
        db.execute('UPDATE customers SET portal_hash=NULL,portal_version=portal_version+1 WHERE portal_hash IS NOT NULL')
        db.execute('INSERT INTO portal_migrations VALUES(1,?)',(now(),))


def account(db,cid):
    return one(db,'SELECT * FROM portal_accounts WHERE customer_id=?',(cid,))


def safe_account(row):
    return {k:v for k,v in row.items() if k!='password'}


def event(db,cid,action,actor,detail=''):
    row=account(db,cid)
    db.execute('INSERT INTO portal_events(customer_id,action,actor,snapshot,detail,created_at) VALUES(?,?,?,?,?,?)',
               (cid,action,actor,json.dumps(safe_account(row)) if row else '{}',detail,now()))


def check_session(db,s):
    from .customer_review import active_access
    if not active_access(db,s['subject']):fail('This customer record is archived or retired. Portal access has ended.')
    row=one(db,'SELECT c.portal_hash,c.portal_version,a.state FROM customers c JOIN portal_accounts a ON a.customer_id=c.id WHERE c.id=?',(s['subject'],))
    if not row or not row['portal_hash'] or row['portal_version']!=s['portal_version'] or row['state'] not in ('enabled','legacy'):
        fail('This portal access has ended.')


def revoke(db,cid,actor):
    if not one(db,'SELECT id FROM customers WHERE id=?',(cid,)):fail('Customer not found.',404)
    db.execute("UPDATE portal_accounts SET state='revoked',password='',version=version+1,updated_at=? WHERE customer_id=?",(now(),cid))
    db.execute('UPDATE portal_tokens SET used_at=COALESCE(used_at,?) WHERE customer_id=?',(now(),cid))
    db.execute('UPDATE customers SET portal_hash=NULL,portal_version=portal_version+1 WHERE id=?',(cid,))
    event(db,cid,'revoked',actor)


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


def capture(store,token,code,slug,a):
    path=capture_path(store,token['id'])
    body={'mode':'LOCAL CAPTURE ONLY — no email sent or address verified',
          'to':a['delivery_email'],'portal_id':a['portal_id'],'portal_path':'/portal/'+slug,
          'kind':token['kind'],'code':code,'expires':token['expires']}
    # Exclusive create prevents an existing file (including a symlink) being followed.
    with path.open('x') as f:
        path.chmod(0o600);json.dump(body,f)


def clear_captures(store,db,cid):
    for token in many(db,'SELECT id FROM portal_tokens WHERE customer_id=? AND (used_at IS NOT NULL OR expires<=?)',(cid,time.time())):
        try:capture_path(store,token['id']).unlink(missing_ok=True)
        except OSError:pass  # SQL revocation remains authoritative if local cleanup fails.


def token(db,cid,kind,version,seconds):
    code=secrets.token_urlsafe(32)
    row={'id':uid(),'customer_id':cid,'kind':kind,'account_version':version,'expires':time.time()+seconds}
    db.execute('INSERT INTO portal_tokens VALUES(?,?,?,?,?,?,NULL,?)',
               (row['id'],cid,digest(code),kind,version,row['expires'],now()))
    return row,code


def issue_legacy(db,cid,actor):
    from .customer_review import active
    active(db,cid)
    c=one(db,'SELECT id,email FROM customers WHERE id=?',(cid,))
    if not c:fail('Customer not found.',404)
    a=account(db,cid)
    if a and a['state'] in ('enabled','pending'):fail('Use the password portal invitation or revoke it first.',409)
    revoke(db,cid,actor)
    stamp=now()
    db.execute("INSERT INTO portal_accounts VALUES(?,?,?,'','legacy',1,?,?) ON CONFLICT(customer_id) DO UPDATE SET state='legacy',updated_at=excluded.updated_at",
               (cid,uid(),c['email'],stamp,stamp))
    a=account(db,cid);t,code=token(db,cid,'legacy',a['version'],1800)
    event(db,cid,'legacy.code_issued',actor,'One use; expires after 30 minutes. Local compatibility only.')
    return code


def consume_legacy(db,code):
    t=one(db,"SELECT * FROM portal_tokens WHERE token_hash=? AND kind='legacy' AND used_at IS NULL AND expires>?",(digest(code),time.time()))
    a=account(db,t['customer_id']) if t else None
    if not a or a['state']!='legacy' or a['version']!=t['account_version']:fail()
    db.execute('UPDATE portal_tokens SET used_at=? WHERE id=?',(now(),t['id']))
    db.execute('UPDATE customers SET portal_hash=?,portal_version=portal_version+1 WHERE id=?',(digest(secrets.token_urlsafe(32)),a['customer_id']))
    event(db,a['customer_id'],'legacy.code_used','portal:'+a['customer_id'])
    return one(db,'SELECT id,portal_version FROM customers WHERE id=?',(a['customer_id'],))


class PortalLogin(Strict):
    portal_id: str = Field(min_length=1,max_length=100)
    password: str = Field(min_length=1,max_length=200)


class Complete(Strict):
    code: str = Field(min_length=12,max_length=100)
    password: str = Field(min_length=12,max_length=200)


class Reset(Strict):
    portal_id: str = Field(min_length=1,max_length=100)
    email: str = Field(min_length=1,max_length=200)


def mount_portal_access(app,store,auth,once,business,rate_limit):
    def throttle(request,slug,identity):
        rate_limit('portal-access-ip:'+request.client.host,50)
        rate_limit('portal-access:'+slug+':'+identity,12)

    @app.get('/api/customers/{cid}/portal-access')
    def state(cid:str,request:Request):
        s=auth.session(request)
        with store.tenant(s['business_id']) as db:
            c=one(db,'SELECT id,version FROM customers WHERE id=?',(cid,))
            if not c:fail('Customer not found.',404)
            a=account(db,cid)
            captures=many(db,"SELECT id,kind,expires FROM portal_tokens WHERE customer_id=? AND kind!='legacy' AND used_at IS NULL AND expires>? ORDER BY created_at,id",(cid,time.time()))
            history=many(db,'SELECT action,actor,detail,created_at FROM portal_events WHERE customer_id=? ORDER BY id',(cid,))
            return {'customer_version':c['version'],'account':safe_account(a) if a else None,'captures':captures,'history':history}

    @app.post('/api/customers/{cid}/portal-access/invite')
    def invite(cid:str,payload:Version,request:Request):
        s=auth.session(request,csrf=True)
        with store.tenant(s['business_id']) as db:
            def perform():
                from .customer_review import active
                active(db,cid)
                c=one(db,'SELECT * FROM customers WHERE id=?',(cid,))
                if not c:fail('Customer not found.',404)
                if c['version']!=payload.version:fail('Customer changed. Reload before inviting.',409)
                if not re.fullmatch(r'[^\s@]+@[^\s@]+\.example',c['email'],re.I):fail('Local capture requires an invented .example address on this exact customer.',422)
                revoke(db,cid,s['subject'])
                stamp=now()
                db.execute("INSERT INTO portal_accounts VALUES(?,?,?,'','pending',1,?,?) ON CONFLICT(customer_id) DO UPDATE SET delivery_email=excluded.delivery_email,state='pending',updated_at=excluded.updated_at",
                           (cid,uid(),c['email'],stamp,stamp))
                a=account(db,cid);t,code=token(db,cid,'setup',a['version'],48*3600)
                capture(store,t,code,s['slug'],a)
                event(db,cid,'setup.captured',s['subject'],'Local captured invitation; no delivery or email verification.')
                return {'capture_id':t['id'],'portal_id':a['portal_id'],'slug':s['slug'],'expires':t['expires'],'mode':'local-capture'}
            result=once(db,request,payload.model_dump(),perform)
        with store.tenant(s['business_id']) as db:clear_captures(store,db,cid)
        return result

    @app.get('/api/customers/{cid}/portal-access/captures/{token_id}')
    def read_capture(cid:str,token_id:str,request:Request):
        s=auth.session(request)
        with store.tenant(s['business_id']) as db:
            t=one(db,"SELECT t.* FROM portal_tokens t JOIN portal_accounts a ON a.customer_id=t.customer_id WHERE t.id=? AND t.customer_id=? AND t.kind!='legacy' AND t.used_at IS NULL AND t.expires>? AND t.account_version=a.version AND a.state IN ('pending','enabled')",(token_id,cid,time.time()))
            if not t:fail('Captured message is expired, used or unavailable.',404)
            path=capture_path(store,token_id)
            if not path.is_file():fail('Captured message is unavailable. Reissue the invitation.',404)
            result=json.loads(path.read_text())
            if digest(result.get('code',''))!=t['token_hash']:fail('Captured message does not match its token.',409)
            return result

    @app.post('/api/portal/{slug}/access/complete')
    def complete(slug:str,payload:Complete,request:Request):
        throttle(request,slug,digest(payload.code));b=business(slug)
        with store.tenant(b['id']) as db:
            db.execute('BEGIN IMMEDIATE')
            t=one(db,"SELECT * FROM portal_tokens WHERE token_hash=? AND kind IN ('setup','reset') AND used_at IS NULL AND expires>?",(digest(payload.code),time.time()))
            a=account(db,t['customer_id']) if t else None
            if not a or a['version']!=t['account_version'] or a['state']!=('pending' if t['kind']=='setup' else 'enabled'):fail('This setup or recovery code is invalid, expired or already used.')
            cid=a['customer_id']
            from .customer_review import active_access
            if not active_access(db,cid):fail()
            db.execute("UPDATE portal_accounts SET password=?,state='enabled',version=version+1,updated_at=? WHERE customer_id=?",(password_hash(payload.password),now(),cid))
            db.execute('UPDATE portal_tokens SET used_at=COALESCE(used_at,?) WHERE customer_id=?',(now(),cid))
            db.execute('UPDATE customers SET portal_hash=?,portal_version=portal_version+1 WHERE id=?',(digest(secrets.token_urlsafe(32)),cid))
            event(db,cid,t['kind']+'.completed','portal:'+cid,'Password set locally. This does not verify email ownership.')
        with store.tenant(b['id']) as db:clear_captures(store,db,cid)
        return {'portal_id':a['portal_id'],'message':'Password saved. Sign in with this Portal ID.'}

    @app.post('/api/portal/{slug}/access/login')
    def login(slug:str,payload:PortalLogin,request:Request):
        throttle(request,slug,payload.portal_id);b=business(slug)
        with store.tenant(b['id']) as db:
            db.execute('BEGIN IMMEDIATE')
            a=one(db,'SELECT * FROM portal_accounts WHERE portal_id=?',(payload.portal_id,))
            valid=password_matches(payload.password,a['password'] if a else '')
            if not valid or not a or a['state']!='enabled':fail()
            c=one(db,'SELECT id,portal_version,portal_hash FROM customers WHERE id=?',(a['customer_id'],))
            from .customer_review import active_access
            if not c['portal_hash'] or not active_access(db,c['id']):fail()
        return auth.new_session(b['id'],c['id'],'portal',c['portal_version'],request.cookies.get(COOKIE))

    @app.post('/api/portal/{slug}/access/reset-request')
    def reset_request(slug:str,payload:Reset,request:Request):
        throttle(request,slug,payload.portal_id)
        with store.registry() as registry:b=one(registry,'SELECT * FROM businesses WHERE slug=? AND enabled=1',(slug,))
        if b:
            with store.tenant(b['id']) as db:
                db.execute('BEGIN IMMEDIATE')
                a=one(db,"SELECT * FROM portal_accounts WHERE portal_id=? AND state='enabled'",(payload.portal_id,))
                from .customer_review import active_access
                if a and active_access(db,a['customer_id']) and secrets.compare_digest(a['delivery_email'].lower().encode(),payload.email.lower().encode()) and a['delivery_email'].lower().endswith('.example'):
                    db.execute("UPDATE portal_tokens SET used_at=COALESCE(used_at,?) WHERE customer_id=? AND kind='reset'",(now(),a['customer_id']))
                    t,code=token(db,a['customer_id'],'reset',a['version'],1800)
                    capture(store,t,code,slug,a)
                    event(db,a['customer_id'],'reset.captured','portal-recovery','Local captured recovery; existing sessions remain until password reset completes.')
                    clear_captures(store,db,a['customer_id'])
        return {'message':'If these details match an enabled local account, a recovery message is available in the owner capture. No email is sent.'}
