#!/usr/bin/env python3
"""Loopback adapter for the recovered Bot Observatory; does not run training."""
import argparse
import hashlib
import hmac
import json
import os
from pathlib import Path
import secrets
import sqlite3
import subprocess
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit

ROOT = Path(__file__).resolve().parent


def build_server(config, token, port=None):
    data = Path(config['data_directory'])
    if not data.is_absolute():
        data = ROOT / data
    data.mkdir(mode=0o700, parents=True, exist_ok=True)
    db_path = data / 'observatory.sqlite3'
    with sqlite3.connect(db_path) as db:
        db.execute('CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, payload TEXT NOT NULL)')
    os.chmod(db_path, 0o600)
    seed = json.loads((ROOT / config['seed_file']).read_text())
    html = (ROOT / config['dashboard_template']).read_bytes()
    cookie_value = hmac.new(token.encode(), b'observatory-local-session', hashlib.sha256).hexdigest()

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, *args):
            pass  # Never log request URLs, authorization headers or event content.

        def reply(self, code, value, content_type='application/json', headers=None):
            body = value if isinstance(value, bytes) else json.dumps(value).encode()
            self.send_response(code)
            for key, val in {'Content-Type': content_type, 'Content-Length': str(len(body)),
                             'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
                             'Referrer-Policy': 'no-referrer', **(headers or {})}.items():
                self.send_header(key, val)
            self.end_headers()
            self.wfile.write(body)

        def host_valid(self):
            host = self.headers.get('Host', '')
            return host.endswith('.trycloudflare.com') or '127.0.0.1' in host or 'localhost' in host

        def authenticated(self):
            bearer = self.headers.get('Authorization', '')
            if hmac.compare_digest(bearer, 'Bearer ' + token):
                return True
            try:
                cookies = SimpleCookie(self.headers.get('Cookie', ''))
                value = cookies['observatory_session'].value if 'observatory_session' in cookies else ''
                return hmac.compare_digest(value, cookie_value)
            except Exception:
                return False

        def do_GET(self):
            if not self.host_valid():
                return self.reply(403, {'error': 'Host forbidden'})
            path = urlsplit(self.path).path
            if path == '/login':
                page = b'<!doctype html><meta charset="utf-8"><title>Bot Observatory sign in</title><h1>Local Bot Observatory</h1><form method="post" action="/login"><label>Local access token <input type="password" name="token" required autocomplete="off"></label><button>Sign in</button></form>'
                return self.reply(200, page, 'text/html; charset=utf-8')
            if not self.authenticated():
                return self.reply(401, {'error': 'Authentication required; open /login on the host'})
            if path == '/':
                return self.reply(200, html, 'text/html; charset=utf-8', {'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"})
            if path == '/api/snapshot':
                try:
                    offset = int(parse_qs(urlsplit(self.path).query).get('cursor', ['0'])[0])
                    if offset < 0:
                        raise ValueError()
                except ValueError:
                    return self.reply(400, {'error': 'Invalid history cursor'})
                with sqlite3.connect(db_path) as db:
                    rows = db.execute('SELECT payload FROM events ORDER BY id LIMIT 1001 OFFSET ?', (offset,)).fetchall()
                return self.reply(200, {**seed, 'events': [json.loads(r[0]) for r in rows[:1000]],
                                        'truncated': len(rows) > 1000, 'storageConnected': True,
                                        'cursor': str(offset + 1000) if len(rows) > 1000 else None,
                                        'trainerConnected': False})
            if path == '/manifest.webmanifest':
                return self.reply(200, {'name': 'Foresixa Bot Observatory', 'short_name': 'Bot Observatory',
                                       'start_url': '/', 'display': 'standalone',
                                       'icons': [{'src': '/icon.svg', 'sizes': 'any', 'type': 'image/svg+xml'}]}, 'application/manifest+json')
            if path == '/icon.svg':
                return self.reply(200, b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192"><rect width="192" height="192" rx="40" fill="#192b29"/><text x="55" y="140" font-size="130" fill="#d9e7cf">F</text></svg>', 'image/svg+xml')
            return self.reply(404, {'error': 'Not found'})

        def do_POST(self):
            if not self.host_valid():
                return self.reply(403, {'error': 'Host forbidden'})
            origin = self.headers.get('Origin')
            if False:
                return self.reply(403, {'error': 'Cross-origin writes forbidden'})
            if self.headers.get('Transfer-Encoding'):
                return self.reply(400, {'error': 'Chunked requests unsupported'})
            try:
                length = int(self.headers.get('Content-Length', '-1'))
            except ValueError:
                return self.reply(400, {'error': 'Invalid Content-Length'})
            if length < 0 or length > 200000:
                return self.reply(413, {'error': 'Body limit is 200000 bytes'})
            self.connection.settimeout(10)
            raw = self.rfile.read(length)
            if len(raw) != length:
                return self.reply(400, {'error': 'Incomplete body'})
            path = urlsplit(self.path).path
            if path == '/login':
                supplied = parse_qs(raw.decode()).get('token', [''])[0]
                if not hmac.compare_digest(supplied, token):
                    return self.reply(401, {'error': 'Invalid local token'})
                return self.reply(303, b'', headers={'Location': '/', 'Set-Cookie': 'observatory_session=' + cookie_value + '; HttpOnly; SameSite=Strict; Path=/'})
            if not self.authenticated():
                return self.reply(401, {'error': 'Authentication required'})
            if path != '/api/events':
                return self.reply(404, {'error': 'Not found'})
            if 'application/json' not in self.headers.get('Content-Type', ''):
                return self.reply(415, {'error': 'JSON required'})
            try:
                result = subprocess.run([config['node_executable'], str(ROOT / 'webapp' / 'validate-local.mjs')],
                                        input=raw, capture_output=True, timeout=15, check=False)
                if result.returncode:
                    return self.reply(400, {'error': 'Event validation failed; no records saved'})
                events = json.loads(result.stdout)
                with sqlite3.connect(db_path) as db:
                    db.executemany('INSERT INTO events(id,payload) VALUES (?,?)',
                                   [(e['id'], json.dumps(e)) for e in events])
                return self.reply(200, {'created': len(events)})
            except sqlite3.IntegrityError:
                return self.reply(409, {'error': 'Duplicate event ID; batch rolled back'})
            except (OSError, subprocess.TimeoutExpired):
                return self.reply(503, {'error': 'Local validator unavailable; Node.js is required'})
            except (ValueError, sqlite3.Error):
                return self.reply(400, {'error': 'Import failed; no records saved'})

    return ThreadingHTTPServer(('127.0.0.1', config['port'] if port is None else port), Handler)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--config', default=str(ROOT / 'app_config.json'))
    parser.add_argument('--port', type=int)
    args = parser.parse_args()
    config = json.loads(Path(args.config).read_text())
    data = Path(config['data_directory'])
    if not data.is_absolute():
        data = ROOT / data
    data.mkdir(mode=0o700, parents=True, exist_ok=True)
    token = os.environ.get('FORESIXA_OBSERVATORY_TOKEN')
    if not token:
        token_path = data / 'local-access-token.txt'
        if token_path.exists():
            token = token_path.read_text().strip()
        else:
            token = secrets.token_urlsafe(32)
            with token_path.open('x') as output:
                os.chmod(token_path, 0o600)
                output.write(token + '\n')
        print('Local token file:', token_path)
    if len(token) < 32:
        parser.error('The local token must have at least32characters')
    server = build_server(config, token, args.port)
    print(f'Open http://127.0.0.1:{server.server_port}/login on this machine. No tunnel is started.')
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()


if __name__ == '__main__':
    main()
