#!/usr/bin/env python3
"""Compare canonical Postgres source and restored-target JSONL manifests."""

from __future__ import annotations

import argparse
import hashlib
import ipaddress
import json
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

try:
    from .private_receipt_io import print_private_receipt_summary, write_private_json
except ImportError:  # pragma: no cover - direct script execution
    from private_receipt_io import print_private_receipt_summary, write_private_json

REVIEWED_MANIFEST_SQL_SHA256 = "87bb9b7777d6d2e767c864de1d41d87ed157831b73926cd630354db942ddede4"
ROWSET_MD5_RE = re.compile(r"[0-9a-f]{32}\Z")

SINGLETON_KINDS = {
    "identity",
    "schemas",
    "extensions",
    "application_roles",
    "role_memberships",
    "object_ownership",
    "object_acl",
    "columns",
    "constraints",
    "indexes",
    "sequences",
    "views",
    "functions",
    "triggers",
    "types",
    "policies",
}


def canonical_hash(value: Any) -> str:
    payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
    return hashlib.sha256(payload.encode()).hexdigest()


def file_sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def load_manifest(path: Path) -> dict[str, Any]:
    singleton: dict[str, dict[str, Any]] = {}
    tables: dict[str, dict[str, Any]] = {}
    performance: dict[str, dict[str, Any]] = {}
    with path.open(encoding="utf-8") as handle:
        for line_number, raw_line in enumerate(handle, 1):
            line = raw_line.strip()
            if not line or line in {"BEGIN", "SET", "ROLLBACK"}:
                continue
            try:
                row = json.loads(line)
            except json.JSONDecodeError as exc:
                raise ValueError(f"{path}:{line_number}: invalid JSONL manifest row") from exc
            kind = row.get("kind")
            if kind in SINGLETON_KINDS:
                if kind in singleton:
                    raise ValueError(f"{path}: duplicate {kind} row")
                singleton[str(kind)] = row
            elif kind == "table":
                key = f"{row.get('schema')}.{row.get('table')}"
                if key in tables:
                    raise ValueError(f"{path}: duplicate table row {key}")
                tables[key] = row
            elif kind == "performance":
                key = str(row.get("query"))
                if key in performance:
                    raise ValueError(f"{path}: duplicate performance row {key}")
                performance[key] = row
            else:
                raise ValueError(f"{path}:{line_number}: unknown manifest kind {kind!r}")
    missing = SINGLETON_KINDS - set(singleton)
    if missing:
        raise ValueError(f"{path}: missing singleton rows {sorted(missing)}")
    if not tables:
        raise ValueError(f"{path}: no table rows")
    return {"singleton": singleton, "tables": tables, "performance": performance}


def item_map(row: dict[str, Any], key: str) -> dict[str, dict[str, Any]]:
    items = row.get("items", [])
    if not isinstance(items, list):
        raise ValueError(f"manifest {row.get('kind')} items must be a list")
    mapped: dict[str, dict[str, Any]] = {}
    for item in items:
        if not isinstance(item, dict) or key not in item:
            raise ValueError(f"manifest {row.get('kind')} item is missing {key}")
        item_key = str(item[key])
        if item_key in mapped:
            raise ValueError(f"manifest {row.get('kind')} contains duplicate {key}={item_key}")
        mapped[item_key] = item
    return mapped


