#!/usr/bin/env python3
"""RuangSekolah same-origin web server and multi-device workspace API."""
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from http.cookies import SimpleCookie
from pathlib import Path
import base64, copy, hashlib, json, mimetypes, os, secrets, sqlite3, time, uuid
from urllib.parse import unquote, urlparse

ROOT = Path(__file__).resolve().parent
DATA_DIR = Path(os.environ.get('RUANGSEKOLAH_DATA', ROOT / 'data'))
DB_PATH = DATA_DIR / 'ruangsekolah.sqlite3'
HOST = os.environ.get('HOST', '0.0.0.0')
PORT = int(os.environ.get('PORT', '8080'))
COOKIE_SECURE = os.environ.get('RUANGSEKOLAH_SECURE_COOKIE', '0') == '1'
MAX_BODY = 20 * 1024 * 1024
SESSION_SECONDS = 30 * 24 * 60 * 60


def connect():
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    c = sqlite3.connect(DB_PATH, timeout=20)
    c.row_factory = sqlite3.Row
    c.execute('PRAGMA journal_mode=WAL')
    c.execute('PRAGMA foreign_keys=ON')
    return c


def init_db():
    with connect() as c:
        c.executescript('''
        CREATE TABLE IF NOT EXISTS workspaces(id TEXT PRIMARY KEY, name TEXT NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL);
        CREATE TABLE IF NOT EXISTS accounts(id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces(id), password_hash TEXT NOT NULL, recovery_hash TEXT NOT NULL);
        CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id), expires_at INTEGER NOT NULL);
        CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires_at);
        ''')


def kdf(value, salt=None):
    salt = salt or secrets.token_bytes(16)
    digest = hashlib.pbkdf2_hmac('sha256', value.encode('utf-8'), salt, 310000)
    return base64.urlsafe_b64encode(salt).decode() + '$' + base64.urlsafe_b64encode(digest).decode()


def check_kdf(value, stored):
    try:
        s, _ = stored.split('$', 1)
        return secrets.compare_digest(kdf(value, base64.urlsafe_b64decode(s)), stored)
    except Exception:
        return False


def merge3(base, local, remote):
    """Merge independent workspace edits; preserve remote-only and local-only fields."""
    missing = object()
    if local == base:
        return copy.deepcopy(remote)
    if remote == base or local == remote:
        return copy.deepcopy(local)
    if isinstance(base, dict) and isinstance(local, dict) and isinstance(remote, dict):
        out = {}
        for k in set(base) | set(local) | set(remote):
            b, l, r = base.get(k, missing), local.get(k, missing), remote.get(k, missing)
            if l is missing and r is missing:
                continue
            if l is missing:
                # A local delete wins only if the remote value was not independently edited.
                if r == b:
                    continue
                out[k] = copy.deepcopy(r)
            elif r is missing:
                if l == b:
                    continue
                out[k] = copy.deepcopy(l)
            elif b is missing:
                out[k] = merge3({}, l, r) if isinstance(l, dict) and isinstance(r, dict) else copy.deepcopy(l)
            else:
                out[k] = merge3(b, l, r)
        return out
    if isinstance(local, list) and isinstance(remote, list):
        b_list = base if isinstance(base, list) else []
        def keyed(items):
            return {str(x['id']): x for x in items if isinstance(x, dict) and 'id' in x}
        bm, lm, rm = keyed(b_list), keyed(local), keyed(remote)
        if len(lm) == len(local) and len(rm) == len(remote):
            order = list(dict.fromkeys([str(x['id']) for x in local] + [str(x['id']) for x in remote]))
            out = []
            for ident in order:
                b, l, r = bm.get(ident, missing), lm.get(ident, missing), rm.get(ident, missing)
                if l is missing and r is missing:
                    continue
                if l is missing:
                    if r != b: out.append(copy.deepcopy(r))
                    continue
                if r is missing:
                    if l != b: out.append(copy.deepcopy(l))
                    continue
                if b is missing:
                    out.append(merge3({}, l, r) if l != r else copy.deepcopy(l))
                else:
                    out.append(merge3(b, l, r))
            return out
    # If the same scalar was changed on both devices, prefer the incoming change.
    return copy.deepcopy(local)


