#!/usr/bin/env python3
"""
Reproduce the unstable REST object pagination ordering bug against a live XWiki
instance.

Background
----------
The REST endpoint

    GET /wikis/{wiki}/classes/{className}/objects?start=..&number=..

is served by AllObjectsForClassNameResourceImpl. Before the fix it built its
query WITHOUT an `order by` clause (except for `order=date`), yet paginated it
with `setLimit`/`setOffset`. Without an ORDER BY the database is free to return
rows in an arbitrary order, and — importantly — that order can differ between
otherwise-equivalent queries when the LIMIT/OFFSET change, because the planner
may pick a different execution strategy. A single query often looks stable,
which is why the CI test was only intermittently flaky (and only on PostgreSQL).

What this script does
---------------------
It probes the endpoint several ways and reports any inconsistency:

  A. Same-query stability   -- fetch the full list N times, check it never varies.
  B. Window-slice consistency -- for many (start, number) windows, check the
     window equals reference[start : start+number].
  C. Test-scenario repro    -- for each offset i, compare the object at offset i
     obtained with a window of size i+1 against the one obtained with number=1
     (this is exactly the shape of the flaky assertion: number=2 element[1] vs
     number=1&start=1 element[0], generalised and with varying window sizes).

Against a buggy instance (typically PostgreSQL) B and/or C report mismatches;
against a fixed instance everything is consistent.

XWiki.UIExtensionClass is used by default because a standard install ships many
such objects across the wiki, giving enough rows to page through without any
setup.

Usage
-----
    python3 reproduce-object-pagination.py --url http://localhost:8080 \
        --wiki xwiki --user superadmin --password pass

Only depends on the Python 3 standard library.
"""

import argparse
import base64
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET

XWIKI_NS = "http://www.xwiki.org"
QNAME_OBJECT_SUMMARY = f"{{{XWIKI_NS}}}objectSummary"


def child_text(element, tag):
    node = element.find(f"{{{XWIKI_NS}}}{tag}")
    return node.text if node is not None else None


class Identity:
    """A stable, comparable identity for one object summary."""

    __slots__ = ("wiki", "space", "page", "class_name", "number")

    def __init__(self, summary_element):
        self.wiki = child_text(summary_element, "wiki")
        self.space = child_text(summary_element, "space")
        self.page = child_text(summary_element, "pageName")
        self.class_name = child_text(summary_element, "className")
        self.number = child_text(summary_element, "number")

    def key(self):
        return (self.wiki, self.space, self.page, self.class_name, self.number)

    def __eq__(self, other):
        return isinstance(other, Identity) and self.key() == other.key()

    def __hash__(self):
        return hash(self.key())

    def __str__(self):
        return f"{self.wiki}:{self.space}.{self.page}[{self.class_name}#{self.number}]"


class Client:
    def __init__(self, base_url, wiki, class_name, user=None, password=None,
                 order=None, timeout=30):
        self.base_url = base_url.rstrip("/")
        self.wiki = wiki
        self.class_name = class_name
        self.order = order
        self.timeout = timeout
        self.auth_header = None
        if user is not None:
            raw = f"{user}:{password or ''}".encode("utf-8")
            self.auth_header = "Basic " + base64.b64encode(raw).decode("ascii")
        self.request_count = 0

    def _url(self, start, number):
        path = (f"{self.base_url}/rest/wikis/{self.wiki}"
                f"/classes/{urllib.parse.quote(self.class_name)}/objects")
        params = {"start": start, "number": number}
        if self.order:
            params["order"] = self.order
        return f"{path}?{urllib.parse.urlencode(params)}"

    def fetch(self, start, number):
        """Return the ordered list of Identity for one (start, number) window."""
        url = self._url(start, number)
        request = urllib.request.Request(url, headers={"Accept": "application/xml"})
        if self.auth_header:
            request.add_header("Authorization", self.auth_header)
        self.request_count += 1
        try:
            with urllib.request.urlopen(request, timeout=self.timeout) as response:
                body = response.read()
        except urllib.error.HTTPError as error:
            detail = error.read().decode("utf-8", "replace")[:200]
            raise SystemExit(
                f"HTTP {error.code} for {url}\n{detail}\n"
                "If this is 401/403 the class objects may require authentication "
                "(pass --user/--password) or VIEW rights.")
        except urllib.error.URLError as error:
            raise SystemExit(f"Could not reach {url}: {error.reason}")
        root = ET.fromstring(body)
        return [Identity(node) for node in root.findall(QNAME_OBJECT_SUMMARY)]


def probe_same_query_stability(client, total, iterations):
    print(f"\n[A] Same-query stability: fetching the full list "
          f"({total} objects) {iterations} times ...")
    reference = client.fetch(0, total)
    mismatches = 0
    for i in range(1, iterations):
        current = client.fetch(0, total)
        if [o.key() for o in current] != [o.key() for o in reference]:
            mismatches += 1
            first_diff = next(
                (j for j in range(min(len(current), len(reference)))
                 if current[j] != reference[j]), None)
            print(f"    ! iteration {i}: order differs "
                  f"(first difference at index {first_diff})")
    if mismatches == 0:
        print("    OK: identical order every time.")
    else:
        print(f"    UNSTABLE: {mismatches}/{iterations - 1} repeats differed.")
    return reference


