#!/usr/bin/env python3
"""
AutoLuxe Atelier · test de integrare pentru API (flux complet + verificări de securitate).
Rulare pe o instalare de TEST, niciodată pe producție:
  python3 tools/test_api.py http://127.0.0.1:8080 admin@exemplu.ro 'parola'
Creează date de test (clienți, lucrări, plăți).
"""
import io, sys, json, datetime, requests

BASE = sys.argv[1].rstrip('/') if len(sys.argv) > 1 else 'http://127.0.0.1:8080'
EMAIL = sys.argv[2] if len(sys.argv) > 2 else 'admin@autoluxe.test'
PASS = sys.argv[3] if len(sys.argv) > 3 else 'Atelier!2026x'
OK = 0


def check(cond, msg):
    global OK
    if not cond:
        print('EȘEC:', msg)
        sys.exit(1)
    OK += 1


class C:
    def __init__(self):
        self.s = requests.Session()
        self.tok = self.s.get(BASE + '/api.php?r=csrf').json()['data']['token']

    def get(self, r, **p):
        return self.s.get(BASE + '/api.php', params={'r': r, **p})

    def post(self, r, data=None, files=None, tok=True):
        h = {'X-CSRF-Token': self.tok} if tok else {}
        if files is not None:
            return self.s.post(BASE + '/api.php', params={'r': r}, data=data or {}, files=files, headers=h)
        return self.s.post(BASE + '/api.php', params={'r': r}, json=data or {}, headers=h)

    def ok(self, resp, msg):
        try:
            j = resp.json()
        except Exception:
            check(False, f'{msg}: răspuns ne-JSON {resp.status_code} {resp.text[:200]}')
        check(resp.status_code == 200 and j.get('ok'), f'{msg}: {resp.status_code} {j}')
        return j['data']

    def login(self, email, pw):
        d = self.ok(self.post('login', {'email': email, 'password': pw}), 'login')
        self.tok = d['csrf']
        return d


def jpeg_bytes(w=3000, h=2000):
    from PIL import Image
    im = Image.new('RGB', (w, h), (180, 90, 20))
    b = io.BytesIO()
    im.save(b, 'JPEG', quality=80)
    return b.getvalue()


def png_sig():
    from PIL import Image, ImageDraw
    im = Image.new('RGBA', (600, 150), (0, 0, 0, 0))
    ImageDraw.Draw(im).line([(10, 100), (200, 40), (400, 120), (590, 30)], fill=(242, 102, 27, 255), width=3)
    b = io.BytesIO()
    im.save(b, 'PNG')
    import base64
    return 'data:image/png;base64,' + base64.b64encode(b.getvalue()).decode()


anon = C()
r = anon.get('dashboard')
check(r.status_code == 401, 'fără autentificare → 401')
r = anon.post('login', {'email': EMAIL, 'password': PASS}, tok=False)
check(r.status_code == 419, 'login fără CSRF → 419')
r = anon.post('login', {'email': EMAIL, 'password': 'gresit-gresit'})
check(r.status_code == 401, 'parolă greșită → 401')

a = C()
boot = a.login(EMAIL, PASS)
check(boot['user']['role'] == 'admin', 'rol admin')
services = {s['code']: s for s in boot['services']}
staff = boot['staff']
check(len(services) >= 10 and len(staff) == 4, 'catalog și echipă încărcate')
today = boot['today']

# CSRF cu token greșit
r = a.s.post(BASE + '/api.php', params={'r': 'requests'}, json={}, headers={'X-CSRF-Token': 'x'})
check(r.status_code == 419, 'token CSRF greșit → 419')
# origine străină
r = a.s.post(BASE + '/api.php', params={'r': 'requests'}, json={}, headers={'X-CSRF-Token': a.tok, 'Origin': 'https://evil.example'})
check(r.status_code == 403, 'origine străină → 403')

