"""Only the new local app is tested. No SHS URL, DB, .env or email used."""
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import io
import json
import subprocess
import sys
import uuid

import pytest
from fastapi.testclient import TestClient
from PIL import Image
from backend.app import create_app
from backend.store import DEMO_BUSINESSES

ORIGIN={'origin':'http://testserver'}


@pytest.fixture
def app(tmp_path):
    return create_app(tmp_path/'demo')


def client(app,business=0):
    c=TestClient(app,headers=ORIGIN)
    b=DEMO_BUSINESSES[business]
    r=c.post('/api/login',json={'email':b['email'],'password':b['password']})
    assert r.status_code==200,r.text
    c.headers['x-csrf-token']=r.json()['csrf']
    return c


def write(c,url,data=None,method='post',key=None):
    return c.request(method,url,json=data or {},headers={'idempotency-key':key or str(uuid.uuid4())})


def job(c,name='Alex Demo',email='alex@customer.example'):
    r=write(c,'/api/jobs',{'name':name,'email':email,'phone':'000000','address':'1 Example Street','service':'Fit shelves','message':'Two shelves in a demonstration room.'})
    assert r.status_code==200,r.text
    return r.json()


def quote(c,j,total=10000):
    r=write(c,'/api/jobs/'+j['id']+'/quotes',{'items':[{'description':'Demo labour','quantity':1,'unit_pence':total}],'vat_percent':0,'notes':'Customer-visible note'})
    assert r.status_code==200,r.text
    return r.json()


def invoice(c,q):
    r=write(c,'/api/quotes/'+q['id']+'/accept',{'version':1})
    assert r.status_code==200,r.text
    r=write(c,'/api/quotes/'+q['id']+'/invoice',{'version':2})
    assert r.status_code==200,r.text
    result=r.json()
    issued=write(c,'/api/invoices/'+result['id']+'/issue',{'version':1})
    assert issued.status_code==200,issued.text
    return result


def portal(app,c,j,slug='oak-and-vale'):
    r=write(c,'/api/customers/'+j['customer_id']+'/portal')
    assert r.status_code==200,r.text
    p=TestClient(app,headers=ORIGIN)
    result=p.post('/api/portal/'+slug+'/login',json={'code':r.json()['code']})
    assert result.status_code==200,result.text
    p.headers['x-csrf-token']=result.json()['csrf']
    return p,r.json()['code']


def png():
    b=io.BytesIO();Image.new('RGB',(16,16),'green').save(b,'PNG');return b.getvalue()


def test_fresh_businesses_empty_and_separate_files(app):
    for n in (0,1):
        data=client(app,n).get('/api/workspace').json()
        assert data['jobs']==data['quotes']==data['invoices']==data['customers']==[]
    assert (app.state.store.root/'oak/business.sqlite3').is_file()
    assert (app.state.store.root/'harbour/business.sqlite3').is_file()


def test_auth_csrf_origin_host_and_security_headers(app):
    anon=TestClient(app,headers=ORIGIN)
    assert anon.get('/api/workspace').status_code==401
    assert anon.get('/api/jobs/guess').status_code==401
    c=client(app)
    assert 'HttpOnly' in c.cookies.jar._cookies['testserver.local']['/']['shs_demo_staff']._rest
    assert c.get('/api/workspace').headers['cache-control']=='no-store'
    assert "frame-ancestors 'none'" in c.get('/api/health').headers['content-security-policy']
    assert c.post('/api/logout',headers={'x-csrf-token':''}).status_code==403
    assert c.post('/api/logout',headers={'origin':'http://evil.example'}).status_code==403
    assert c.get('/api/workspace',headers={'host':'evil.example'}).status_code==400
    assert c.get('/api/workspace',headers={'x-forwarded-host':'harbour.example'}).json()['jobs']==[]
    assert c.post('/api/assets',content=b'x'*(2*1024*1024+1)).status_code==413


def test_cross_business_reads_writes_and_body_tenant_spoof(app):
    a=client(app);b=client(app,1);j=job(a);q=quote(a,j);i=invoice(a,q)
    assert b.get('/api/workspace').json()['jobs']==[]
    for path in ('/api/jobs/'+j['id'],'/api/documents/quote/'+q['id'],'/api/documents/invoice/'+i['id']):
        assert b.get(path).status_code==404,path
    assert write(b,'/api/invoices/'+i['id']+'/payments',{'amount_pence':1}).status_code==404
    assert write(b,'/api/customers/'+j['customer_id']+'/portal').status_code==404
    assert write(b,'/api/quotes/'+q['id']+'/invoice',{'version':2}).status_code==404
    assert write(b,'/api/jobs/'+j['id']+'/quotes',{'items':[{'description':'A line','quantity':1,'unit_pence':100}]}).status_code==404
    assert write(b,'/api/jobs/'+j['id'],{'version':1,'status':'completed'},method='put').status_code==404
    injected={'name':'Demo','email':'','phone':'','service':'Shelf','message':'Example task','business_id':'oak'}
    assert write(b,'/api/jobs',injected).status_code==422
    assert client(app).get('/api/workspace').json()['stats']['received_pence']==0


