#!/usr/bin/env python3
"""NACEF SIC browser connector.

Enables the "cloud app + local SMDF" model: a page served from the cloud
(e.g. https://test.kaissflow.com) runs JavaScript that must call the NACEF
SIC Agent on the cashier's own machine (http://localhost:10006). The browser
CAN reach localhost (the JS runs on the local machine), but two browser rules
block a *direct* call to the raw SIC:

  * CORS  -- the SIC (a fixed Ministry binary) never sends
            Access-Control-Allow-Origin, so the browser can't read its reply;
  * Private Network Access (PNA) -- a public HTTPS page calling a private/
            localhost address must pass a preflight the SIC won't answer.

This tiny connector runs ON THE CASHIER'S MACHINE, listens on localhost, adds
the CORS + PNA headers the browser needs, and forwards each call to the SIC at
127.0.0.1:10006 (so the SIC still sees a localhost caller and accepts it).

    Browser (cloud page)  --CORS-->  this connector  --plain-->  SIC :10006

Only binds to 127.0.0.1, so only a browser on this machine can use it. An
origin allow-list restricts which web origins may talk to it.

Env:
    LISTEN_HOST   default 127.0.0.1
    LISTEN_PORT   default 10016
    TARGET_HOST   default 127.0.0.1
    TARGET_PORT   default 10006
    ALLOW_SUFFIXES  comma list of allowed Origin host suffixes,
                    default ".kaissflow.com,localhost,127.0.0.1"
"""
import os
import sys
import tempfile
import traceback
import urllib.error
import urllib.request
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit

# The connector runs headless (a Windows scheduled task under pythonw, a
# systemd unit on Linux), so anything printed to a console is lost. Without a
# log, a crash looks identical to "the till was never set up" — which is what
# happened: the process died mid-session and the only symptom was a POS
# signature timing out. Always leave a trace.
LOG_PATH = os.environ.get(
    "NACEF_LOG",
    os.path.join(tempfile.gettempdir(), "nacef-connector.log"))


def log(message):
    line = "%s  %s" % (datetime.now().isoformat(timespec="seconds"), message)
    try:
        with open(LOG_PATH, "a", encoding="utf-8") as fh:
            fh.write(line + "\n")
    except OSError:
        pass  # logging must never take the connector down
    print(line, flush=True)

LISTEN_HOST = os.environ.get("LISTEN_HOST", "127.0.0.1")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "10016"))
TARGET_HOST = os.environ.get("TARGET_HOST", "127.0.0.1")
TARGET_PORT = int(os.environ.get("TARGET_PORT", "10006"))
ALLOW_SUFFIXES = tuple(
    s.strip() for s in os.environ.get(
        "ALLOW_SUFFIXES", ".kaissflow.com,localhost,127.0.0.1").split(",")
    if s.strip())
TARGET = "http://%s:%d" % (TARGET_HOST, TARGET_PORT)


def _origin_allowed(origin):
    if not origin:
        return False
    host = (urlsplit(origin).hostname or "").lower()
    return any(host == s or host.endswith(s) for s in ALLOW_SUFFIXES)


class Handler(BaseHTTPRequestHandler):
    server_version = "NacefSicConnector/1.0"

    # -- CORS helpers ---------------------------------------------------- #
    def _cors(self):
        origin = self.headers.get("Origin")
        if _origin_allowed(origin):
            self.send_header("Access-Control-Allow-Origin", origin)
            self.send_header("Vary", "Origin")
            self.send_header("Access-Control-Allow-Credentials", "false")

    def do_OPTIONS(self):
        # CORS + Private Network Access preflight.
        origin = self.headers.get("Origin")
        if not _origin_allowed(origin):
            self.send_response(403)
            self.end_headers()
            return
        self.send_response(204)
        self._cors()
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers",
                         self.headers.get("Access-Control-Request-Headers",
                                          "Content-Type"))
        if self.headers.get("Access-Control-Request-Private-Network") == "true":
            self.send_header("Access-Control-Allow-Private-Network", "true")
        self.send_header("Access-Control-Max-Age", "600")
        self.end_headers()

    # -- proxying -------------------------------------------------------- #
    def _proxy(self, method):
        origin = self.headers.get("Origin")
        if origin and not _origin_allowed(origin):
            self.send_response(403)
            self.end_headers()
            return
        length = int(self.headers.get("Content-Length") or 0)
        body = self.rfile.read(length) if length else None
        req = urllib.request.Request(TARGET + self.path, data=body,
                                     method=method)
        ct = self.headers.get("Content-Type")
        if ct:
            req.add_header("Content-Type", ct)
        req.add_header("Accept", "application/json")
        try:
            with urllib.request.urlopen(req, timeout=310) as resp:
                status, payload = resp.status, resp.read()
                ctype = resp.headers.get("Content-Type", "application/json")
        except urllib.error.HTTPError as exc:
            status, payload = exc.code, exc.read()
            ctype = exc.headers.get("Content-Type", "application/json")
        except urllib.error.URLError as exc:
            status = 502
            payload = ('{"errorCode":512,"message":"SIC unreachable at %s: %s"}'
                       % (TARGET, exc.reason)).encode()
            ctype = "application/json"
        except Exception as exc:  # noqa: BLE001
            # Any other failure (malformed upstream reply, socket error...)
            # must become an HTTP answer, not an escaping exception. The POS
            # can act on a 502; it cannot act on a connection that just dies.
            log("proxy %s %s failed: %r" % (method, self.path, exc))
            status = 502
            payload = ('{"errorCode":512,"message":"connector error: %s"}'
                       % str(exc).replace('"', "'")).encode()
            ctype = "application/json"
        try:
            self.send_response(status)
            self._cors()
            self.send_header("Content-Type", ctype)
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            if payload:
                self.wfile.write(payload)
        except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
            # The browser gave up (POS timeout, cashier navigated away). Not an
            # error worth a traceback, and definitely not worth killing over.
            log("client disconnected before the %s reply was written" % method)

    def do_GET(self):
        self._proxy("GET")

    def do_POST(self):
        self._proxy("POST")

    def log_message(self, fmt, *args):  # quieter logging
        return


def main():
    try:
        srv = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
    except OSError as exc:
        # Almost always "address already in use": a previous instance is still
        # holding the port. Say so plainly — the scheduled task otherwise
        # reports "Running" while nothing answers, which is what happened here.
        log("FATAL: cannot listen on %s:%d — %s. Is another connector already "
            "running? Stop it first (nacef-connector restart)."
            % (LISTEN_HOST, LISTEN_PORT, exc))
        return 1
    log("started: http://%s:%d -> %s  (origins: %s)  log: %s"
        % (LISTEN_HOST, LISTEN_PORT, TARGET, ", ".join(ALLOW_SUFFIXES),
           LOG_PATH))
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        log("stopped on interrupt")
    except Exception:  # noqa: BLE001
        # Never die silently: a headless crash is indistinguishable from a
        # till that was never configured.
        log("FATAL: serve loop crashed\n" + traceback.format_exc())
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
