from __future__ import annotations

import argparse
import hashlib
import http.cookiejar
import json
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any


def request_json(
    opener: urllib.request.OpenerDirector,
    url: str,
    payload: dict[str, Any] | None = None,
    method: str = "GET",
) -> tuple[int, Any]:
    data = None
    headers = {"Accept": "application/json"}
    if payload is not None:
        data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json"
    request = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with opener.open(request, timeout=90) as response:
            return response.status, json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        return exc.code, json.loads(exc.read().decode("utf-8"))


def session(base_url: str, username: str, password: str) -> urllib.request.OpenerDirector:
    opener = urllib.request.build_opener(
        urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
    )
    status, body = request_json(
        opener,
        f"{base_url.rstrip('/')}/api/auth/login",
        {"username": username, "password": password},
        "POST",
    )
    if status != 200 or not body.get("ok"):
        raise RuntimeError(f"Login failed for {base_url}: HTTP {status} {body}")
    csrf_token = str(body.get("csrf_token") or "")
    if csrf_token:
        opener.addheaders.append(("X-CSRF-Token", csrf_token))
    return opener


def digest(value: Any) -> str:
    content = json.dumps(
        value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
    ).encode("utf-8")
    return hashlib.sha256(content).hexdigest()


def comparable(
    value: Any,
    excluded_paths: set[str] | None = None,
    sorted_lists: dict[str, str] | None = None,
    path: str = "$",
) -> Any:
    excluded_paths = excluded_paths or set()
    sorted_lists = sorted_lists or {}
    if isinstance(value, dict):
        return {
            key: comparable(
                item,
                excluded_paths,
                sorted_lists,
                f"{path}.{key}",
            )
            for key, item in value.items()
            if key != "request_id"
            and f"{path}.{key}" not in excluded_paths
        }
    if isinstance(value, list):
        normalized = [
            comparable(item, excluded_paths, sorted_lists, f"{path}[]")
            for item in value
        ]
        sort_key = sorted_lists.get(path)
        if sort_key:
            normalized.sort(
                key=lambda item: (
                    str(item.get(sort_key) or "")
                    if isinstance(item, dict)
                    else json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
                )
            )
        return normalized
    return value


def first_difference(original: Any, migrated: Any, path: str = "$") -> dict[str, Any] | None:
    if type(original) is not type(migrated):
        return {"path": path, "original": original, "migrated": migrated}
    if isinstance(original, dict):
        for key in sorted(set(original) | set(migrated)):
            if key not in original or key not in migrated:
                return {
                    "path": f"{path}.{key}",
                    "original": original.get(key, "<missing>"),
                    "migrated": migrated.get(key, "<missing>"),
                }
            difference = first_difference(original[key], migrated[key], f"{path}.{key}")
            if difference:
                return difference
        return None
    if isinstance(original, list):
        if len(original) != len(migrated):
            return {"path": f"{path}.length", "original": len(original), "migrated": len(migrated)}
        for index, (original_item, migrated_item) in enumerate(zip(original, migrated)):
            difference = first_difference(
                original_item, migrated_item, f"{path}[{index}]"
            )
            if difference:
                return difference
        return None
    if original != migrated:
        return {"path": path, "original": original, "migrated": migrated}
    return None


def main() -> None:
    parser = argparse.ArgumentParser(description="Compare authenticated preservation APIs")
    parser.add_argument("--original", required=True)
    parser.add_argument("--migrated", required=True)
    parser.add_argument("--username", required=True)
    parser.add_argument("--password", required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--requests-file", type=Path)
    parser.add_argument("endpoints", nargs="*")
    args = parser.parse_args()

    original = session(args.original, args.username, args.password)
    migrated = session(args.migrated, args.username, args.password)
    rows = []
    all_equal = True
    requests = [
        {"name": endpoint, "method": "GET", "endpoint": endpoint, "payload": None}
        for endpoint in args.endpoints
    ]
    if args.requests_file:
        requests.extend(json.loads(args.requests_file.read_text(encoding="utf-8")))
    if not requests:
        parser.error("provide at least one endpoint or --requests-file")
    for item in requests:
        endpoint = str(item["endpoint"])
        method = str(item.get("method") or "GET").upper()
        payload = item.get("payload")
        excluded_paths = {str(path) for path in item.get("exclude_paths") or []}
        sorted_lists = {
            str(path): str(key)
            for path, key in (item.get("sort_lists") or {}).items()
        }
        original_status, original_body = request_json(
            original, f"{args.original.rstrip('/')}{endpoint}", payload, method
        )
        migrated_status, migrated_body = request_json(
            migrated, f"{args.migrated.rstrip('/')}{endpoint}", payload, method
        )
        original_comparable = comparable(
            original_body, excluded_paths, sorted_lists
        )
        migrated_comparable = comparable(
            migrated_body, excluded_paths, sorted_lists
        )
        equal = original_status == migrated_status and original_comparable == migrated_comparable
        all_equal = all_equal and equal
        rows.append(
            {
                "name": str(item.get("name") or endpoint),
                "method": method,
                "endpoint": endpoint,
                "original_status": original_status,
                "migrated_status": migrated_status,
                "original_sha256": digest(original_comparable),
                "migrated_sha256": digest(migrated_comparable),
                "equal": equal,
                "first_difference": (
                    None
                    if equal
                    else first_difference(original_comparable, migrated_comparable)
                ),
            }
        )

    result = {"all_equal": all_equal, "endpoints": rows}
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text(
        json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
    )
    print(json.dumps(result, ensure_ascii=False, indent=2))
    if not all_equal:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
