#!/usr/bin/env python3
"""Fail the build on outbound curl calls that can hang indefinitely.

Why this exists
---------------
On 2026-08-13 a division edit returned "upstream request timeout" in the browser.
The cause was not the database and not the application logic: Bridge.php curled an
on-premise address (192.168.1.77:8380) that is unroutable from the VPC, with no
connect timeout set, so curl waited out the kernel's full TCP retry:

    Failed to connect to 192.168.1.77:8380 after 133118 ms

ECS Service Connect gives each request 15s (HTTP_X_ENVOY_EXPECTED_RQ_TIMEOUT_MS),
so Envoy returned 504 at 15s while PHP stayed blocked for another two minutes.

The call was already wrapped in try/catch with a comment saying failures must not
break the main flow -- but a blocked socket raises nothing. try/catch cannot bound
a hang; only a timeout can. That is the class of bug this check exists to prevent.

CURLOPT_CONNECTTIMEOUT is the one that matters most. CURLOPT_TIMEOUT alone bounds
the whole operation, which is enough to stay inside the Envoy budget, so either is
accepted -- but a handle with neither is a latent outage.

The repo had 70 unbounded handles when this check was written, so failing on all of
them would break every build on day one and the check would simply be removed. It
therefore works against a baseline: ci/curl-timeout-baseline.txt records the count
per file that already existed, and the check fails only when a file's count RISES or
a new file appears. Fixing a file and lowering its baseline is always safe; the count
can never drift upward unnoticed.

Baseline by file and count rather than by line number, deliberately: line numbers
shift on every unrelated edit above them, which would make the baseline noisy and
train people to regenerate it without reading it.

Usage:  python3 ci/curl-timeout-check.py [--quiet] [--update-baseline]
Exit:   0 = no regression against the baseline, 1 = regression
"""

import os
import re
import sys

# Directories that are not ours to fix.
SKIP_DIRS = {
    "vendor", "node_modules", ".git", "runtime", "dist", "demo",
    "assets", "web/assets", "__pycache__",
}

# How far past a curl_init to look for the matching exec. Real handles are
# configured within a few lines; a large window only risks bleeding into the
# NEXT handle, which is why the scan stops at curl_init/curl_exec/curl_close.
WINDOW = 80

RE_INIT = re.compile(r"\bcurl_init\s*\(")
RE_EXEC = re.compile(r"\bcurl_exec\s*\(")
RE_CLOSE = re.compile(r"\bcurl_close\s*\(")
RE_CONNECTTIMEOUT = re.compile(r"CURLOPT_CONNECTTIMEOUT(?:_MS)?\b")
RE_TIMEOUT = re.compile(r"CURLOPT_TIMEOUT(?:_MS)?\b")


def php_files(root):
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for name in filenames:
            if name.endswith(".php"):
                yield os.path.join(dirpath, name)


def audit_file(path):
    """Return a list of (line_no, has_connect, has_total) for each curl handle."""
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as fh:
            lines = fh.readlines()
    except OSError:
        return []

    findings = []
    for i, line in enumerate(lines):
        if not RE_INIT.search(line):
            continue

        has_connect = bool(RE_CONNECTTIMEOUT.search(line))
        has_total = bool(RE_TIMEOUT.search(line))

        # Scan forward to the point the handle is used or released. Stopping at a
        # second curl_init matters: without it, one well-configured handle would
        # mask an unbounded one declared just above it.
        for j in range(i + 1, min(i + 1 + WINDOW, len(lines))):
            nxt = lines[j]
            if RE_CONNECTTIMEOUT.search(nxt):
                has_connect = True
            if RE_TIMEOUT.search(nxt):
                has_total = True
            if RE_EXEC.search(nxt) or RE_CLOSE.search(nxt) or RE_INIT.search(nxt):
                break

        findings.append((i + 1, has_connect, has_total))
    return findings


BASELINE_NAME = "curl-timeout-baseline.txt"