def compare_manifests(
    source: dict[str, Any],
    target: dict[str, Any],
    *,
    max_target_query_ms: float,
    max_target_source_ratio: float,
) -> tuple[list[str], dict[str, Any]]:
    problems: list[str] = []
    source_singleton = source["singleton"]
    target_singleton = target["singleton"]

    source_identity = source_singleton["identity"]
    target_identity = target_singleton["identity"]
    source_major = int(source_identity["server_version_num"]) // 10000
    target_major = int(target_identity["server_version_num"]) // 10000
    if source_major != target_major:
        problems.append(f"postgres_major source={source_major} target={target_major}")
    if source_identity.get("transaction_read_only") != "on":
        problems.append("source_manifest_not_captured_read_only")
    if target_identity.get("transaction_read_only") != "on":
        problems.append("target_manifest_not_captured_read_only")

    source_tables = source["tables"]
    target_tables = target["tables"]
    if set(source_tables) != set(target_tables):
        problems.append(
            "table_set mismatch source_only="
            + json.dumps(sorted(set(source_tables) - set(target_tables)))
            + " target_only="
            + json.dumps(sorted(set(target_tables) - set(source_tables)))
        )
    table_mismatches = []
    table_hash_problems = []
    for table in sorted(set(source_tables) & set(target_tables)):
        source_row = source_tables[table]
        target_row = target_tables[table]
        for side, row in (("source", source_row), ("target", target_row)):
            rowset_md5 = row.get("rowset_md5")
            if not isinstance(rowset_md5, str) or not ROWSET_MD5_RE.fullmatch(rowset_md5):
                table_hash_problems.append(f"{table}:{side}:rowset_md5_invalid")
        mismatch = {
            field: {"source": source_row.get(field), "target": target_row.get(field)}
            for field in ("row_count", "rowset_md5")
            if source_row.get(field) != target_row.get(field)
        }
        if mismatch:
            table_mismatches.append({"table": table, "mismatch": mismatch})
    if table_mismatches:
        problems.append(f"table_content_mismatches={len(table_mismatches)}")
    if table_hash_problems:
        problems.append(f"table_hash_problems={len(table_hash_problems)}")

    structural_hashes: dict[str, dict[str, str]] = {}
    for kind in (
        "schemas",
        "columns",
        "constraints",
        "indexes",
        "sequences",
        "views",
        "functions",
        "triggers",
        "types",
        "policies",
        "role_memberships",
        "object_ownership",
        "object_acl",
    ):
        source_hash = canonical_hash(source_singleton[kind].get("items", []))
        target_hash = canonical_hash(target_singleton[kind].get("items", []))
        structural_hashes[kind] = {"source": source_hash, "target": target_hash}
        if source_hash != target_hash:
            problems.append(f"{kind}_hash_mismatch")

    source_extensions = item_map(source_singleton["extensions"], "name")
    target_extensions = item_map(target_singleton["extensions"], "name")
    extension_mismatches = {
        name: {"source": source_extensions.get(name), "target": target_extensions.get(name)}
        for name in sorted(set(source_extensions) | set(target_extensions))
        if source_extensions.get(name) != target_extensions.get(name)
    }
    if extension_mismatches:
        problems.append(f"extension_mismatches={len(extension_mismatches)}")

    source_roles = item_map(source_singleton["application_roles"], "name")
    target_roles = item_map(target_singleton["application_roles"], "name")
    role_mismatches = {
        name: {"source": source_roles.get(name), "target": target_roles.get(name)}
        for name in sorted(set(source_roles) | set(target_roles))
        if source_roles.get(name) != target_roles.get(name)
    }
    if role_mismatches:
        problems.append(f"application_role_mismatches={len(role_mismatches)}")

    performance_problems = []
    performance_rows = []
    for query, target_row in sorted(target["performance"].items()):
        target_ms = float(target_row.get("elapsed_ms") or 0)
        source_row = source["performance"].get(query)
        source_ms = float((source_row or {}).get("elapsed_ms") or 0)
        ratio = target_ms / max(source_ms, 5.0)
        row = {"query": query, "source_ms": source_ms, "target_ms": target_ms, "source_ratio": ratio}
        performance_rows.append(row)
        if source_row is None:
            performance_problems.append(f"{query}:missing_source_benchmark")
        if target_ms > max_target_query_ms:
            performance_problems.append(f"{query}:target_ms={target_ms:.3f}>{max_target_query_ms:.3f}")
        if ratio > max_target_source_ratio:
            performance_problems.append(f"{query}:source_ratio={ratio:.3f}>{max_target_source_ratio:.3f}")
    missing_target_benchmarks = sorted(set(source["performance"]) - set(target["performance"]))
    if missing_target_benchmarks:
        performance_problems.append("missing_target_benchmarks=" + ",".join(missing_target_benchmarks))
    if performance_problems:
        problems.append(f"performance_problems={len(performance_problems)}")

    details = {
        "source_database": source_identity.get("database"),
        "target_database": target_identity.get("database"),
        "source_connection": {
            "server_address": source_identity.get("server_address"),
            "server_port": source_identity.get("server_port"),
            "ssl": source_identity.get("ssl"),
            "ssl_version": source_identity.get("ssl_version"),
            "database_collation": source_identity.get("database_collation"),
            "database_ctype": source_identity.get("database_ctype"),
        },
        "target_connection": {
            "server_address": target_identity.get("server_address"),
            "server_port": target_identity.get("server_port"),
            "ssl": target_identity.get("ssl"),
            "ssl_version": target_identity.get("ssl_version"),
            "database_collation": target_identity.get("database_collation"),
            "database_ctype": target_identity.get("database_ctype"),
        },
        "source_table_count": len(source_tables),
        "target_table_count": len(target_tables),
        "source_total_rows": sum(int(row["row_count"]) for row in source_tables.values()),
        "target_total_rows": sum(int(row["row_count"]) for row in target_tables.values()),
        "table_mismatches": table_mismatches,
        "table_hash_problems": table_hash_problems,
        "structural_hashes": structural_hashes,
        "required_extension_mismatches": extension_mismatches,
        "target_extra_extensions": sorted(set(target_extensions) - set(source_extensions)),
        "application_role_hashes": {
            "source": canonical_hash([source_roles[name] for name in sorted(source_roles)]),
            "target": canonical_hash([target_roles[name] for name in sorted(target_roles)]),
        },
        "application_role_mismatches": role_mismatches,
        "target_extra_application_roles": sorted(set(target_roles) - set(source_roles)),
        "performance": performance_rows,
        "performance_problems": performance_problems,
    }
    return problems, details