def blank_state(name, level, school_type, year):
    return {
        'school': {'name': name, 'level': level, 'type': school_type, 'year': year, 'address': '', 'phone': ''},
        'data': {k: [] for k in ['programs','tasks','supervision','learning','students','studentCases','meetings','calendar','issues','assets','budget','letters','staff']},
        'customModules': [], 'customFields': {}, 'dashboardModules': ['programs','tasks','supervision','issues'], 'activity': [],
        'appearance': {'primary':'#1d6d5b','nav':'#123c35','background':'#f3f6f5','surface':'#ffffff','text':'#172522','density':'comfortable','radius':12,'layout':'standard','dashboard':'cards'}
    }


class Handler(SimpleHTTPRequestHandler):
    server_version = 'RuangSekolah/3.2'
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(ROOT), **kwargs)

    def translate_path(self, path):
        # Serve only browser assets. In particular, never expose SQLite, backups,
        # source instructions, or arbitrary files from the install directory.
        rel = unquote(urlparse(path).path).lstrip('/')
        if rel in ('', '/'):
            rel = 'BUKA APLIKASI.html'
        candidate = (ROOT / rel).resolve()
        try:
            relative = candidate.relative_to(ROOT.resolve())
        except ValueError:
            return str(ROOT / '__not_a_static_asset__')
        allowed = relative.as_posix() in ('BUKA APLIKASI.html', 'manifest.json', 'sw.js') or (relative.parts and relative.parts[0] == 'assets' and len(relative.parts) > 1)
        try:
            candidate.relative_to(DATA_DIR.resolve())
            in_data = True
        except ValueError:
            in_data = False
        if not allowed or in_data or not candidate.is_file():
            return str(ROOT / '__not_a_static_asset__')
        return str(candidate)

    def end_headers(self):
        self.send_header('X-Content-Type-Options', 'nosniff')
        self.send_header('Referrer-Policy', 'same-origin')
        self.send_header('X-Frame-Options', 'SAMEORIGIN')
        self.send_header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
        self.send_header('Content-Security-Policy', "default-src 'self' data: blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'")
        super().end_headers()

    def log_message(self, fmt, *args):
        if os.environ.get('RUANGSEKOLAH_ACCESS_LOG', '0') == '1':
            super().log_message(fmt, *args)

    def json_reply(self, status, data, extra_headers=None):
        raw = json.dumps(data, ensure_ascii=False, separators=(',', ':')).encode()
        self.send_response(status)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Content-Length', str(len(raw)))
        for k,v in (extra_headers or {}).items(): self.send_header(k,v)
        self.end_headers(); self.wfile.write(raw)

    def read_json(self):
        n = int(self.headers.get('Content-Length', '0'))
        if n > MAX_BODY: raise ValueError('Payload terlalu besar.')
        raw = self.rfile.read(n)
        if not raw: return {}
        return json.loads(raw.decode('utf-8'))

    def require_same_origin(self):
        origin = self.headers.get('Origin')
        if origin:
            host = self.headers.get('Host', '')
            if urlparse(origin).netloc != host:
                self.json_reply(403, {'error':'Asal permintaan tidak diizinkan.'}); return False
        return True

    def current_account(self):
        jar = SimpleCookie(self.headers.get('Cookie', ''))
        morsel = jar.get('rks_session')
        if not morsel: return None
        token_hash = hashlib.sha256(morsel.value.encode()).hexdigest()
        with connect() as c:
            row = c.execute('''SELECT a.id,a.email,a.name,a.workspace_id FROM sessions s JOIN accounts a ON a.id=s.account_id WHERE s.token_hash=? AND s.expires_at>?''', (token_hash, int(time.time()))).fetchone()
        return dict(row) if row else None

    def issue_session(self, account_id):
        token = secrets.token_urlsafe(36)
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        expires = int(time.time()) + SESSION_SECONDS
        with connect() as c:
            c.execute('INSERT INTO sessions(token_hash,account_id,expires_at) VALUES(?,?,?)', (token_hash, account_id, expires))
        secure = '; Secure' if COOKIE_SECURE else ''
        return {'Set-Cookie': f'rks_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_SECONDS}{secure}'}

    def do_GET(self):
        path = urlparse(self.path).path
        if path == '/api/health': return self.json_reply(200, {'ok': True, 'app':'RuangSekolah', 'version':'3.2'})
        if path == '/api/me':
            a = self.current_account()
            return self.json_reply(200, {'authenticated': bool(a), 'user': ({'id':a['id'],'email':a['email'],'name':a['name']} if a else None)})
        if path == '/api/state':
            a = self.current_account()
            if not a: return self.json_reply(401, {'error':'Silakan masuk kembali.'})
            with connect() as c:
                w = c.execute('SELECT state,revision,updated_at FROM workspaces WHERE id=?', (a['workspace_id'],)).fetchone()
            if not w: return self.json_reply(404, {'error':'Ruang sekolah tidak ditemukan.'})
            return self.json_reply(200, {'state':json.loads(w['state']), 'revision':w['revision'], 'updatedAt':w['updated_at']})
        return super().do_GET()

    def do_POST(self):
        path = urlparse(self.path).path
        if path not in ['/api/setup','/api/login','/api/logout','/api/password/reset','/api/password/change']:
            return self.json_reply(404, {'error':'Tidak ditemukan.'})
        if not self.require_same_origin(): return
        try: d = self.read_json()
        except Exception as e: return self.json_reply(400, {'error':str(e) or 'Permintaan tidak valid.'})
        if path == '/api/setup': return self.setup_account(d)
        if path == '/api/login': return self.login_account(d)
        if path == '/api/logout':
            a = self.current_account()
            if a:
                jar=SimpleCookie(self.headers.get('Cookie','')); m=jar.get('rks_session')
                if m:
                    with connect() as c:c.execute('DELETE FROM sessions WHERE token_hash=?',(hashlib.sha256(m.value.encode()).hexdigest(),))
            return self.json_reply(200, {'ok':True}, {'Set-Cookie':'rks_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'})
        if path == '/api/password/reset': return self.reset_password(d)
        if path == '/api/password/change': return self.change_password(d)

    def setup_account(self, d):
        name=str(d.get('school','')).strip(); owner=str(d.get('name','')).strip(); email=str(d.get('email','')).strip().lower(); pw=str(d.get('password','')); confirm=str(d.get('confirm',''))
        if not all([name,owner,email]) or '@' not in email: return self.json_reply(400, {'error':'Lengkapi data sekolah, nama, dan email.'})
        if len(pw)<10 or pw!=confirm: return self.json_reply(400, {'error':'Password minimal 10 karakter dan harus sama dengan konfirmasinya.'})
        workspace_id=str(uuid.uuid4()); account_id=str(uuid.uuid4())
        state=blank_state(name,str(d.get('level','SD')),str(d.get('type','Negeri')),str(d.get('year','')))
        try:
            with connect() as c:
                c.execute('INSERT INTO workspaces(id,name,state,revision,updated_at) VALUES(?,?,?,?,?)',(workspace_id,name,json.dumps(state,ensure_ascii=False),1,int(time.time())))
                c.execute('INSERT INTO accounts(id,email,name,workspace_id,password_hash,recovery_hash) VALUES(?,?,?,?,?,?)',(account_id,email,owner,workspace_id,kdf(pw),''))
        except sqlite3.IntegrityError:
            return self.json_reply(409, {'error':'Email sudah terdaftar. Masuk dengan akun tersebut atau gunakan email lain.'})
        headers=self.issue_session(account_id)
        return self.json_reply(201, {'ok':True,'user':{'id':account_id,'email':email,'name':owner}},headers)

    def login_account(self,d):
        email=str(d.get('email','')).strip().lower();pw=str(d.get('password',''))
        with connect() as c: row=c.execute('SELECT id,email,name,password_hash FROM accounts WHERE email=?',(email,)).fetchone()
        if not row or not check_kdf(pw,row['password_hash']): return self.json_reply(401, {'error':'Email atau password tidak sesuai.'})
        headers=self.issue_session(row['id'])
        return self.json_reply(200, {'ok':True,'user':{'id':row['id'],'email':row['email'],'name':row['name']}},headers)

    def reset_password(self,d):
        email=str(d.get('email','')).strip().lower();school=str(d.get('school','')).strip().casefold();name=str(d.get('name','')).strip().casefold();pw=str(d.get('password',''));confirm=str(d.get('confirm',''))
        if len(pw)<10 or pw!=confirm:return self.json_reply(400,{'error':'Password baru minimal 10 karakter dan harus sama dengan konfirmasinya.'})
        with connect() as c: row=c.execute('SELECT a.id,a.name,w.name AS school_name FROM accounts a JOIN workspaces w ON w.id=a.workspace_id WHERE a.email=?',(email,)).fetchone()
        if not row or name!=row['name'].strip().casefold() or school!=row['school_name'].strip().casefold():return self.json_reply(401,{'error':'Data akun tidak cocok. Periksa kembali email, nama sekolah, dan nama kepala sekolah.'})
        with connect() as c:
            c.execute('UPDATE accounts SET password_hash=? WHERE id=?',(kdf(pw),row['id']))
            c.execute('DELETE FROM sessions WHERE account_id=?',(row['id'],))
        return self.json_reply(200,{'ok':True,'message':'Password diperbarui. Gunakan password baru untuk masuk.'})

    def change_password(self,d):
        a=self.current_account()
        if not a:return self.json_reply(401,{'error':'Silakan masuk kembali.'})
        old=str(d.get('currentPassword',''));pw=str(d.get('password',''));confirm=str(d.get('confirm',''))
        with connect() as c: row=c.execute('SELECT password_hash FROM accounts WHERE id=?',(a['id'],)).fetchone()
        if not row or not check_kdf(old,row['password_hash']):return self.json_reply(401,{'error':'Password saat ini tidak sesuai.'})
        if len(pw)<10 or pw!=confirm:return self.json_reply(400,{'error':'Password baru minimal 10 karakter dan harus sama dengan konfirmasinya.'})
        with connect() as c:c.execute('UPDATE accounts SET password_hash=? WHERE id=?',(kdf(pw),a['id']))
        return self.json_reply(200,{'ok':True})

    def do_PUT(self):
        if urlparse(self.path).path!='/api/state': return self.json_reply(404, {'error':'Tidak ditemukan.'})
        if not self.require_same_origin(): return
        a=self.current_account()
        if not a:return self.json_reply(401,{'error':'Silakan masuk kembali.'})
        try:d=self.read_json()
        except Exception as e:return self.json_reply(400,{'error':str(e) or 'Permintaan tidak valid.'})
        local=d.get('state');base=d.get('baseState') or {};revision=d.get('baseRevision')
        if not isinstance(local,dict):return self.json_reply(400,{'error':'Data workspace tidak valid.'})
        with connect() as c:
            c.execute('BEGIN IMMEDIATE')
            w=c.execute('SELECT state,revision FROM workspaces WHERE id=?',(a['workspace_id'],)).fetchone()
            remote=json.loads(w['state'])
            if revision==w['revision']:
                merged=local
            else:
                merged=merge3(base,local,remote)
            merged['school']=remote.get('school',merged.get('school',{})) | merged.get('school',{})
            new_rev=w['revision']+1;now=int(time.time())
            c.execute('UPDATE workspaces SET state=?,revision=?,updated_at=? WHERE id=?',(json.dumps(merged,ensure_ascii=False,separators=(',',':')),new_rev,now,a['workspace_id']))
            c.commit()
        return self.json_reply(200,{'state':merged,'revision':new_rev,'updatedAt':now})

    def end_headers(self):
        if self.path.endswith('.webmanifest') or self.path.endswith('sw.js'):
            self.send_header('Cache-Control','no-cache')
        self.send_header('X-Content-Type-Options', 'nosniff')
        self.send_header('Referrer-Policy', 'same-origin')
        self.send_header('X-Frame-Options', 'SAMEORIGIN')
        self.send_header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
        self.send_header('Content-Security-Policy', "default-src 'self' data: blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'")
        super().end_headers()

if __name__=='__main__':
    init_db()
    print(f'RuangSekolah berjalan pada http://{HOST}:{PORT}')
    print('Untuk akses internet, gunakan HTTPS reverse proxy dan atur RUANGSEKOLAH_SECURE_COOKIE=1.')
    ThreadingHTTPServer((HOST,PORT),Handler).serve_forever()
