"""Independent local-only API. No SHS modules, env files or services imported."""
from pathlib import Path
import hashlib
import io
import json
import re
import secrets
import sqlite3
import time
import threading
import uuid

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError
from PIL import Image, UnidentifiedImageError

from . import models as m
from .sales_outputs import Response as QuoteResponse, respond
from .documents import get_document, invoice_rows, accept_quote, mount_documents, today
from .data_lock import data_lock, DataBusy
from .current_positions import open_job, open_quote_state
from .public_enquiries import PublicEnquiry, MAX_REQUEST_BYTES, submit as submit_public_enquiry, with_intake
from .store import Store, DEMO_BUSINESSES, uid, now, digest, password_matches, password_hash, one, many, audit

STAFF_COOKIE = 'shs_demo_staff'
PORTAL_COOKIE = 'shs_demo_portal'
BODY_LIMIT = 2 * 1024 * 1024
Image.MAX_IMAGE_PIXELS = 12_000_000


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


def required(row):
    if not row:
        fail(404, 'Record not found in this business.')
    return row


def decoded_document(row):
    if row:
        row['snapshot'] = json.loads(row['snapshot'])
    return row


def create_app(data_dir, static_dir=None):
    app = FastAPI(title='SHS local prototype', docs_url=None, redoc_url=None, openapi_url=None)
    store = Store(data_dir)
    app.state.store = store
    from .access_policy import policies, enforce
    access_policy=policies()

    @app.middleware('http')
    async def recovery_boundary(request: Request, call_next):
        try:
            with data_lock(store.root):
                return await call_next(request)
        except DataBusy:
            return JSONResponse({'detail':'A local backup is running. Try again after it finishes.'},status_code=503)

    @app.middleware('http')
    async def boundary(request: Request, call_next):
        host = request.headers.get('host', '')
        hostname = host.split(':')[0]
        if not re.fullmatch(r'(?:127\.0\.0\.1|localhost|pwa-qa\.localhost|note-review-v23\.localhost|testserver)(?::[0-9]{1,5})?',host):
            return JSONResponse({'detail': 'This prototype only accepts local hostnames.'}, status_code=400)
        if request.method not in ('GET', 'HEAD', 'OPTIONS'):
            origin = request.headers.get('origin')
            if origin != 'http://' + host:
                return JSONResponse({'detail': 'A same-origin request is required.'}, status_code=403)
            public_enquiry_path = request.method == 'POST' and re.fullmatch(r'/api/public/[a-z0-9-]+/enquiries', request.url.path)
            body_limit = MAX_REQUEST_BYTES if public_enquiry_path else BODY_LIMIT
            if public_enquiry_path:
                try:
                    with data_lock(store.root):
                        b=business(request.url.path.split('/')[3])
                        rate_limit('enquiry:'+b['id']+':'+request.client.host,20,60)
                except HTTPException as error:
                    return JSONResponse({'detail':error.detail},status_code=error.status_code)
                except DataBusy:
                    return JSONResponse({'detail':'A local backup is running. Keep this page open and retry after it finishes.'},status_code=503)
            body = bytearray()
            async for chunk in request.stream():
                if len(body) + len(chunk) > body_limit:
                    return JSONResponse({'detail': 'Maximum encoded enquiry request size is 70 MB; choose up to five photos, 10 MB each and 50 MB combined.' if public_enquiry_path else 'Maximum request size is 2 MB.'}, status_code=413)
                body.extend(chunk)
            request._body = bytes(body)
        try:
            enforce(app,request,auth,access_policy)
        except HTTPException as error:
            response=JSONResponse({"detail":error.detail},status_code=error.status_code)
        else:
            response = await call_next(request)
        response.headers.update({
            'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag':'noindex, nofollow',
            'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY',
            'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
            'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
        })
        if request.url.path == '/service-worker.js' and response.status_code == 200:
            response.headers.update({'Cache-Control':'no-cache', 'Service-Worker-Allowed':'/'})
        return response

    # Admission happens before buffering the expanded JSON body. This bounds
    # concurrent anonymous bodies as well as child decoders in this process.
    enquiry_slots = threading.BoundedSemaphore(2)
    @app.middleware('http')
    async def enquiry_capacity(request: Request, call_next):
        if request.method != 'POST' or not re.fullmatch(r'/api/public/[a-z0-9-]+/enquiries', request.url.path):
            return await call_next(request)
        if not enquiry_slots.acquire(blocking=False):
            return JSONResponse({'detail': 'Photo checking is busy. Keep this page open and retry the same enquiry.'}, status_code=503,
                                headers={'Cache-Control': 'no-store'})
        try:
            return await call_next(request)
        finally:
            enquiry_slots.release()

    @app.exception_handler(RequestValidationError)
    async def invalid_input(request, exc):
        # Do not echo passwords, portal codes or arbitrary submitted objects.
        if re.fullmatch(r'/api/public/[a-z0-9-]+/enquiries', request.url.path):
            messages=[]
            for e in exc.errors()[:5]:
                loc=e['loc'][1:]
                if loc and loc[0]=='preferred_date':messages.append('Preferred date: use a valid YYYY-MM-DD calendar date or leave it blank.')
                elif loc and loc[0]=='photos':
                    if len(loc)>1 and isinstance(loc[1],int):messages.append(f'Photo {loc[1]+1}: choose a readable file no larger than 10 MB with a simple filename (up to 120 characters). Remove it and select it again.')
                    else:messages.append('Photos: choose up to five PNG, JPEG, WebP or HEIC/HEIF still images, 10 MB each and 50 MB combined.')
                else:messages.append('Check the name, contact, service and message fields; extra record IDs or file references are not accepted.')
            return JSONResponse({'detail':' '.join(dict.fromkeys(messages))},status_code=422)
        if request.url.path=='/api/site':
            errors=exc.errors()
            message='; '.join('.'.join(str(p) for p in e['loc'][1:])+': '+e['msg'] for e in errors[:4])
            return JSONResponse({'detail':message},status_code=422)
        return JSONResponse({'detail': 'Please check the fields and try again.', 'fields': [str(e['loc'][-1]) for e in exc.errors()]}, status_code=422)

    def business(slug):
        with store.registry() as db:
            return required(one(db, 'SELECT * FROM businesses WHERE slug=? AND enabled=1', (slug,)))

    def rate_limit(key, count=10, seconds=900):
        key = digest(key)
        stamp = time.time()
        with store.registry() as db:
            db.execute('BEGIN IMMEDIATE')
            db.execute('DELETE FROM limits WHERE expires < ?', (stamp,))
            row = one(db, 'SELECT * FROM limits WHERE key=?', (key,))
            if row and row['count'] >= count:
                fail(429, 'Too many attempts. Please try again later.')
            db.execute('INSERT INTO limits VALUES(?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1', (key, stamp + seconds))

    from .identity import Identity, mount_identity
    auth=Identity(store,rate_limit)
    app.state.identity=auth
    session=auth.session
    new_session=auth.new_session

    def once(db, request, body, action, *, check=None):
        key = request.headers.get('idempotency-key', '')
        try:
            uuid.UUID(key)
        except (ValueError, AttributeError):
            fail(400, 'A unique operation ID is required.')
        db.execute('BEGIN IMMEDIATE')
        if check:check()
        fingerprint = digest(getattr(request.state,'operation_subject','public') + request.method + request.url.path + json.dumps(body, sort_keys=True))
        prior = one(db, 'SELECT * FROM operations WHERE key=?', (key,))
        if prior:
            if prior['fingerprint'] != fingerprint:
                fail(409, 'That operation ID was already used for a different change.')
            return json.loads(prior['result'])
        result = action()
        db.execute('INSERT INTO operations VALUES(?,?,?)', (key, fingerprint, json.dumps(result)))
        return result

    def add_enquiry(db, payload, source):
        cid, jid = uid(), uid()
        db.execute('INSERT INTO customers(id,name,email,phone,address,created_at) VALUES(?,?,?,?,?,?)', (cid, payload.name, payload.email, payload.phone, payload.address, now()))
        db.execute('INSERT INTO jobs(id,customer_id,service,message,status,source,created_at) VALUES(?,?,?,?,?,?,?)', (jid, cid, payload.service, payload.message, 'new', source, now()))
        from .workflow import register_job
        register_job(db,jid)
        audit(db, 'enquiry.created', jid)
        return {'id': jid, 'customer_id': cid}

    def invoices(db, where='', args=()):
        return invoice_rows(db,where,args)

    @app.get('/api/health')
    def health():
        return {'mode': 'local-demo', 'integrations': 'disabled', 'businesses': 'private-registry', 'storage': 'separate-local-databases'}

    @app.get('/api/demo')
    def demo():
        return {'businesses': [{k: b[k] for k in ('id', 'slug', 'name', 'email', 'colour')} for b in DEMO_BUSINESSES], 'mode': 'Invented data only. No emails, charges or live domains.'}

    @app.post('/api/login')
    def login(payload: m.Login, request: Request):
        rate_limit('login:' + request.client.host + ':' + payload.email.lower())
        rate_limit('login-ip:' + request.client.host, 40)
        with store.registry() as db:
            user = one(db, 'SELECT * FROM users WHERE email=?', (payload.email.lower(),))
        # One equal-cost password check for known, unknown and disabled accounts.
        valid=password_matches(payload.password,user['password'] if user else '')
        if not valid or not user or user['disabled']:
            fail(401, 'Email or password is incorrect.')
        with store.registry() as db:
            member=one(db,'SELECT m.business_id 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 (m.business_id=?) DESC,m.created_at,m.id LIMIT 1',(user['id'],user['business_id']))
        if not member:fail(401,'Email or password is incorrect.')
        return new_session(member['business_id'],user['id'],'staff',old_token=request.cookies.get(STAFF_COOKIE))

    @app.get('/api/session')
    def current_session(request: Request):return auth.current(request)

    @app.post('/api/logout')
    def logout(request: Request):
        s = session(request, csrf=True)
        with store.registry() as db:
            db.execute('DELETE FROM sessions WHERE token=?', (s['token'],))
        response = JSONResponse({'ok': True})
        response.delete_cookie(STAFF_COOKIE, path='/')
        return response

    @app.post('/api/password')
    def change_password(payload: m.PasswordChange, request: Request):
        return auth.change_password(session(request,csrf=True),payload)

    @app.get('/api/workspace')
    def workspace(request: Request):
        s = session(request)
        with store.tenant(s['business_id']) as db:
            jobs = many(db, 'SELECT j.*,c.name,c.email,c.phone,c.address FROM jobs j JOIN customers c ON c.id=j.customer_id ORDER BY j.created_at DESC,j.rowid DESC')
            from .workflow import enrich
            jobs=with_intake(db,enrich(db,jobs))
            qs = many(db, "SELECT q.id,q.reference,q.job_id,q.customer_id,q.status,q.total_pence,q.version,q.created_at,c.name,l.revision,l.current,l.previous_id,json_extract(q.snapshot,'$.valid_until') AS valid_until FROM quotes q JOIN quote_links l ON l.quote_id=q.id JOIN customers c ON c.id=q.customer_id ORDER BY q.created_at DESC,q.rowid DESC")
            inv = invoices(db)
            for i in inv:
                i.pop('snapshot')
                i['name'] = required(one(db, 'SELECT name FROM customers WHERE id=?', (i['customer_id'],)))['name']
            from .customer_review import directory
            customers = directory(db,state='all')
            by_id = {j['id']: j for j in jobs}
            current_day = today()
            quote_estimate = sum(q['total_pence'] for q in qs if open_quote_state(q, by_id[q['job_id']]['status'], by_id[q['job_id']]['workflow']['archived'], current_day) == 'included')
            stats = {'enquiries': len(jobs), 'open_jobs': sum(open_job(j['status'], j['workflow']['archived']) for j in jobs), 'quoted_pence': quote_estimate, 'open_quote_estimate_pence': quote_estimate, 'received_pence': sum(i['paid_pence'] for i in inv), 'outstanding_pence': sum(i['balance_pence'] for i in inv)}
        return {'jobs': jobs, 'quotes': qs, 'invoices': inv, 'customers': customers, 'stats': stats}

    @app.post('/api/jobs')
    def manual_enquiry(payload: m.Enquiry, request: Request):
        s = session(request, csrf=True)
        with store.tenant(s['business_id']) as db:
            return once(db, request, payload.model_dump(), lambda: add_enquiry(db, payload, 'manual'))

    @app.get('/api/jobs/{job_id}')
    def job_detail(job_id: str, request: Request):
        s = session(request)
        with store.tenant(s['business_id']) as db:
            from .workflow import enrich
            return with_intake(db,enrich(db,[required(one(db, 'SELECT j.*,c.name,c.email,c.phone,c.address FROM jobs j JOIN customers c ON c.id=j.customer_id WHERE j.id=?', (job_id,)))]))[0]

    @app.put('/api/jobs/{job_id}')
    def edit_job(job_id: str, payload: m.JobEdit, request: Request):
        s = session(request, csrf=True)
        from .workflow import edit_job as save_workflow
        with store.tenant(s['business_id']) as db:
            return once(db,request,payload.model_dump(),lambda:save_workflow(db,job_id,payload,s['subject']))

    @app.get('/api/documents/{kind}/{doc_id}')
    def staff_document(kind: str, doc_id: str, request: Request):
        s = session(request)
        with store.tenant(s['business_id']) as db:
            return get_document(db,kind,doc_id)

    @app.get('/api/assets')
    def asset_library(request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:return many(db,'SELECT id,created_at FROM assets ORDER BY created_at,rowid')

    @app.post('/api/assets')
    async def upload_asset(request:Request):
        s=session(request,csrf=True)
        raw=await request.body()
        try:
            source=Image.open(io.BytesIO(raw))
            if source.format not in ('PNG','JPEG','WEBP') or source.width*source.height>12_000_000:
                fail(415,'Use a PNG, JPEG or WebP image up to 12 megapixels.')
            source.load()
            source.thumbnail((1800,1800))
            # Re-encode pixels only: no EXIF, scripts, original filename or metadata.
            clean=Image.new('RGBA',source.size)
            clean.paste(source.convert('RGBA'))
        except (UnidentifiedImageError,OSError,ValueError,Image.DecompressionBombError):
            fail(415,'That file is not a supported image.')
        aid=uid()
        path=store.asset_path(s['business_id'],aid)
        clean.save(path,'PNG')
        with store.tenant(s['business_id']) as db:
            db.execute('INSERT INTO assets VALUES(?,?)',(aid,now()))
        return {'id':aid}

    @app.get('/api/assets/{asset_id}')
    def private_asset(asset_id:str,request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            required(one(db,'SELECT id FROM assets WHERE id=?',(asset_id,)))
        return FileResponse(store.asset_path(s['business_id'],asset_id),media_type='image/png')

    @app.post('/api/public/{slug}/enquiries')
    def public_enquiry(slug:str,payload:PublicEnquiry,request:Request):
        b=business(slug)
        result=submit_public_enquiry(store,b['id'],payload,request,once,add_enquiry)
        # Public caller receives no customer ID or record lookup capability.
        return {'received':True,'receipt':result['id']}

    @app.post('/api/customers/{customer_id}/portal')
    def issue_portal(customer_id:str,request:Request):
        from .portal_access import issue_legacy,clear_captures
        s=session(request,csrf=True)
        with store.tenant(s['business_id']) as db:
            db.execute('BEGIN IMMEDIATE')
            code=issue_legacy(db,customer_id,s['subject'])
            clear_captures(store,db,customer_id)
        return {'code':code,'slug':s['slug'],'expires_in_seconds':1800,'single_use':True}

    @app.delete('/api/customers/{customer_id}/portal')
    def revoke_portal(customer_id:str,request:Request):
        from .portal_access import revoke,clear_captures
        s=session(request,csrf=True)
        with store.tenant(s['business_id']) as db:
            db.execute('BEGIN IMMEDIATE');revoke(db,customer_id,s['subject'])
            clear_captures(store,db,customer_id)
        return {'ok':True}

    @app.post('/api/portal/{slug}/login')
    def portal_login(slug:str,payload:m.PortalLogin,request:Request):
        from .portal_access import consume_legacy
        b=business(slug)
        rate_limit('portal:'+b['id']+':'+request.client.host)
        with store.tenant(b['id']) as db:
            db.execute('BEGIN IMMEDIATE');customer=consume_legacy(db,payload.code)
        return new_session(b['id'],customer['id'],'portal',customer['portal_version'],request.cookies.get(PORTAL_COOKIE))

    def portal_session(request,slug,csrf=False):
        s=session(request,'portal',csrf)
        if s['slug']!=slug:
            fail(401,'Please sign in to this business portal.')
        return s

    @app.get('/api/portal/{slug}/me')
    def portal_home(slug:str,request:Request):
        s=portal_session(request,slug)
        with store.tenant(s['business_id']) as db:
            cid=s['subject']
            customer=required(one(db,'SELECT name FROM customers WHERE id=?',(cid,)))
            # Explicit projections keep staff notes and internal IDs private.
            jobs=many(db,'SELECT id,service,status,scheduled_date FROM jobs WHERE customer_id=?',(cid,))
            quotes=many(db,"SELECT q.id,q.reference,q.total_pence,q.status,q.version,l.revision,l.current,json_extract(q.snapshot,'$.valid_until') AS valid_until FROM quotes q JOIN quote_links l ON l.quote_id=q.id WHERE q.customer_id=? AND l.shared_at IS NOT NULL",(cid,))
            for q in quotes:q['expired']=bool(q['valid_until'] and q['valid_until']<today())
            inv=[{k:v for k,v in i.items() if k in ('id','reference','total_pence','paid_pence','balance_pence','status','payment_status','created_at')} for i in invoices(db,'WHERE i.customer_id=? AND i.issued_at IS NOT NULL',(cid,))]
        return {'business_id':s['business_id'],'name':customer['name'],'jobs':jobs,'quotes':quotes,'invoices':inv,'csrf':s['csrf']}

    @app.get('/api/portal/{slug}/documents/{kind}/{doc_id}')
    def portal_document(slug:str,kind:str,doc_id:str,request:Request):
        s=portal_session(request,slug)
        with store.tenant(s['business_id']) as db:
            return get_document(db,kind,doc_id,s['subject'])

    @app.get('/api/documents/{kind}/{doc_id}/logo')
    def document_logo(kind:str,doc_id:str,request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:document=get_document(db,kind,doc_id)
        aid=document['snapshot']['business'].get('logo_id')
        if not aid:fail(404,'No saved document logo.')
        return FileResponse(store.asset_path(s['business_id'],aid),media_type='image/png')

    @app.get('/api/portal/{slug}/documents/{kind}/{doc_id}/logo')
    def portal_document_logo(slug:str,kind:str,doc_id:str,request:Request):
        s=portal_session(request,slug)
        with store.tenant(s['business_id']) as db:document=get_document(db,kind,doc_id,s['subject'])
        aid=document['snapshot']['business'].get('logo_id')
        if not aid:fail(404,'No saved document logo.')
        return FileResponse(store.asset_path(s['business_id'],aid),media_type='image/png')

    @app.post('/api/portal/{slug}/quotes/{quote_id}/accept')
    def portal_accept(slug:str,quote_id:str,payload:QuoteResponse,request:Request):
        s=portal_session(request,slug,True)
        with store.tenant(s['business_id']) as db:
            return once(db,request,payload.model_dump(),lambda:respond(db,quote_id,payload,'accepted',s))

    @app.post('/api/portal/{slug}/logout')
    def portal_logout(slug:str,request:Request):
        s=portal_session(request,slug,True)
        with store.registry() as db:
            db.execute('DELETE FROM sessions WHERE token=?',(s['token'],))
        response=JSONResponse({'ok':True})
        response.delete_cookie(PORTAL_COOKIE,path='/')
        return response

    @app.get('/api/domains')
    def domains(request:Request):
        s=session(request)
        with store.registry() as db:
            return many(db,'SELECT hostname FROM domains WHERE business_id=?',(s['business_id'],))

    @app.post('/api/domains')
    def domain_add(payload:m.Domain,request:Request):
        s=session(request,csrf=True)
        try:
            with store.registry() as db:
                db.execute('INSERT INTO domains VALUES(?,?)',(payload.hostname,s['business_id']))
        except sqlite3.IntegrityError:
            fail(409,'That simulated hostname is already assigned.')
        return {'mode':'simulation','hostname':payload.hostname}

    @app.delete('/api/domains/{hostname}')
    def domain_remove(hostname:str,request:Request):
        s=session(request,csrf=True)
        with store.registry() as db:
            if not db.execute('DELETE FROM domains WHERE hostname=? AND business_id=?',(hostname,s['business_id'])).rowcount:
                fail(404,'Hostname not found in this business.')
        return {'ok':True}

    @app.get('/api/demo/resolve-host')
    def resolve_host(hostname:str):
        with store.registry() as db:
            return required(one(db,'SELECT b.name,b.slug FROM domains d JOIN businesses b ON b.id=d.business_id WHERE d.hostname=? AND b.enabled=1',(hostname.lower(),)))

    from .photos import mount_photos
    mount_photos(app,store,session,once,business,portal_session,static_dir)
    from .feedback import mount_feedback
    mount_feedback(app,store,session,once,business,portal_session,rate_limit,static_dir)
    from .websites import mount_websites
    mount_websites(app,store,session,once,business,static_dir)
    mount_documents(app,store,session,once)
    from .portal_access import mount_portal_access
    from .sales_outputs import mount_sales_outputs
    from .private_pdf import mount_pdf
    mount_portal_access(app,store,auth,once,business,rate_limit)
    mount_sales_outputs(app,store,session,once,portal_session)
    mount_pdf(app,store,session,portal_session)
    from .materials import mount_materials
    mount_materials(app, store, session, once)
    from .customers import mount_customers
    mount_customers(app, store, session, once)
    from .customer_history import mount_customer_reviews
    mount_customer_reviews(app,store,session,once)
    from .workforce import mount_workforce
    mount_workforce(app, store, session, once)
    from .scheduling import mount_scheduling
    mount_scheduling(app, store, session, once)

    mount_identity(app,auth,once)
    from .fieldwork import mount_field
    from .billing_sources import mount_billing_sources
    mount_field(app,store,session,once)
    from .device_notes import mount_device_notes
    mount_device_notes(app,store,session)
    mount_billing_sources(app,store,session,once)
    from .recurrence import mount_recurrence
    mount_recurrence(app,store,session,once)
    from .workflow import mount_workflow
    from .operations_report import mount_report
    mount_workflow(app,store,session,once)
    mount_report(app,store,session)
    from .reporting import mount_reporting
    mount_reporting(app,store,session)

    if static_dir:
        static_root=Path(static_dir).resolve()

        @app.get('/{path:path}')
        def frontend(path:str):
            if path.startswith('api/'):
                fail(404,'Route not found.')
            resolved=(static_root/path).resolve()
            if not resolved.is_relative_to(static_root):
                fail(404,'File not found.')
            if resolved.is_file():
                return FileResponse(resolved)
            if path.startswith(('assets/','.','api','service-worker','manifest.','pwa-','offline-')):
                fail(404,'File not found.')
            return FileResponse(static_root/'index.html')

    return app
