import collections
import functools
import hmac
import logging
import queue
import secrets
import ssl

from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import socket
import threading
import time
import shutil
import pathlib
import hashlib
import requests
import os
import json
from urllib.parse import urlparse, parse_qs, quote

# warning: importing pathlib replace any "import os" order

# https://nachtimwald.com/2019/12/10/python-http-server/
# SSL: https://realpython.com/python-http-server/


class QuietSimpleHTTPRequestHandler(SimpleHTTPRequestHandler):
    def log_message(self, mformat, *args):
        pass

    def log_error(self, mformat, *args):
        pass

    def setup(self):
        super().setup()
        self.request.settimeout(60)

    def handle_one_request(self):
        try:
            return super().handle_one_request()
        except socket.timeout:
            # --- Handle the timeout gracefully ---
            # print(f"[TIMEOUT] {self.client_address} request timed out")
            try:
                self.send_error(408, "Request Timeout")
            except Exception:
                pass  # ignore if client already disconnected
            self.close_connection = True
        except ConnectionResetError:
            # print(f"[RESET] {self.client_address} connection reset")
            self.close_connection = True


class QuietSimpleHTTPRequestHandlerWithLogger(QuietSimpleHTTPRequestHandler):
    def __init__(self, *args, directory=None, logger=None, callbacks=None, auth=None, **kwargs):
        self._logger = logger
        self._callbacks = callbacks or {}
        self._auth = auth
        super().__init__(*args, directory=directory, **kwargs)

    @property
    def logger(self):
        return self._logger

    def log_message(self, mformat, *args):
        logger = self.logger
        if logger:
            host, port = self.client_address
            message = 'WS REQUEST %s %s' % (host, mformat % args)
            logger.debug(message)

    def log_error(self, mformat, *args):
        logger = self.logger
        if logger:
            host, port = self.client_address
            message = 'WS ERROR %s %s' % (host, mformat % args)
            logger.error(message)

    def handle_one_request(self):
        logger = self.logger
        try:
            return super().handle_one_request()
        except socket.timeout:
            try:
                self.send_error(408, "Request Timeout")
                if logger:
                    logger.debug('WS request timeout from %s' % self.client_address[0])
            except Exception:
                pass  # client already disconnected before we could reply
            self.close_connection = True
        except ConnectionResetError:
            # Normal event: client closed the connection abruptly — not an error
            self.close_connection = True

    # ------------------------------------------------------------------
    # Auth helpers
    # ------------------------------------------------------------------

    def _get_session_token(self):
        """Extract mbio_session cookie value from request headers."""
        cookie_header = self.headers.get('Cookie', '')
        for part in cookie_header.split(';'):
            part = part.strip()
            if part.startswith('mbio_session='):
                return part[len('mbio_session='):]
        return None

    def _is_authenticated(self):
        """Return True if the request carries a valid session cookie or API Bearer token."""
        auth = self._auth
        if not auth or not auth._auth_enabled:
            return True
        # Static API Bearer token (Authorization: Bearer <token>)
        auth_header = self.headers.get('Authorization', '')
        if auth_header.startswith('Bearer '):
            if auth._is_api_token(auth_header[7:].strip()):
                return True
        # Session cookie
        token = self._get_session_token()
        if token:
            return auth._validate_session(token)
        return False

    def _redirect_to_login(self, original_path=None):
        """Send a 302 redirect to /login, preserving original path as query param."""
        dest = '/login'
        if original_path and original_path not in ('/login', '/logout'):
            dest = '/login?redirect=' + quote(original_path, safe='')
        self.send_response(302)
        self.send_header('Location', dest)
        self.end_headers()

    def _send_json(self, data, rcode=200):
        """Send a JSON response."""
        encoded = json.dumps(data).encode('utf-8')
        self.send_response(rcode)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

    def _serve_login_page(self):
        """Render and serve the Bootstrap login page."""
        html = """<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <title>Digimat MBIO Monitor</title>
    <link href="/www/Bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        body { background-color: #f8f9fa; }
        .login-card { max-width: 380px; margin: 100px auto; position: relative; z-index: 1; }
        .login-brand { font-size: 0.75rem; letter-spacing: 0.08em; }
        .login-bg-logo { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); opacity: 0.25; pointer-events: none; z-index: 0; }
    </style>
</head>
<body>
<div class="login-card card shadow-sm p-4">
    <div class="text-center mb-4">
        <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor"
             class="bi bi-shield-lock text-primary mb-2" viewBox="0 0 16 16">
            <path d="M5.338 1.59a61 61 0 0 0-2.837.856.48.48 0 0 0-.328.39c-.554 4.157.726 7.19
                     2.253 9.188a10.7 10.7 0 0 0 2.287 2.233c.346.244.652.42.893.533q.18.085.293.118
                     a1 1 0 0 0 .101.025 1 1 0 0 0 .1-.025q.114-.034.294-.118c.24-.113.547-.29.893-.533
                     a10.7 10.7 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0
                     -.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067
                     c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56
                     c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795
                     -2.465 9.99a11.8 11.8 0 0 1-2.517 2.453 7 7 0 0 1-1.048.625c-.28.132-.581.24
                     -.829.24s-.548-.108-.829-.24a7 7 0 0 1-1.048-.625 11.8 11.8 0 0 1-2.517-2.453
                     C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 63 63 0 0 1 5.072.56"/>
            <path d="M9.5 6.5a1.5 1.5 0 0 1-1 1.415l.385 1.99a.5.5 0 0 1-.491.595h-.788
                     a.5.5 0 0 1-.49-.595l.384-1.99A1.5 1.5 0 1 1 9.5 6.5"/>
        </svg>
        <h5 class="card-title mb-0">Digimat MBIO Processor Monitor</h5>
        <p class="text-muted login-brand mt-1">AUTHENTICATION REQUIRED</p>
    </div>
    <div id="alert-box"></div>
    <form id="login-form">
        <div class="mb-3">
            <label for="password" class="form-label">Password</label>
            <input type="password" class="form-control" id="password"
                   autofocus autocomplete="current-password"/>
        </div>
        <div class="d-grid">
            <button type="submit" class="btn btn-primary" id="login-btn">Sign in</button>
        </div>
    </form>
</div>
<div class="login-bg-logo">
    <img src="/www/images/digimat.png" alt="Digimat" style="max-height: 128px;">
</div>
<script src="/www/jquery/jquery.min.js"></script>
<script src="/www/Bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script>
$(function() {
    var redirect = (new URLSearchParams(window.location.search)).get('redirect') || '/';
    $('#login-form').on('submit', function(e) {
        e.preventDefault();
        var pwd = $('#password').val();
        if (!pwd) return;
        $('#login-btn').prop('disabled', true).text('Signing in...');
        $('#alert-box').html('');
        $.ajax({
            url: '/login',
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({password: pwd}),
            success: function(resp) {
                if (resp.ok) {
                    window.location = redirect;
                } else {
                    var msg = resp.reason === 'rate_limited'
                        ? 'Too many failed attempts. Please try again in a moment.'
                        : 'Incorrect password.';
                    showError(msg);
                    $('#password').val('').focus();
                    $('#login-btn').prop('disabled', false).text('Sign in');
                }
            },
            error: function() {
                showError('Connection error. Please try again.');
                $('#login-btn').prop('disabled', false).text('Sign in');
            }
        });
    });
    function showError(msg) {
        $('#alert-box').html(
            '<div class="alert alert-danger alert-dismissible">' + msg +
            '<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>'
        );
    }
});
</script>
</body>
</html>"""
        encoded = html.encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

    def _handle_login_post(self):
        """Process /login POST: validate password, create session if correct."""
        auth = self._auth
        ip = self.client_address[0]
        try:
            content_length = int(self.headers.get('Content-Length', 0))
            post_data = self.rfile.read(content_length)
            data = json.loads(post_data)
            password = data.get('password', '')
        except Exception:
            self._send_json({'ok': False, 'reason': 'bad_request'}, 400)
            return

        if auth._is_rate_limited(ip):
            self._send_json({'ok': False, 'reason': 'rate_limited'})
            return

        if auth._check_password(password):
            auth._record_success(ip)
            token = auth._create_session(ip=ip)
            cookie = 'mbio_session=%s; HttpOnly; SameSite=Strict; Path=/' % token
            encoded = json.dumps({'ok': True}).encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.send_header('Content-Length', str(len(encoded)))
            self.send_header('Set-Cookie', cookie)
            self.end_headers()
            self.wfile.write(encoded)
            if self._logger:
                self._logger.info('AUTH login success from %s' % ip)
        else:
            auth._record_failed_attempt(ip)
            self._send_json({'ok': False, 'reason': 'invalid_password'})
            if self._logger:
                self._logger.warning('AUTH login failure from %s' % ip)

    def _handle_logout(self):
        """Revoke session cookie and redirect to /login."""
        auth = self._auth
        token = self._get_session_token()
        if token:
            auth._revoke_session(token)
            if self._logger:
                self._logger.info('AUTH logout from %s' % self.client_address[0])
        self.send_response(302)
        self.send_header('Set-Cookie', 'mbio_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0')
        self.send_header('Location', '/login')
        self.end_headers()

    def _handle_auth_session(self, parsed):
        """Bootstrap a browser session from a programmatically created token.

        GET /auth/session?token=<token>[&redirect=/path]
        If the token is a valid session, sets the mbio_session cookie and redirects.
        Otherwise redirects to /login.
        """
        params = parse_qs(parsed.query)
        token = (params.get('token') or [None])[0]
        redirect = (params.get('redirect') or ['/'])[0]
        auth = self._auth
        if token and auth._validate_session(token):
            auth._slide_session(token)
            cookie = 'mbio_session=%s; HttpOnly; SameSite=Strict; Path=/' % token
            self.send_response(302)
            self.send_header('Set-Cookie', cookie)
            self.send_header('Location', redirect)
            self.end_headers()
        else:
            self._redirect_to_login()

    # ------------------------------------------------------------------

    def do_GET(self):
        logger = self.logger
        parsed = urlparse(self.path)
        path = parsed.path

        # Auth gate
        auth = self._auth
        if auth and auth._auth_enabled:
            if path == '/login':
                self._serve_login_page()
                return
            if path == '/logout':
                self._handle_logout()
                return
            if path == '/auth/session':
                self._handle_auth_session(parsed)
                return
            if not auth._is_user_exempt(path) and not self._is_authenticated():
                self._redirect_to_login(self.path)
                return
            token = self._get_session_token()
            if token:
                auth._slide_session(token)

        try:
            callbacks = self._callbacks.get('GET')
            if callbacks:
                callback = callbacks.get(path)
                if callback:
                    params = parse_qs(parsed.query)
                    # retain only 1 value for each args
                    for key in params.keys():
                        params[key] = params[key][0]

                    # if logger:
                        # self.logger.debug('WS GET callback %s(%s) %s' % (path, params, callback))
                    callback(self, params)
                    return
        except:
            self.logger.exception('do_GET')
            return  # do not fall through to static file serving after a callback failure

        try:
            # Fallback to default file handling for all other paths
            return super().do_GET()
        except:
            pass

    def do_POST(self):
        logger = self.logger
        parsed = urlparse(self.path)
        path = parsed.path

        # Auth gate
        auth = self._auth
        if auth and auth._auth_enabled:
            if path == '/login':
                self._handle_login_post()
                return
            if not auth._is_user_exempt(path) and not self._is_authenticated():
                self._redirect_to_login()
                return
            token = self._get_session_token()
            if token:
                auth._slide_session(token)

        try:
            callbacks = self._callbacks.get('POST')
            if callbacks:
                callback = callbacks.get(path)
                if callback:
                    params = parse_qs(parsed.query)
                    # retain only 1 value for each args
                    for key in params.keys():
                        params[key] = params[key][0]

                    content_length = int(self.headers.get('Content-Length', 0))
                    post_data = self.rfile.read(content_length)
                    data = json.loads(post_data)

                    if logger:
                        self.logger.debug('WS POST callback %s(%s) %s' % (path, params, callback))
                        # self.logger.debug(data)
                    callback(self, params, data)
                    return
        except:
            # self.logger.exception('do_POST')
            pass