# cerere cu XSS în câmpuri: se salvează ca text
xss = '<img src=x onerror=alert(1)>"\''
req = a.ok(a.post('requests', {
    'name': 'Ion ' + xss, 'phone': '0746 111 222', 'email': 'ion@exemplu.ro', 'car_brand': 'BMW', 'car_model': 'X5',
    'car_year': 2021, 'car_plate': 'dj 10 abc', 'car_color': 'Negru', 'desired_date': today, 'pickup': True,
    'pickup_addr': 'Str. Test 1', 'note': xss, 'source': 'telefon',
    'service_ids': [services['L2']['id'], services['glass']['id']]}), 'creare cerere')
check(req['name'].endswith(xss), 'textul se păstrează exact (escaparea se face la afișare)')
check(req['total'] == 320000 and req['days'] == 2, f'total și zile calculate: {req["total"]} {req["days"]}')
check(req['suggest'] is not None, 'propunere de boxă')

r = a.post('requests', {'name': 'X', 'phone': 'abc', 'car_brand': 'Y', 'service_ids': [services['L1']['id']]})
check(r.status_code == 422, 'telefon invalid → 422')

sg = req['suggest']
job = a.ok(a.post(f'requests/{req["id"]}/book', {'bay': sg['bay'], 'start': sg['start'], 'days': 2,
                                                   'staff': [], 'advance': '500', 'advance_method': 'Transfer'}), 'programare')
jid = job['id']
check(job['status'] == 'programata' and job['paid'] == 50000, 'lucrare programată cu avans')

# a doua cerere peste aceeași boxă → conflict
req2 = a.ok(a.post('requests', {'name': 'Maria Pop', 'phone': '0722 333 444', 'car_brand': 'Audi', 'car_model': 'A6',
                                'service_ids': [services['L1']['id']], 'source': 'instagram'}), 'cerere 2')
r = a.post(f'requests/{req2["id"]}/book', {'bay': sg['bay'], 'start': sg['start'], 'days': 1, 'staff': []})
check(r.status_code == 409 and 'ocupată' in r.json()['error'], 'suprapunere blocată la programare')

# duminica
d = datetime.date.fromisoformat(today)
sunday = (d + datetime.timedelta(days=(6 - d.weekday()) or 7)).isoformat()
r = a.post(f'requests/{req2["id"]}/book', {'bay': 1, 'start': sunday, 'days': 1})
check(r.status_code == 422, 'duminica blocată')

sg2 = a.ok(a.get('suggest', days=1, **{'from': today}), 'sugestie')
job2 = a.ok(a.post(f'requests/{req2["id"]}/book', {'bay': sg2['bay'], 'start': sg2['start'], 'days': 1, 'staff': [staff[3]['id']]}), 'programare 2')

# mutare peste lucrarea 1 → conflict
r = a.post(f'jobs/{job2["id"]}/schedule', {'bay': sg['bay'], 'start': sg['start'], 'days': 1})
check(r.status_code == 409, 'mutare suprapusă blocată în fișă')

# avans fără tehnician → refuz
r = a.post(f'jobs/{jid}/advance')
check(r.status_code == 409, 'recepție fără tehnician refuzată')
a.ok(a.post(f'jobs/{jid}/staff', {'staff': [staff[0]['id'], staff[3]['id']]}), 'alocare tehnicieni')
mv = a.ok(a.post(f'jobs/{jid}/advance'), 'avans → recepție')
check(mv['move']['to'] == 'receptie', 'etapa recepție')

# inspecție
a.ok(a.post(f'inspections/{jid}', {'km': '84 250', 'by_staff': staff[0]['id'], 'paint': {'0': 110, '3': 260},
                                   'defects': [{'type': 'zg', 'x': 500, 'y': 150, 'note': '3 cm ' + xss}]}), 'salvare inspecție')