def test_public_capture_routes_exact_business_and_deduplicates_retries(app):
    anon=TestClient(app,headers=ORIGIN);key=str(uuid.uuid4())
    data={'name':'Alex Demo','email':'test@fiction.example','service':'New shelf','message':'A fictional task only.'}
    first=write(anon,'/api/public/harbour-homeworks/enquiries',data,key=key)
    second=write(anon,'/api/public/harbour-homeworks/enquiries',data,key=key)
    assert first.status_code==200 and first.json()==second.json()
    assert 'customer_id' not in first.json()
    assert len(client(app,1).get('/api/workspace').json()['jobs'])==1
    assert client(app).get('/api/workspace').json()['jobs']==[]
    assert write(anon,'/api/public/unknown/enquiries',data).status_code==404
    assert anon.get('/api/documents/quote/Q-0001').status_code==401


def test_two_customers_same_name_email_have_separate_portals(app):
    c=client(app);a=job(c);b=job(c);qa=quote(c,a);qb=quote(c,b)
    write(c,'/api/quotes/'+qa['id']+'/share',{'version':1})
    write(c,'/api/quotes/'+qb['id']+'/share',{'version':1})
    p,code=portal(app,c,a)
    rows=p.get('/api/portal/oak-and-vale/me').json()
    assert [q['id'] for q in rows['quotes']]==[qa['id']]
    assert len(rows['jobs'])==1
    assert 'notes' not in rows['jobs'][0] and 'customer_id' not in rows['jobs'][0]
    assert p.get('/api/portal/oak-and-vale/documents/quote/'+qb['id']).status_code==404
    assert write(p,'/api/portal/oak-and-vale/quotes/'+qb['id']+'/accept',{'version':2,'snapshot_hash':c.get('/api/documents/quote/'+qb['id']).json()['snapshot_hash'],'confirmed':True}).status_code==404
    assert p.get('/api/portal/harbour-homeworks/me').status_code==401
    assert p.get('/api/workspace').status_code==401
    other=TestClient(app,headers=ORIGIN)
    assert other.post('/api/portal/harbour-homeworks/login',json={'code':code}).status_code==401


def test_portal_drafts_hidden_acceptance_and_immediate_revocation(app):
    c=client(app);j=job(c);q=quote(c,j);p,code=portal(app,c,j)
    assert p.get('/api/portal/oak-and-vale/me').json()['quotes']==[]
    assert p.get('/api/portal/oak-and-vale/documents/quote/'+q['id']).status_code==404
    assert write(c,'/api/quotes/'+q['id']+'/share',{'version':1}).status_code==200
    assert write(p,'/api/portal/oak-and-vale/quotes/'+q['id']+'/accept',{'version':2,'snapshot_hash':c.get('/api/documents/quote/'+q['id']).json()['snapshot_hash'],'confirmed':True}).status_code==200
    assert write(p,'/api/portal/oak-and-vale/quotes/'+q['id']+'/accept',{'version':2,'snapshot_hash':c.get('/api/documents/quote/'+q['id']).json()['snapshot_hash'],'confirmed':True}).status_code==409
    created=write(c,'/api/quotes/'+q['id']+'/invoice',{'version':3})
    assert created.status_code==200
    assert p.get('/api/portal/oak-and-vale/me').json()['invoices']==[]
    assert write(c,'/api/invoices/'+created.json()['id']+'/issue',{'version':1}).status_code==200
    assert len(p.get('/api/portal/oak-and-vale/me').json()['invoices'])==1
    assert write(c,'/api/customers/'+j['customer_id']+'/portal',method='delete').status_code==200
    assert p.get('/api/portal/oak-and-vale/me').status_code==401
    assert p.post('/api/portal/oak-and-vale/login',json={'code':code}).status_code==401


def test_portal_replacing_code_revokes_old_session(app):
    c=client(app);j=job(c);p,old=portal(app,c,j)
    fresh=write(c,'/api/customers/'+j['customer_id']+'/portal').json()['code']
    assert fresh!=old
    assert p.get('/api/portal/oak-and-vale/me').status_code==401