def probe_window_slices(client, reference, page_sizes, max_offset):
    total = len(reference)
    limit = min(total, max_offset) if max_offset else total
    print(f"\n[B] Window-slice consistency: checking windows against the "
          f"reference for page sizes {page_sizes} up to offset {limit} ...")
    mismatches = []
    for page_size in page_sizes:
        for start in range(0, limit, max(1, page_size)):
            window = client.fetch(start, page_size)
            expected = reference[start:start + page_size]
            got_keys = [o.key() for o in window]
            exp_keys = [o.key() for o in expected]
            if got_keys != exp_keys:
                mismatches.append((start, page_size, expected, window))
    if not mismatches:
        print("    OK: every window matched the reference slice.")
    else:
        print(f"    BUG REPRODUCED: {len(mismatches)} window(s) disagreed "
              f"with the reference order. Examples:")
        for start, page_size, expected, window in mismatches[:5]:
            print(f"      start={start} number={page_size}")
            print(f"        expected: {[str(o) for o in expected]}")
            print(f"        got:      {[str(o) for o in window]}")
    return mismatches


def probe_test_scenario(client, reference, max_offset):
    """Directly generalise the flaky CI assertion.

    For each offset i, compare the object at that offset obtained inside a window
    of size (i+1) starting at 0 with the object obtained via number=1&start=i.
    The original test used i=1 (number=2 element[1] vs number=1&start=1); varying
    the window size makes planner-dependent reordering far more likely to show.
    """
    total = len(reference)
    limit = min(total, max_offset) if max_offset else total
    print(f"\n[C] Test-scenario reproduction (varying window size vs number=1) "
          f"for offsets 1..{limit - 1} ...")
    mismatches = []
    for i in range(1, limit):
        in_window = client.fetch(0, i + 1)
        single = client.fetch(i, 1)
        if not in_window or len(in_window) <= i or not single:
            continue
        if in_window[i] != single[0]:
            mismatches.append((i, in_window[i], single[0]))
    if not mismatches:
        print("    OK: the object at each offset was identical regardless of "
              "window size.")
    else:
        print(f"    BUG REPRODUCED: {len(mismatches)} offset(s) returned a "
              f"different object depending on the window size. Examples:")
        for i, in_window, single in mismatches[:5]:
            print(f"      offset {i}: number={i + 1}[{i}] = {in_window}")
            print(f"                 number=1&start={i}    = {single}")
    return mismatches


def main():
    parser = argparse.ArgumentParser(
        description="Reproduce unstable REST object pagination ordering.")
    parser.add_argument("--url", default="http://localhost:8080",
                        help="XWiki base URL (default: http://localhost:8080)")
    parser.add_argument("--wiki", default="xwiki",
                        help="Wiki identifier (default: xwiki)")
    parser.add_argument("--class", dest="class_name",
                        default="XWiki.UIExtensionClass",
                        help="XClass reference (default: XWiki.UIExtensionClass)")
    parser.add_argument("--user", default=None, help="Username for basic auth")
    parser.add_argument("--password", default=None, help="Password for basic auth")
    parser.add_argument("--order", default=None,
                        help="Value for the 'order' query param (e.g. 'date'); "
                             "omit for the store's natural order")
    parser.add_argument("--iterations", type=int, default=5,
                        help="Repeats for the same-query stability probe")
    parser.add_argument("--max-offset", type=int, default=40,
                        help="Cap on how deep to page (keeps runtime bounded)")
    parser.add_argument("--page-sizes", default="1,2,3,5,8,13",
                        help="Comma-separated window sizes for probe B")
    args = parser.parse_args()

    page_sizes = [int(x) for x in args.page_sizes.split(",") if x.strip()]

    client = Client(args.url, args.wiki, args.class_name, args.user,
                    args.password, args.order)

    # Discover how many objects exist (cap at a large but finite window).
    discovered = client.fetch(0, 1000)
    total = len(discovered)
    print(f"Endpoint: {client._url(0, 1000)}")
    print(f"Found {total} object(s) of class {args.class_name} on wiki "
          f"'{args.wiki}'.")
    if total < 3:
        raise SystemExit(
            "Need at least 3 objects to exercise pagination. Pick a class with "
            "more instances via --class, or add some objects.")

    reference = probe_same_query_stability(client, total, args.iterations)
    slice_mismatches = probe_window_slices(client, reference, page_sizes,
                                           args.max_offset)
    scenario_mismatches = probe_test_scenario(client, reference, args.max_offset)

    print(f"\n--- Summary ({client.request_count} HTTP requests) ---")
    reproduced = bool(slice_mismatches or scenario_mismatches)
    if reproduced:
        print("BUG REPRODUCED: pagination order is not stable across varying "
              "limit/offset. This is the missing-ORDER-BY defect.")
    else:
        print("No inconsistency detected: pagination order was stable across "
              "all probes (expected on a fixed instance, or on a DB/dataset "
              "that happens to return a consistent order).")
    return 1 if reproduced else 0


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