class _SSLThreadingHTTPServer(ThreadingHTTPServer):
    """ThreadingHTTPServer with per-connection TLS wrapping.

    Wrapping each accepted connection (rather than the listening socket) is the
    robust approach: TLS errors on individual connections are isolated and caught
    by BaseServer._handle_request_noblock() which discards OSError from get_request().
    """

    def __init__(self, server_address, RequestHandlerClass, ssl_context=None, logger=None):
        super().__init__(server_address, RequestHandlerClass)
        self._ssl_context = ssl_context
        self._logger = logger

    def get_request(self):
        sock, addr = self.socket.accept()
        if self._ssl_context:
            try:
                # do_handshake_on_connect=False: the TLS handshake is deferred to
                # finish_request() which runs in a worker thread, so the main server
                # loop is never blocked waiting for a slow or stalled client.
                sock = self._ssl_context.wrap_socket(sock, server_side=True,
                                                     do_handshake_on_connect=False)
            except ssl.SSLError as e:
                sock.close()
                raise
        return sock, addr

    def finish_request(self, request, client_address):
        # Complete the deferred TLS handshake in the worker thread.
        if self._ssl_context and isinstance(request, ssl.SSLSocket):
            try:
                request.do_handshake()
            except ssl.SSLError as e:
                request.close()
                _SILENT = {'SSLV3_ALERT_CERTIFICATE_UNKNOWN', 'TLSV1_ALERT_UNKNOWN_CA',
                           'UNEXPECTED_EOF_WHILE_READING'}
                if self._logger and getattr(e, 'reason', None) not in _SILENT:
                    self._logger.debug('HTTPS TLS handshake failed from %s: %s' % (client_address[0], e))
                return
            except OSError:
                request.close()
                return
        super().finish_request(request, client_address)

    def handle_error(self, request, client_address):
        """Suppress noisy but harmless SSL/connection errors on stderr."""
        import sys
        exc = sys.exc_info()[1]
        _SILENT = (BrokenPipeError, ConnectionResetError, ssl.SSLError)
        if isinstance(exc, _SILENT):
            return
        super().handle_error(request, client_address)