def test_money_rounding_invoice_snapshot_idempotency_and_overpayment(app):
    c=client(app);j=job(c)
    r=write(c,'/api/jobs/'+j['id']+'/quotes',{'items':[{'description':'Round at invoice total','quantity':3,'unit_pence':333}],'vat_percent':20})
    q=r.json();doc=c.get('/api/documents/quote/'+q['id']).json()
    assert doc['total_pence']==1199
    assert doc['snapshot']['subtotal_pence']==999 and doc['snapshot']['vat_pence']==200
    assert write(c,'/api/quotes/'+q['id']+'/invoice',{'version':1}).status_code==409
    inv=invoice(c,q)
    assert write(c,'/api/quotes/'+q['id']+'/invoice',{'version':2}).json()['id']==inv['id']
    key=str(uuid.uuid4());url='/api/invoices/'+inv['id']+'/payments'
    r1=write(c,url,{'amount_pence':500},key=key);r2=write(c,url,{'amount_pence':500},key=key)
    assert r1.json()==r2.json()
    assert write(c,url,{'amount_pence':501},key=key).status_code==409
    assert write(c,url,{'amount_pence':700}).status_code==409
    assert write(c,url,{'amount_pence':699}).status_code==200
    assert write(c,url,{'amount_pence':1}).status_code==409
    assert c.get('/api/workspace').json()['stats']['received_pence']==1199
    current=c.get('/api/documents/invoice/'+inv['id']).json()
    assert len(current['payments'])==2 and current['paid_pence']==1199
    site=c.get('/api/site').json();site['draft']['business_name']='Renamed Demo'
    assert write(c,'/api/site',method='put',data={'version':site['version'],'content':site['draft']}).status_code==200
    assert c.get('/api/documents/invoice/'+inv['id']).json()['snapshot']['business']['business_name']=='Oak & Vale'


def test_concurrent_payments_do_not_lose_or_double_count(app):
    c=client(app);j=job(c);q=quote(c,j);inv=invoice(c,q)
    def pay(n):
        worker=TestClient(app,headers=dict(c.headers));worker.cookies.update(c.cookies)
        return write(worker,'/api/invoices/'+inv['id']+'/payments',{'amount_pence':2500,'note':'Worker '+str(n)}).status_code
    with ThreadPoolExecutor(max_workers=5) as pool:
        codes=list(pool.map(pay,range(5)))
    assert sorted(codes)==[200,200,200,200,409]
    result=c.get('/api/documents/invoice/'+inv['id']).json()
    assert result['paid_pence']==10000 and len(result['payments'])==4


def test_optimistic_job_update_rejects_stale_version(app):
    c=client(app);j=job(c)
    payload={'version':1,'status':'scheduled','scheduled_date':'2026-11-01','notes':'Private scheduling note'}
    assert c.put('/api/jobs/'+j['id'],json=payload).status_code==400
    assert write(c,'/api/jobs/'+j['id'],payload,method='put').status_code==200
    payload['notes']='stale edit'
    assert write(c,'/api/jobs/'+j['id'],payload,method='put').status_code==409
    assert c.get('/api/jobs/'+j['id']).json()['notes']=='Private scheduling note'
    assert write(c,'/api/jobs/'+j['id'],{'version':2,'status':'scheduled'},method='put').status_code==422


def test_site_draft_publish_restore_and_tenant_separation(app):
    a=client(app);b=client(app,1);anon=TestClient(app,headers=ORIGIN)
    content=a.get('/api/site').json();draft=content['draft'];draft['tagline']='A changed local headline.'
    assert write(a,'/api/site',method='put',data={'version':1,'content':draft}).status_code==200
    assert 'changed' not in anon.get('/api/public/oak-and-vale').json()['tagline']
    assert write(a,'/api/site',method='put',data={'version':1,'content':draft}).status_code==409
    assert write(a,'/api/site/publish',{'version':2}).status_code==200
    assert 'changed' in anon.get('/api/public/oak-and-vale').json()['tagline']
    assert 'changed' not in b.get('/api/site').json()['draft']['tagline']
    assert write(a,'/api/site/restore',{'version':3,'revision_id':1}).status_code==200
    assert 'changed' not in a.get('/api/site').json()['draft']['tagline']
    assert 'changed' in anon.get('/api/public/oak-and-vale').json()['tagline']
    assert write(a,'/api/site/publish',{'version':4}).status_code==200
    assert 'changed' not in anon.get('/api/public/oak-and-vale').json()['tagline']