ins = a.ok(a.get(f'inspections/{jid}'), 'citire inspecție')
check(ins['km'] == '84 250' and len(ins['defects']) == 1 and ins['paint']['3'] == 260, 'inspecție salvată')
ph = a.ok(a.post(f'inspections/{jid}/photo', {'zone': '0'}, files={'file': ('a.jpg', jpeg_bytes(), 'image/jpeg')}), 'încărcare foto')
img = a.get(f'files/{ph["id"]}')
from PIL import Image
im = Image.open(io.BytesIO(img.content))
check(img.status_code == 200 and img.headers['Content-Type'] == 'image/jpeg' and max(im.size) == 2048, f'foto redimensionată la 2048: {im.size}')
check(img.headers.get('X-Content-Type-Options') == 'nosniff', 'nosniff pe fișiere')
r = a.post(f'inspections/{jid}/photo', {'zone': '1'}, files={'file': ('x.jpg', b'<?php system($_GET[1]); ?>', 'image/jpeg')})
check(r.status_code == 415, 'fișier PHP deghizat respins')
r = a.post(f'inspections/{jid}/photo', {'zone': '1'}, files={'file': ('x.svg', b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>', 'image/svg+xml')})
check(r.status_code == 415, 'SVG respins')
check(anon.get(f'files/{ph["id"]}').status_code == 401, 'fișierele cer autentificare')
a.ok(a.post(f'inspections/{jid}/sent'), 'raport trimis')

# proces-verbal
r = a.post(f'handovers/{jid}/ridicare', {'km': '84 250', 'fuel': 2, 'signer_name': 'Ion', 'signature': 'data:image/png;base64,AAAA'})
check(r.status_code == 422, 'semnătură invalidă respinsă')
a.ok(a.post(f'handovers/{jid}/ridicare', {'km': '84 250', 'fuel': 3, 'time': '09:15', 'docs': True, 'spare_key': True,
                                         'signer_name': 'Ion Ionescu', 'signature': png_sig()}), 'PV semnat')
r = a.post(f'handovers/{jid}/ridicare', {'km': '1', 'signer_name': 'X', 'signature': png_sig()})
check(r.status_code == 409, 'PV semnat nu se mai modifică')

for st in ('lucru', 'control', 'gata'):
    mv = a.ok(a.post(f'jobs/{jid}/advance'), f'avans → {st}')
    check(mv['move']['to'] == st, f'etapa {st}')
r = a.post(f'jobs/{jid}/advance')
check(r.status_code == 409, 'predarea nu se face prin avans simplu')

# stoc înainte
prods = {p['name']: p for p in a.ok(a.get('products'), 'produse')}
before = prods['Rupes Uno Pure (pastă finisare)']['qty']
res = a.ok(a.post(f'jobs/{jid}/deliver', {'method': 'Card', 'invoice_no': 'SB 0001'}), 'predare')
check(res['result']['amount'] == 270000, f'rest de plată = total − avans: {res["result"]["amount"]}')
check(res['job']['status'] == 'predata' and res['job']['paid'] == 320000, 'predată și achitată integral')
prods = {p['name']: p for p in a.ok(a.get('products'), 'produse')}
check(prods['Rupes Uno Pure (pastă finisare)']['qty'] == before - 60, 'consum scăzut din stoc')
# inspecția e blocată după predare
r = a.post(f'inspections/{jid}', {'km': '1'})
check(r.status_code == 409, 'raportul nu se mai modifică după predare')

w = a.ok(a.get('warranties'), 'garanții')
rem = a.ok(a.get('reminders'), 'reamintiri')
check(any(x['job_id'] == jid for x in rem), 'reamintire creată la predare')

dash = a.ok(a.get('dashboard'), 'panou')
check(dash['money'] and len(dash['kpis']) == 4 and len(dash['chart']) == 9, 'panou complet pentru admin')
check(dash['kpis'][3]['cur'] >= 320000, 'încasările includ avans + final')
pay = a.ok(a.get('payments'), 'încasări')
check(pay['total'] == dash['month_total'], f'aceeași cifră în panou și în Încasări: {pay["total"]} vs {dash["month_total"]}')
rep = a.ok(a.get('reports'), 'rapoarte')
check(rep['delivered'] >= 1 and rep['profit'][0]['materials'] > 0, 'rapoarte cu cost de materiale')
s = a.ok(a.get('search', q='ion'), 'căutare')
check(any(h['type'] == 'job' for h in s), 'căutare fără diacritice')
s = a.ok(a.get('search', q='DJ10'), 'căutare după număr')
check(len(s) >= 1, 'căutare după număr fără spații')
s = a.ok(a.get('search', q="%' OR 1=1 --"), 'căutare cu injecție')

# utilizator tehnician: nu vede bani, nu programează
a.ok(a.post('users', {'email': 'tehnician@autoluxe.test', 'name': 'Tehnician Test', 'password': 'Tehnic!an2026', 'role': 'tehnician', 'staff_id': staff[0]['id']}), 'creare tehnician')
t = C()
tb = t.login('tehnician@autoluxe.test', 'Tehnic!an2026')
check(tb['user']['must_change'] == 1, 'parolă temporară → schimbare obligatorie')
r = t.get('dashboard')
check(r.status_code == 423, 'blocat până schimbă parola')
t.ok(t.post('me/password', {'current': 'Tehnic!an2026', 'password': 'Noua-Parola-2026'}), 'schimbare parolă')
d2 = t.ok(t.get('dashboard'), 'panou tehnician')
check(not d2['money'] and 'chart' not in d2 and len(d2['kpis']) == 3, 'tehnicianul nu vede sume')
jd = t.ok(t.get(f'jobs/{job2["id"]}'), 'fișă tehnician')
check('total' not in jd and jd['payments'] == [], 'fișa fără bani pentru tehnician')
check(t.get('payments').status_code == 403, 'tehnician → încasări 403')
check(t.post(f'requests/{req2["id"]}/book', {}).status_code == 403, 'tehnician → programare 403')
check(t.get('users').status_code == 403, 'tehnician → utilizatori 403')
check(t.get('backup').status_code == 403, 'tehnician → backup 403')

# dezactivarea contului închide sesiunea
uid = [u for u in a.ok(a.get('users'), 'utilizatori') if u['email'] == 'tehnician@autoluxe.test'][0]['id']
a.ok(a.post(f'users/{uid}/update', {'name': 'Tehnician Test', 'role': 'tehnician', 'active': False}), 'dezactivare')
check(t.get('dashboard').status_code == 401, 'sesiunea contului dezactivat e închisă')

# administratorul nu se poate retrage singur
me = boot['user']['id']
r = a.post(f'users/{me}/update', {'name': 'X', 'role': 'tehnician', 'active': True})
check(r.status_code == 409, 'ultimul admin protejat')

# configurator: dezactivat implicit
r = requests.post(BASE + '/intake.php', json={'name': 'A'})
check(r.status_code == 404, 'configurator dezactivat implicit')
key = a.ok(a.post('settings/intake-key'), 'cheie configurator')['key']
a.ok(a.post('settings', {'intake_enabled': True, 'intake_origins': 'https://autoluxe.ro'}), 'activare configurator')
r = requests.post(BASE + '/intake.php', json={'name': 'Web Client', 'phone': '0733 000 111', 'car': {'brand': 'Tesla', 'model': 'Model 3'},
                                               'services': ['ppf_front', 'cer_4'], 'note': xss}, headers={'X-Api-Key': key})
check(r.status_code == 201, f'cerere din configurator acceptată: {r.status_code} {r.text}')
r = requests.post(BASE + '/intake.php', json={'name': 'X'}, headers={'X-Api-Key': 'gresit'})
check(r.status_code == 401, 'cheie greșită → 401')
r = requests.post(BASE + '/intake.php', json={'name': 'X'}, headers={'Origin': 'https://evil.example'})
check(r.status_code == 403, 'origine nepermisă → 403')
r = requests.post(BASE + '/intake.php', json={'name': 'Bot', 'phone': '0700000000', 'car': {'brand': 'X'}, 'services': ['L1'], 'website': 'spam'}, headers={'X-Api-Key': key})
check(r.status_code == 200 and 'id' not in r.json(), 'capcana pentru roboți')

# backup
b = a.get('backup')
check(b.status_code == 200 and b.content[:16] == b'SQLite format 3\x00', 'copie de siguranță SQLite validă')

# acces direct la fișiere sensibile (pe Apache le blochează .htaccess; aici verificăm doar că nu sunt în public/)
for path in ('/../app/config.php', '/paths.php'):
    rr = requests.get(BASE + path)
    check(rr.status_code != 200 or 'install_key' not in rr.text, f'{path} nu expune configurarea')

print(f'OK · {OK} verificări trecute')