class _WebLogHandler(logging.Handler):
    """logging.Handler that feeds log records to a TemporaryWebServer SSE stream."""

    def __init__(self, server):
        super().__init__()
        self._server = server

    def emit(self, record):
        try:
            msg = self.format(record)
            self._server._push_log_record(record, msg)
        except Exception:
            self.handleError(record)


class TemporaryWebServer(object):
    def __init__(self, fpath='/tmp/wserver', port=8000, host=None, logger=None):
        self._path: pathlib.Path = pathlib.Path(fpath)
        self._host = host
        self._port = int(port)
        self._portHttpsProxy = None
        self._ssl_certfile = None
        self._ssl_keyfile = None
        self._cert_managed = False   # True if cert was auto-generated by declare_certificate
        self._cert_stop = threading.Event()
        self._cert_thread = None
        self._interface = ''
        self._thread = None
        self._httpd = None
        self._files = {}
        self._enableAutoShutdown = True
        self._timeout = 0
        self._callbacks = {'GET': {}, 'POST': {}}
        self._logger = logger
        # Auth
        self._auth_enabled = False
        self._auth_hash = None
        self._auth_salt = None
        self._session_ttl = 86400
        self._sessions = {}
        self._sessions_lock = threading.Lock()
        self._failed_attempts = {}
        self._attempts_lock = threading.Lock()
        self._exempt_paths = set()
        self._api_tokens = set()
        # Log streaming (SSE)
        self._log_buffer = collections.deque(maxlen=500)
        self._log_seq = 0
        self._log_clients = []
        self._log_lock = threading.Lock()

    @property
    def logger(self):
        return self._logger

    def enableHttpsProxyPort(self, port=8443):
        self._portHttpsProxy = port

    @property
    def portHttpsProxy(self):
        return self._portHttpsProxy

    def set_ssl(self, certfile, keyfile=None):
        """Enable native HTTPS on this server.

        certfile: path to PEM certificate file (may include full chain).
        keyfile:  path to PEM private key file. If None, the private key is
                  expected to be bundled inside certfile.

        Must be called before start(). Once set, all connections are TLS-only;
        there is no HTTP fallback (run two instances on different ports if needed).
        """
        self._ssl_certfile = certfile
        self._ssl_keyfile = keyfile or certfile
        self._cert_managed = False   # external cert — cannot auto-renew
        if self._logger:
            self._logger.info('SSL certificate configured: %s' % certfile)

    def declare_certificate(self, path, cert_name='wsmonitor.pem', reuse=True):
        """Ensure a TLS certificate exists and activate HTTPS.

        path: str or list of str — candidate directories (tested in order).
              The first directory that exists on disk is used.
              If none exists, HTTPS is not activated (silent, just logged).

        cert_name: filename of the PEM bundle inside the selected directory
                   (default: 'wsmonitor.pem').

        reuse: if False, the certificate is always regenerated at
               startup. If True (default), an existing valid certificate is reused.

        Automatically calls set_ssl() on success.
        Returns True if HTTPS is ready, False otherwise.

        Usage:
            ws.declare_certificate('/etc/mbio/ssl')
            ws.declare_certificate(['/mnt/persist/ssl', '/tmp/ssl'])
            ws.declare_certificate(['/etc/mbio', '/tmp'], cert_name='mbio.pem')
            ws.declare_certificate('/etc/mbio/ssl', reuse=True)
            ws.start()
        """
        dirs = list(path) if isinstance(path, (list, tuple)) else [path]

        # Find first existing directory
        target_dir = None
        for d in dirs:
            if os.path.isdir(d):
                target_dir = d
                break

        if target_dir is None:
            if self._logger:
                self._logger.info('SSL no existing directory found in %s — HTTPS not activated' % dirs)
            return False

        cert_path = os.path.join(target_dir, cert_name)
        if self._logger:
            self._logger.info('SSL using certificate: %s' % cert_path)

        if reuse and self._is_cert_valid(cert_path):
            remaining = self._cert_days_remaining(cert_path)
            if self._logger:
                self._logger.info('SSL certificate OK (reusing, %d days remaining): %s' % (remaining, cert_path))
        else:
            if self._logger:
                reason = '' if not reuse else ' (< 365 days remaining or missing)'
                self._logger.info('SSL generating self-signed certificate%s → %s' % (reason, cert_path))
            if not self._generate_self_signed_cert(cert_path):
                if self._logger:
                    self._logger.error('SSL certificate generation failed — HTTPS not activated')
                return False
            if self._logger:
                self._logger.info('SSL self-signed certificate created: %s' % cert_path)

        self.set_ssl(cert_path)
        self._cert_managed = True   # set_ssl() resets to False; restore: we own this cert
        return True

    def _cert_days_remaining(self, path=None):
        """Return remaining validity days for the given cert path (default: current certfile).

        Returns None if the file cannot be read or parsed.
        """
        certfile = path or self._ssl_certfile
        if not certfile:
            return None
        try:
            from cryptography import x509
            import datetime
            with open(certfile, 'rb') as f:
                data = f.read()
            cert = x509.load_pem_x509_certificate(data)
            now = datetime.datetime.now(datetime.timezone.utc)
            try:
                not_after = cert.not_valid_after_utc       # cryptography >= 42
            except AttributeError:
                not_after = cert.not_valid_after.replace(tzinfo=datetime.timezone.utc)
            return (not_after - now).days
        except Exception:
            return None

    def _is_cert_valid(self, path, min_days=365):
        """Return True if cert is readable and has at least min_days of validity remaining."""
        remaining = self._cert_days_remaining(path)
        if remaining is None:
            return False
        return remaining >= min_days

    def _reload_ssl_context(self):
        """Hot-reload the SSL context from the current certfile.

        Updates the running server without requiring a restart. New connections
        will use the reloaded certificate; existing connections are unaffected.
        Returns True on success.
        """
        try:
            ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
            ctx.load_cert_chain(self._ssl_certfile, self._ssl_keyfile)
            if self._httpd:
                self._httpd._ssl_context = ctx
            if self._logger:
                self._logger.info('SSL context hot-reloaded')
            return True
        except Exception:
            if self._logger:
                self._logger.exception('SSL context reload failed')
            return False

    _CERT_CHECK_INTERVAL = 86400  # check every 24 hours

    def _check_cert_renewal(self):
        """Check certificate expiry and act: renew if managed, warn if external."""
        if not self._ssl_certfile:
            return
        remaining = self._cert_days_remaining()
        if remaining is None:
            return
        if self._cert_managed:
            if remaining < 365:
                if self._logger:
                    self._logger.warning('SSL certificate expires in %d days — auto-renewing' % remaining)
                if self._generate_self_signed_cert(self._ssl_certfile):
                    if self._reload_ssl_context():
                        if self._logger:
                            self._logger.info('SSL certificate renewed and reloaded (new expiry: 9999-12-31)')
                    else:
                        if self._logger:
                            self._logger.error('SSL certificate renewed on disk but context reload failed')
                else:
                    if self._logger:
                        self._logger.error('SSL certificate auto-renewal failed — keeping existing cert')
        else:
            if remaining < 7:
                if self._logger:
                    self._logger.error('SSL certificate expires in %d days — manual renewal required!' % remaining)
            elif remaining < 30:
                if self._logger:
                    self._logger.warning('SSL certificate expires in %d days — please renew' % remaining)

    def _cert_check_loop(self):
        """Background daemon: check certificate validity every 24 hours."""
        while not self._cert_stop.wait(self._CERT_CHECK_INTERVAL):
            self._check_cert_renewal()

    def _generate_self_signed_cert(self, path):
        """Generate a self-signed RSA-2048 certificate, save as PEM bundle to path.

        Validity: from now until 9999-12-31 (X.509 maximum).
        SAN: localhost, 127.0.0.1, and the configured host if set.
        Returns True on success, False on error.
        """
        try:
            import datetime
            import ipaddress as _ipaddress
            from cryptography import x509
            from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
            from cryptography.hazmat.primitives import hashes, serialization
            from cryptography.hazmat.primitives.asymmetric import rsa

            if self._logger:
                self._logger.info('SSL generating RSA-2048 private key...')
            key = rsa.generate_private_key(public_exponent=65537, key_size=2048)

            now = datetime.datetime.now(datetime.timezone.utc)
            not_after = datetime.datetime(9999, 12, 31, 23, 59, 59,
                                          tzinfo=datetime.timezone.utc)

            name = x509.Name([
                x509.NameAttribute(NameOID.COMMON_NAME, 'mbio.local'),
                x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Digimat MBIO'),
            ])

            san_dns = [x509.DNSName('localhost'), x509.DNSName('mbio.local')]
            san_ip_set = {'127.0.0.1'}

            # Enumerate all local IPv4 addresses so the cert is valid on any interface
            try:
                hostname = socket.gethostname()
                if hostname:
                    san_dns.append(x509.DNSName(hostname))
                for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
                    san_ip_set.add(info[4][0])
            except Exception:
                pass

            # Also include the explicitly configured host
            host = self._host or self._interface
            if host and host not in ('', '0.0.0.0'):
                try:
                    san_ip_set.add(str(_ipaddress.ip_address(host)))
                except ValueError:
                    san_dns.append(x509.DNSName(host))

            san_ip = [x509.IPAddress(_ipaddress.IPv4Address(ip)) for ip in sorted(san_ip_set)]

            if self._logger:
                san_repr = ', '.join(
                    [d.value for d in san_dns] + [str(i.value) for i in san_ip]
                )
                self._logger.info('SSL signing certificate (SAN: %s, valid until 9999-12-31)' % san_repr)

            cert = (
                x509.CertificateBuilder()
                .subject_name(name)
                .issuer_name(name)
                .public_key(key.public_key())
                .serial_number(x509.random_serial_number())
                .not_valid_before(now)
                .not_valid_after(not_after)
                .add_extension(
                    x509.SubjectAlternativeName(san_dns + san_ip),
                    critical=False,
                )
                .add_extension(
                    x509.BasicConstraints(ca=False, path_length=None),
                    critical=True,
                )
                .add_extension(
                    x509.KeyUsage(
                        digital_signature=True,
                        content_commitment=False,
                        key_encipherment=True,
                        data_encipherment=False,
                        key_agreement=False,
                        key_cert_sign=False,
                        crl_sign=False,
                        encipher_only=False,
                        decipher_only=False,
                    ),
                    critical=True,
                )
                .add_extension(
                    x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
                    critical=False,
                )
                .sign(key, hashes.SHA256())
            )

            cert_pem = cert.public_bytes(serialization.Encoding.PEM)
            key_pem = key.private_bytes(
                serialization.Encoding.PEM,
                serialization.PrivateFormat.TraditionalOpenSSL,
                serialization.NoEncryption(),
            )

            with open(path, 'wb') as f:
                f.write(cert_pem)
                f.write(key_pem)
            os.chmod(path, 0o600)
            if self._logger:
                self._logger.info('SSL certificate saved: %s (chmod 600)' % path)
            return True

        except Exception:
            if self._logger:
                self._logger.exception('SSL certificate generation error')
            return False

    @property
    def is_https(self):
        """Return True when native SSL/TLS is configured."""
        return bool(self._ssl_certfile)

    def url(self, path=None):
        host = self._host or self._interface
        if host:
            scheme = 'https' if self.is_https else 'http'
            url = '%s://%s:%d' % (scheme, host, self._port)
            if path:
                if path[0] != '/':
                    url += '/'
                url += path
            return url

    def registerCallback(self, method, rpath, callback):
        try:
            if rpath and callback and callable(callback):
                data = self._callbacks.get(method.upper())
                if data is not None:
                    data[rpath] = callback
        except:
            pass

    def registerGetCallback(self, rpath, callback):
        return self.registerCallback('GET', rpath, callback)

    def registerPostCallback(self, rpath, callback):
        return self.registerCallback('POST', rpath, callback)

    def registerGetPostCallback(self, rpath, callback):
        self.registerGetCallback(rpath, callback)
        self.registerPostCallback(rpath, callback)

    # ------------------------------------------------------------------
    # Authentication
    # ------------------------------------------------------------------

    def add_exempt_path(self, path):
        """Register a path that bypasses authentication.

        Exact match for paths not ending with '/'; prefix match for paths ending with '/'.
        Example: add_exempt_path('/health') or add_exempt_path('/public/').
        """
        if path:
            self._exempt_paths.add(path)

    def _is_user_exempt(self, path):
        """Return True if path matches a user-registered exempt path."""
        for p in self._exempt_paths:
            if p.endswith('/'):
                if path == p.rstrip('/') or path.startswith(p):
                    return True
            elif path == p:
                return True
        return False

    def add_api_token(self, token):
        """Register a static Bearer token for programmatic or cross-app access.

        Accepted via Authorization: Bearer <token> request header.
        Requests using a valid API token bypass cookie-based authentication.
        """
        if token:
            self._api_tokens.add(token)

    def remove_api_token(self, token):
        """Unregister a previously added API token."""
        self._api_tokens.discard(token)

    def _is_api_token(self, token):
        """Return True if token is a registered API token."""
        return bool(token and token in self._api_tokens)

    def create_session(self, ip=None):
        """Programmatically create an authenticated session and return its token.

        The token can be used in two ways:
        - Directly by another Python app in the same process via _validate_session(token).
        - To bootstrap a browser session via GET /auth/session?token=<token>, which sets
          the mbio_session cookie and redirects the browser to the application.
        """
        return self._create_session(ip=ip)

    def revoke_session(self, token):
        """Immediately invalidate a session by its full token."""
        self._revoke_session(token)

    def sessions_info(self):
        """Return a snapshot of all active (non-expired) sessions as a list of dicts.

        Each dict contains: token, ip, created (epoch float), expiry (epoch float).
        Expired sessions are pruned during this call.
        """
        now = time.time()
        result = []
        with self._sessions_lock:
            expired = [t for t, d in self._sessions.items() if now >= d['expiry']]
            for t in expired:
                del self._sessions[t]
            for token, data in self._sessions.items():
                result.append({
                    'token': token,
                    'ip': data.get('ip', ''),
                    'created': data.get('created', 0.0),
                    'expiry': data['expiry'],
                })
        return result

    def api_tokens_info(self):
        """Return a sorted list of all registered API Bearer tokens."""
        return sorted(self._api_tokens)

    def set_password(self, password, ttl=86400):
        """Activate password authentication.

        Once called, all HTTP requests require a valid session cookie.
        ttl: session lifetime in seconds after last activity (sliding, default 24h).
        """
        self._auth_salt = secrets.token_hex(16)
        self._auth_hash = hashlib.sha256(
            (self._auth_salt + password.lower()).encode('utf-8')
        ).hexdigest()
        self._session_ttl = ttl
        self._auth_enabled = True
        # The login page loads JS/CSS from /www/ — auto-exempt so it can render
        # even before the browser has a session cookie.
        self.add_exempt_path('/www/')

    def _check_password(self, password):
        """Constant-time password verification (prevents timing attacks)."""
        if not self._auth_enabled or not self._auth_hash:
            return False
        h = hashlib.sha256((self._auth_salt + password.lower()).encode('utf-8')).hexdigest()
        return hmac.compare_digest(h, self._auth_hash)

    def _create_session(self, ip=None):
        """Generate a new session token, store it with metadata, and return it."""
        token = secrets.token_hex(32)
        with self._sessions_lock:
            self._sessions[token] = {
                'expiry': time.time() + self._session_ttl,
                'created': time.time(),
                'ip': ip or '',
            }
        return token

    def _validate_session(self, token):
        """Return True if the session token exists and has not expired."""
        with self._sessions_lock:
            data = self._sessions.get(token)
            if data is None:
                return False
            if time.time() >= data['expiry']:
                del self._sessions[token]
                return False
            return True

    def _slide_session(self, token):
        """Extend the session expiry from now (sliding TTL)."""
        with self._sessions_lock:
            if token in self._sessions:
                self._sessions[token]['expiry'] = time.time() + self._session_ttl

    def _revoke_session(self, token):
        """Immediately invalidate a session (logout)."""
        with self._sessions_lock:
            self._sessions.pop(token, None)

    def _is_rate_limited(self, ip):
        """Return True if the IP is currently blocked due to too many failed attempts."""
        with self._attempts_lock:
            data = self._failed_attempts.get(ip)
            if not data:
                return False
            return time.time() < data.get('blocked_until', 0)

    def _record_failed_attempt(self, ip, max_attempts=5, block_duration=60):
        """Increment failed login counter; block IP for block_duration after max_attempts failures."""
        with self._attempts_lock:
            data = self._failed_attempts.setdefault(ip, {'count': 0, 'blocked_until': 0})
            # Reset count if previous block has expired
            if time.time() >= data['blocked_until']:
                data['count'] += 1
                if data['count'] >= max_attempts:
                    data['blocked_until'] = time.time() + block_duration
                    data['count'] = 0

    def _record_success(self, ip):
        """Clear failed attempt tracking for an IP after successful login."""
        with self._attempts_lock:
            self._failed_attempts.pop(ip, None)

    # ------------------------------------------------------------------
    # Log streaming (SSE)
    # ------------------------------------------------------------------

    def set_log_buffer_size(self, size):
        """Change the circular log buffer capacity (default 500 records)."""
        with self._log_lock:
            self._log_buffer = collections.deque(self._log_buffer, maxlen=int(size))

    def attach_logger(self, logger, level=logging.DEBUG):
        """Attach a Python logger to stream its records to /api/v1/logstream.

        Registers the SSE endpoint automatically.
        Returns the handler so it can be detached later if needed.
        """
        h = _WebLogHandler(self)
        h.setLevel(level)
        h.setFormatter(logging.Formatter('%(message)s'))
        logger.addHandler(h)
        self.registerGetCallback('/api/v1/logstream', self._sse_logstream)
        return h

    def _push_log_record(self, record, msg):
        """Push a formatted log record to the circular buffer and all SSE clients."""
        with self._log_lock:
            self._log_seq += 1
            entry = {
                'seq': self._log_seq,
                'ts': record.created,
                'level': record.levelname,
                'name': record.name,
                'msg': msg,
            }
            self._log_buffer.append(entry)
            for q in list(self._log_clients):
                try:
                    q.put_nowait(entry)
                except queue.Full:
                    pass

    def _sse_logstream(self, handler, params):
        """SSE endpoint: streams log records to the browser in real time.

        Supports Last-Event-ID for catch-up on reconnection.
        Sends a heartbeat comment every 15 s to keep the connection alive.
        """
        handler.send_response(200)
        handler.send_header('Content-Type', 'text/event-stream')
        handler.send_header('Cache-Control', 'no-cache')
        handler.send_header('Connection', 'keep-alive')
        handler.end_headers()

        try:
            handler.request.settimeout(None)
        except Exception:
            pass

        last_id_str = handler.headers.get('Last-Event-ID', '')
        try:
            last_id = int(last_id_str)
        except (ValueError, TypeError):
            last_id = -1

        q = queue.Queue(maxsize=500)

        with self._log_lock:
            self._log_clients.append(q)
            history = [r for r in self._log_buffer if r['seq'] > last_id]

        try:
            for entry in history:
                data = 'id: %d\ndata: %s\n\n' % (entry['seq'], json.dumps(entry))
                handler.wfile.write(data.encode('utf-8'))
            handler.wfile.flush()

            while True:
                try:
                    entry = q.get(timeout=15)
                    data = 'id: %d\ndata: %s\n\n' % (entry['seq'], json.dumps(entry))
                    handler.wfile.write(data.encode('utf-8'))
                    handler.wfile.flush()
                except queue.Empty:
                    handler.wfile.write(b': heartbeat\n\n')
                    handler.wfile.flush()
        except (BrokenPipeError, ConnectionResetError, OSError):
            pass
        finally:
            with self._log_lock:
                try:
                    self._log_clients.remove(q)
                except ValueError:
                    pass

    # ------------------------------------------------------------------

    def server(self):
        handler = functools.partial(QuietSimpleHTTPRequestHandlerWithLogger,
                                    directory=self.pathstr(),
                                    logger=self.logger,
                                    callbacks=self._callbacks,
                                    auth=self)

        ssl_ctx = None
        if self._ssl_certfile:
            try:
                ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
                ssl_ctx.load_cert_chain(self._ssl_certfile, self._ssl_keyfile)
            except Exception:
                if self._logger:
                    self._logger.exception('HTTPS SSL context error — falling back to HTTP')

        with _SSLThreadingHTTPServer((self._interface, self._port), handler, ssl_ctx, self._logger) as httpd:
            if ssl_ctx and self._logger:
                self._logger.info('HTTPS enabled on port %d' % self._port)

            try:
                self._httpd = httpd
                httpd.serve_forever()
            except:
                pass

            try:
                httpd.server_close()
            except:
                pass

        self._httpd = None

    def pathstr(self):
        try:
            return str(self._path)
        except:
            pass

    def createPath(self):
        try:
            self._path.mkdir(parents=True, exist_ok=True)
            return True
        except:
            pass
        return False

    def linkPath(self, fpath, name=None):
        try:
            p: pathlib.Path = pathlib.Path(fpath).expanduser()
            if p.exists() and p.is_dir():
                self.createPath()
                fname = p.name
                if name:
                    fname = name
                os.symlink(str(p), str(self.getPathForFile(fname)))
        except:
            pass

    def getPathForFile(self, fname):
        try:
            return self._path.joinpath(fname)
        except:
            pass

    def isFile(self, fname):
        try:
            p = self.getPathForFile(fname)
            if p.exists() and p.is_file():
                return True
        except:
            pass
        return False

    def computeDataHashCodeForFile(self, f):
        try:
            return hashlib.file_digest(f, 'sha256').hexdigest()
        except:
            pass

    def importFile(self, fpath, fname=None, timeout=0):
        try:
            sp = pathlib.Path(fpath)
            if sp.is_file():
                self.start()
                if not fname:
                    fname = sp.name
                tp = self.getPathForFile(fname)
                # only copy file is not already exists (prevent file corruption while exposed)
                if not tp.is_file():
                    shutil.copyfile(str(sp), str(tp))
                self._files[fname] = {'fname': fname, 'stamp': time.time(), 'timeout': timeout}
                self._timeout = time.time() + 60
                url = self.url(fname)
                if url:
                    return url
        except:
            pass
        return False

    def getFileContent(self, fname):
        try:
            if fname:
                p = self.getPathForFile(fname)
                # print(p)
                with open(str(p), 'rb') as f:
                    data = f.read()
                    return data
        except:
            pass

    def removeFile(self, fname):
        try:
            if fname:
                p = self.getPathForFile(fname)
                p.unlink()
                try:
                    del self._files[fname]
                except:
                    pass
                return True
        except:
            pass
        return False

    def isTimeout(self, stamp):
        if time.time() >= stamp:
            return True
        return False

    def isFileTimeout(self, fname):
        try:
            timeout = self._files[fname].get('timeout', 0)
            stamp = self._files[fname].get('stamp', 0)
            p = self.getPathForFile(fname)
            if not self.isFile(fname):
                return True
            if timeout > 0 and self.isTimeout(stamp + timeout):
                return True
                # p=self.getPathForFile(fname)
                # info=p.stat()
                # age=time.time()-info.st_mtime
                # if age>=timeout:
                    # return True
        except:
            pass
        return False

    def getFiles(self):
        try:
            files = []
            for f in self._path.iterdir():
                fname = f.name
                if not self.isFile(fname):
                    continue
                if self.isFileTimeout(fname):
                    self.removeFile(fname)
                    continue
                files.append(fname)
            return files
        except:
            pass

    def start(self):
        if not self._thread:
            self.createPath()
            self._thread = threading.Thread(target=self.server)
            self._thread.daemon = True
            self._thread.start()
            if self._ssl_certfile and not self._cert_thread:
                self._cert_stop.clear()
                self._cert_thread = threading.Thread(target=self._cert_check_loop, name='cert-monitor')
                self._cert_thread.daemon = True
                self._cert_thread.start()

    def stop(self):
        self.enableAutoShutdown()
        if self._cert_thread:
            self._cert_stop.set()
            self._cert_thread.join(timeout=5)
            self._cert_thread = None
        if self._thread:
            try:
                self._httpd.shutdown()
            except:
                pass

            try:
                # force a fake request to the server (may be waiting for a request)
                proxies = {'http': '', 'https': ''}
                scheme = 'https' if self.is_https else 'http'
                requests.get('%s://localhost:%d/shutdown' % (scheme, self._port),
                             timeout=1.0, proxies=proxies, verify=False)
            except:
                pass

            self._thread.join()
            self._thread = None

    def isRunning(self):
        if self._thread:
            return True
        return False

    def keelAlive(self):
        if self.isRunning():
            self._timeout = time.time() + 15

    def enableAutoShutdown(self, state=True):
        self._enableAutoShutdown = state

    def disableAutoShutdown(self):
        self.enableAutoShutdown(False)

    def manager(self):
        files = self.getFiles()
        if files:
            self._timeout = time.time() + 60

        if not self._enableAutoShutdown:
            self.keelAlive()

        if time.time() > self._timeout:
            self.stop()

    def __del__(self):
        self.stop()


if __name__ == '__main__':
    ws = TemporaryWebServer('/tmp/wserver')
    ws.importFile('/tmp/myfile', 'fhe', 600)
    ws.start()