def test_image_validation_and_publication_access(app):
    a=client(app);b=client(app,1);anon=TestClient(app,headers=ORIGIN)
    assert a.post('/api/assets',content=b'<html><script>alert(1)</script></html>').status_code==415
    assert a.post('/api/assets',content=b'<svg xmlns="http://www.w3.org/2000/svg"></svg>').status_code==415
    uploaded=a.post('/api/assets',content=png());assert uploaded.status_code==200,uploaded.text
    aid=uploaded.json()['id']
    assert a.get('/api/assets/'+aid).status_code==200
    assert b.get('/api/assets/'+aid).status_code==404
    assert anon.get('/api/assets/'+aid).status_code==401
    assert anon.get('/api/public/oak-and-vale/assets/'+aid).status_code==404
    other=b.get('/api/site').json();other['draft']['logo_id']=aid
    assert write(b,'/api/site',method='put',data={'version':1,'content':other['draft']}).status_code==404
    site=a.get('/api/site').json();site['draft']['logo_id']=aid
    assert write(a,'/api/site',method='put',data={'version':1,'content':site['draft']}).status_code==200
    write(a,'/api/site/publish',{'version':2})
    public=anon.get('/api/public/oak-and-vale/assets/'+aid)
    assert public.status_code==200 and public.headers['content-type']=='image/png'
    assert anon.get('/api/public/harbour-homeworks/assets/'+aid).status_code==404
    site=a.get('/api/site').json();site['draft']['logo_id']=None
    write(a,'/api/site',method='put',data={'version':3,'content':site['draft']});write(a,'/api/site/publish',{'version':4})
    assert anon.get('/api/public/oak-and-vale/assets/'+aid).status_code==404


def test_password_change_and_logout_invalidate_sessions(app):
    a=client(app);b=client(app)
    assert write(a,'/api/password',{'current_password':DEMO_BUSINESSES[0]['password'],'new_password':'New-demo-password-only!'}).status_code==200
    assert b.get('/api/session').status_code==401
    assert a.get('/api/session').status_code==401
    c=TestClient(app,headers=ORIGIN)
    r=c.post('/api/login',json={'email':DEMO_BUSINESSES[0]['email'],'password':'New-demo-password-only!'})
    assert r.status_code==200
    c.headers['x-csrf-token']=r.json()['csrf'];saved=dict(c.cookies)
    assert write(c,'/api/logout').status_code==200
    c.cookies.update(saved)
    assert c.get('/api/session').status_code==401


def test_rate_limit_and_no_sensitive_validation_echo(app):
    c=TestClient(app,headers=ORIGIN)
    codes=[c.post('/api/login',json={'email':'owner@oak.example','password':'incorrect'}).status_code for _ in range(11)]
    assert codes[:10]==[401]*10 and codes[10]==429
    r=c.post('/api/login',json={'email':'x','password':'sensitive-demo-value','bad_field':'sensitive-demo-value'})
    assert r.status_code==422 and 'sensitive-demo-value' not in r.text


def test_domain_simulator_no_default_and_unique_ownership(app):
    a=client(app);b=client(app,1)
    assert write(a,'/api/domains',{'hostname':'demo.example.com'}).status_code==422
    assert write(a,'/api/domains',{'hostname':'oak.test'}).status_code==200
    assert write(b,'/api/domains',{'hostname':'oak.test'}).status_code==409
    assert a.get('/api/demo/resolve-host?hostname=unknown.test').status_code==404
    assert a.get('/api/demo/resolve-host?hostname=oak.test').json()['slug']=='oak-and-vale'
    assert b.delete('/api/domains/oak.test').status_code==404
    assert a.delete('/api/domains/oak.test').status_code==200
    assert a.get('/api/demo/resolve-host?hostname=oak.test').status_code==404


def test_records_persist_across_app_restart(tmp_path):
    root=tmp_path/'persist';a=create_app(root);c=client(a);j=job(c)
    restarted=create_app(root);fresh=client(restarted)
    assert fresh.get('/api/jobs/'+j['id']).json()['name']=='Alex Demo'
    assert len(fresh.get('/api/workspace').json()['jobs'])==1


def test_outbound_guard_blocks_socket_before_connection():
    code="from backend.containment import install_guard; import socket; install_guard(); socket.socket().connect(('192.0.2.1',443))"
    r=subprocess.run([sys.executable,'-c',code],capture_output=True,text=True,timeout=10)
    assert r.returncode!=0 and 'External connections are disabled' in r.stderr


def test_static_server_does_not_serve_data_or_unknown_api(tmp_path):
    static=tmp_path/'dist';static.mkdir();(static/'index.html').write_text('<html>App</html>')
    app=create_app(tmp_path/'data',static);c=TestClient(app)
    assert c.get('/workspace').status_code==200
    assert c.get('/api/unknown').status_code==404
    assert c.get('/.local-data/registry.sqlite3').status_code==404
    assert c.get('/assets/missing.js').status_code==404