def load_baseline(path):
    """{relative_path: allowed_unbounded_count}. Absent file means an empty baseline."""
    counts = {}
    if not os.path.exists(path):
        return counts
    with open(path, "r", encoding="utf-8") as fh:
        for raw in fh:
            line = raw.strip()
            if not line or line.startswith("#"):
                continue
            rel, _, num = line.rpartition(" ")
            try:
                counts[rel.strip()] = int(num)
            except ValueError:
                continue
    return counts


def write_baseline(path, per_file):
    with open(path, "w", encoding="utf-8", newline="\n") as fh:
        fh.write("# Unbounded curl handles per file, accepted as pre-existing.\n")
        fh.write("# Generated by ci/curl-timeout-check.py --update-baseline\n")
        fh.write("#\n")
        fh.write("# A count may only go DOWN. Lower it when you fix a file; the check\n")
        fh.write("# fails if any count rises or a new file appears. Do not regenerate\n")
        fh.write("# this to silence a failure - that is the failure doing its job.\n")
        fh.write("#\n")
        fh.write("# format: <path> <count>\n")
        for rel in sorted(per_file):
            fh.write("%s %d\n" % (rel, per_file[rel]))


def main():
    quiet = "--quiet" in sys.argv
    update = "--update-baseline" in sys.argv
    root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    baseline_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), BASELINE_NAME)

    total = 0
    unbounded = []
    connect_missing = []
    per_file = {}

    for path in sorted(php_files(root)):
        rel = os.path.relpath(path, root).replace("\\", "/")
        for line_no, has_connect, has_total in audit_file(path):
            total += 1
            if not has_connect and not has_total:
                unbounded.append((rel, line_no))
                per_file[rel] = per_file.get(rel, 0) + 1
            elif not has_connect:
                connect_missing.append((rel, line_no))

    if update:
        write_baseline(baseline_path, per_file)
        print("baseline written: %s (%d files, %d handles)"
              % (os.path.relpath(baseline_path, root).replace("\\", "/"),
                 len(per_file), len(unbounded)))
        return 0

    baseline = load_baseline(baseline_path)

    regressions = []
    for rel, count in sorted(per_file.items()):
        allowed = baseline.get(rel)
        if allowed is None:
            regressions.append((rel, count, 0, "new file with unbounded handles"))
        elif count > allowed:
            regressions.append((rel, count, allowed, "count rose"))

    improvements = [(rel, baseline[rel], per_file.get(rel, 0))
                    for rel in sorted(baseline)
                    if per_file.get(rel, 0) < baseline[rel]]

    if not quiet and connect_missing:
        print("Bounded overall, but no connect timeout (lower risk, not gated): %d"
              % len(connect_missing))
        print()

    if regressions:
        print("FAIL - new curl handle(s) with neither CURLOPT_TIMEOUT nor")
        print("CURLOPT_CONNECTTIMEOUT. An unroutable host blocks these for ~133s while")
        print("Service Connect kills the request at 15s, so the caller sees")
        print("'upstream request timeout' and no exception is ever raised.")
        print()
        print("Set both on the new handle, e.g.:")
        print("    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);")
        print("    curl_setopt($ch, CURLOPT_TIMEOUT, 10);")
        print()
        for rel, count, allowed, why in regressions:
            print("  %s: %d unbounded, baseline allows %d (%s)" % (rel, count, allowed, why))
            for f_rel, line_no in unbounded:
                if f_rel == rel:
                    print("      line %d" % line_no)
        print()

    if improvements and not quiet:
        print("Improved since the baseline - lower these in %s:" % BASELINE_NAME)
        for rel, was, now in improvements:
            print("  %s: %d -> %d" % (rel, was, now))
        print()

    print("curl handles scanned: %d" % total)
    print("  unbounded: %d (baseline allows %d)"
          % (len(unbounded), sum(baseline.values()) if baseline else 0))
    print("  missing connect timeout only: %d" % len(connect_missing))

    return 1 if regressions else 0


if __name__ == "__main__":
    sys.exit(main())