def canonical_ip(address: str) -> str:
    return str(ipaddress.ip_interface(address).ip)


def is_rfc1918(address: str) -> bool:
    parsed = ipaddress.ip_address(canonical_ip(address))
    return any(
        parsed in network
        for network in (
            ipaddress.ip_network("10.0.0.0/8"),
            ipaddress.ip_network("172.16.0.0/12"),
            ipaddress.ip_network("192.168.0.0/16"),
        )
    )


def load_connectivity(
    path: Path | None,
    scope: str,
    target_identity: dict[str, Any],
) -> tuple[list[str], dict[str, Any]]:
    if scope == "local_restore":
        return [], {"required": False, "status": "not_applicable_local_restore"}
    if path is None:
        return ["gcp_private_connectivity_proof_missing"], {"required": True, "status": "missing"}
    proof = json.loads(path.read_text())
    problems = []
    if proof.get("status") != "pass":
        problems.append("gcp_private_connectivity_status_not_pass")
    if proof.get("private_connectivity") is not True:
        problems.append("gcp_private_connectivity_not_proven")
    if proof.get("public_ip_disabled") is not True:
        problems.append("gcp_public_ip_disabled_not_proven")
    if not proof.get("source_compute"):
        problems.append("gcp_source_compute_missing")
    target_database = target_identity.get("database")
    target_address = target_identity.get("server_address")
    target_ssl = target_identity.get("ssl")
    canonical_target_address = None
    canonical_proof_address = None
    if not target_database:
        problems.append("gcp_target_manifest_database_missing")
    if proof.get("target_database") != target_database:
        problems.append("gcp_connectivity_target_database_mismatch")
    if not target_address:
        problems.append("gcp_target_manifest_server_address_missing")
    else:
        try:
            canonical_target_address = canonical_ip(str(target_address))
            if not is_rfc1918(str(target_address)):
                problems.append("gcp_target_manifest_server_address_not_rfc1918")
        except ValueError:
            problems.append("gcp_target_manifest_server_address_invalid")
    if proof.get("server_address"):
        try:
            canonical_proof_address = canonical_ip(str(proof["server_address"]))
        except ValueError:
            problems.append("gcp_connectivity_server_address_invalid")
    else:
        problems.append("gcp_connectivity_server_address_missing")
    if canonical_proof_address != canonical_target_address:
        problems.append("gcp_connectivity_server_address_mismatch")
    if target_ssl is not True:
        problems.append("gcp_target_manifest_tls_not_proven")
    if proof.get("ssl") is not True:
        problems.append("gcp_connectivity_tls_not_proven")
    if proof.get("sslmode") != "verify-full":
        problems.append("gcp_connectivity_server_identity_mode_not_proven")
    if proof.get("server_identity_verified") is not True:
        problems.append("gcp_connectivity_server_identity_not_proven")
    if not proof.get("ssl_server_name"):
        problems.append("gcp_connectivity_ssl_server_name_missing")
    if not re.fullmatch(r"[0-9a-f]{64}", str(proof.get("ssl_root_cert_sha256") or "")):
        problems.append("gcp_connectivity_ssl_root_cert_hash_missing")
    return problems, {"required": True, "path": str(path), "proof": proof}


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--source", required=True, type=Path)
    parser.add_argument("--target", required=True, type=Path)
    parser.add_argument("--scope", choices=("local_restore", "gcp_staging"), default="local_restore")
    parser.add_argument("--connectivity-proof", type=Path)
    parser.add_argument("--max-target-query-ms", type=float, default=2000.0)
    parser.add_argument("--max-target-source-ratio", type=float, default=20.0)
    parser.add_argument("--output", required=True, type=Path)
    args = parser.parse_args()

    try:
        source_manifest_sha256 = file_sha256(args.source)
        target_manifest_sha256 = file_sha256(args.target)
        connectivity_proof_sha256 = file_sha256(args.connectivity_proof) if args.connectivity_proof else None
        source = load_manifest(args.source)
        target = load_manifest(args.target)
        problems, details = compare_manifests(
            source,
            target,
            max_target_query_ms=args.max_target_query_ms,
            max_target_source_ratio=args.max_target_source_ratio,
        )
        connectivity_problems, connectivity = load_connectivity(
            args.connectivity_proof,
            args.scope,
            target["singleton"]["identity"],
        )
        problems.extend(connectivity_problems)
        status = "pass" if not problems else "fail"
        error = None
    except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
        status = "fail"
        problems = [f"manifest_error={exc}"]
        details = {}
        connectivity = {"required": args.scope == "gcp_staging", "status": "not_checked"}
        source_manifest_sha256 = None
        target_manifest_sha256 = None
        connectivity_proof_sha256 = None
        error = str(exc)

    completed_at_utc = datetime.now(UTC).isoformat()
    payload = {
        "artifact": "canonical_postgres_parity_verification",
        "generated_at_utc": completed_at_utc,
        "completed_at_utc": completed_at_utc,
        "scope": args.scope,
        "source_manifest": str(args.source),
        "source_manifest_sha256": source_manifest_sha256,
        "target_manifest": str(args.target),
        "target_manifest_sha256": target_manifest_sha256,
        "status": status,
        "problems": problems,
        "details": details,
        "private_connectivity": connectivity,
        "connectivity_proof_sha256": connectivity_proof_sha256,
        "error": error,
        "not_proven_by_this_artifact": [
            "GCP staging Leo composition replay",
            "ongoing replication after the manifest timestamps",
            "production cutover",
        ],
    }
    write_private_json(args.output, payload)
    print_private_receipt_summary(args.output, payload)
    return 0 if status == "pass" else 1


if __name__ == "__main__":
    raise SystemExit(main())
