#!/usr/bin/env python3
"""Anatomie locale d'une relation Notion: propriété et page liée sont distinctes.

Extraits JSON fictifs selon la référence consultée pour Notion-Version2026-03-11,
sans validation du schéma complet d'une réponse native.
Reply décrit le contexte de la requête dans le laboratoire, pas des champs
retournés par Notion. Aucun client HTTP, espace Notion ni CRM n'est appelé.
La fin de pagination du témoin ne certifie ni les accès, ni une lecture atomique,
ni une autorisation de remplacer les associations métier.
"""
from copy import deepcopy
from dataclasses import dataclass, replace
import json
from urllib.parse import urlencode
from uuid import UUID

VERSION = "2026-03-11"
PROPERTY = "r%3AaB"
CLIENT = "00000000-0000-4000-8000-000000000100"
OTHER_CLIENT = "00000000-0000-4000-8000-000000000200"
CURSOR = "fictional/opaque+cursor=="


@dataclass(frozen=True)
class Reply:
    # Enveloppe locale associant une fixture à la requête qui l'aurait lue.
    page_id: str
    property_id: str
    version: str
    requested_cursor: str | None
    body: dict


class RelationReadError(ValueError):
    def __init__(self, code, partial=()):
        super().__init__(code)
        self.code = code
        self.partial_reference_ids = tuple(partial)


def require(condition, code, partial=()):
    if not condition:
        raise RelationReadError(code, partial)


def page_identity(value, partial=()):
    """Profil local UUID normalisé; pas un validateur complet de l'API."""
    require(isinstance(value, str), "page_identity_missing", partial)
    try:
        return str(UUID(value))
    except ValueError as error:
        raise RelationReadError("page_identity_invalid", partial) from error


def inline_preview(page, property_id):
    require(isinstance(page, dict) and page.get("object") == "page", "not_page")
    properties = page.get("properties")
    require(isinstance(properties, dict), "properties_missing")
    found = [v for v in properties.values()
             if isinstance(v, dict) and v.get("id") == property_id]
    require(len(found) == 1, "property_missing_or_ambiguous")
    value = found[0]
    require(value.get("type") == "relation", "not_relation")
    require(type(value.get("has_more")) is bool, "preview_more_unknown")
    require(isinstance(value.get("relation"), list), "preview_values_missing")
    ids = []
    for reference in value["relation"]:
        require(isinstance(reference, dict), "preview_reference_invalid")
        ids.append(page_identity(reference.get("id")))
    return {"reference_ids": ids, "has_more": value["has_more"],
            "access_or_business_completeness_certified": False}


def collect_relation(page_id, property_id, replies, version=VERSION):
    owner = page_identity(page_id)
    require(isinstance(property_id, str) and bool(property_id), "property_missing")
    require(version == VERSION, "outside_consulted_version_profile")
    require(isinstance(replies, list) and bool(replies), "no_property_response")
    expected_cursor = None
    requested_cursors = set()
    references = {}
    raw_items = 0
    for position, reply in enumerate(replies):
        require(isinstance(reply, Reply), "reply_context_missing", references)
        require(page_identity(reply.page_id, references) == owner and
                reply.property_id == property_id and reply.version == version,
                "mixed_request_context", references)
        require(reply.requested_cursor == expected_cursor,
                "cursor_chain_mismatch", references)
        require(reply.requested_cursor not in requested_cursors,
                "cursor_repeated", references)
        requested_cursors.add(reply.requested_cursor)
        body = reply.body
        require(isinstance(body, dict) and body.get("object") == "list" and
                body.get("type") == "property_item", "not_property_list", references)
        metadata = body.get("property_item")
        require(isinstance(metadata, dict) and metadata.get("id") == property_id and
                metadata.get("type") == "relation", "wrong_property_metadata", references)
        require(isinstance(body.get("results"), list), "results_missing", references)
        for item in body["results"]:
            require(isinstance(item, dict) and item.get("object") == "property_item" and
                    item.get("type") == "relation" and item.get("id") == property_id,
                    "wrong_relation_item", references)
            relation = item.get("relation")
            require(isinstance(relation, dict), "relation_reference_missing", references)
            reference_id = page_identity(relation.get("id"), references)
            references.setdefault(reference_id, None)
            raw_items += 1
        require(type(body.get("has_more")) is bool, "more_state_unknown", references)
        if body["has_more"]:
            next_cursor = body.get("next_cursor")
            require(isinstance(next_cursor, str) and bool(next_cursor),
                    "next_cursor_missing", references)
            require(next_cursor not in requested_cursors, "cursor_repeated", references)
            require(position + 1 < len(replies), "pagination_interrupted", references)
            expected_cursor = next_cursor
        else:
            require(body.get("next_cursor", "missing") is None and
                    metadata.get("next_url", "missing") is None,
                    "final_pagination_inconsistent", references)
            require(position == len(replies) - 1, "responses_after_final", references)
            return {
                "source_page_id": owner,
                "property_id_as_returned": property_id,
                "consulted_version_profile": version,
                "reference_ids": list(references),
                "raw_items": raw_items,
                "unique_references": len(references),
                "property_pages_read": position + 1,
                "fixture_pagination_finished": True,
                "access_certified": False,
                "atomic_snapshot_certified": False,
                "notion_or_crm_write_authorized": False,
            }
    raise RelationReadError("pagination_interrupted", references)


def intentionally_wrong_outer_id(replies):
    """Contre-exemple: le dernier résultat écrase tous ceux de la propriété."""
    by_property = {}
    for reply in replies:
        for item in reply.body["results"]:
            by_property[item["id"]] = item["relation"]["id"]
    return list(by_property.values())


def intentionally_wrong_top_level_next_url(replies):
    """Contre-exemple: next_url n'est pas à ce niveau du JSON."""
    ids = []
    for reply in replies:
        ids.extend(item["relation"]["id"] for item in reply.body["results"])
        if reply.body.get("next_url") is None:
            break
    return ids


def contract_id(index):
    # UUID fictifs pour l'expérience; aucun identifiant d'un espace réel.
    return str(UUID(int=index, version=4))


def native_shaped_body(ids, more=False, cursor=None, owner=CLIENT):
    next_url = None
    if more:
        next_url = ("https://api.notion.com/v1/pages/" + owner + "/properties/" +
                    PROPERTY + "?" + urlencode({"start_cursor": cursor, "page_size": 20}))
    return {
        "object": "list", "type": "property_item",
        "results": [{"object": "property_item", "id": PROPERTY,
                     "type": "relation", "relation": {"id": value}} for value in ids],
        "has_more": more, "next_cursor": cursor,
        "property_item": {"id": PROPERTY, "type": "relation",
                          "relation": {}, "next_url": next_url},
    }


def fixtures():
    ids = [contract_id(i) for i in range(1, 38)]
    page = {"object": "page", "id": CLIENT, "properties": {
        "Contrats": {"id": PROPERTY, "type": "relation",
                     "relation": [{"id": value} for value in ids[:25]], "has_more": True}}}
    replies = [Reply(CLIENT, PROPERTY, VERSION, None,
                     native_shaped_body(ids[:20], True, CURSOR)),
               Reply(CLIENT, PROPERTY, VERSION, CURSOR,
                     native_shaped_body(ids[20:]))]
    return ids, page, replies


def run_checks():
    count = 0

    def check(condition):
        nonlocal count
        if not condition:
            raise AssertionError("échec du contrôle " + str(count + 1))
        count += 1

    def refused(replies, code, **options):
        try:
            collect_relation(CLIENT, PROPERTY, replies, **options)
        except RelationReadError as error:
            return error.code == code
        return False

    ids, page, replies = fixtures()
    original = deepcopy(replies)
    preview = inline_preview(page, PROPERTY)
    complete = collect_relation(CLIENT, PROPERTY, replies)
    outer_loss = intentionally_wrong_outer_id(replies)
    top_url_loss = intentionally_wrong_top_level_next_url(replies)
    check(len(ids) == 37)
    check(len(preview["reference_ids"]) == 25)
    check(preview["has_more"] is True)
    check(preview["access_or_business_completeness_certified"] is False)
    check(len(outer_loss) == 1)
    check(outer_loss == [ids[-1]])
    check(len(top_url_loss) == 20)
    check(complete["reference_ids"] == ids)
    check(complete["raw_items"] == 37)
    check(complete["unique_references"] == 37)
    check(complete["property_pages_read"] == 2)
    check(complete["fixture_pagination_finished"] is True)
    check(complete["property_id_as_returned"] == "r%3AaB")
    check(complete["access_certified"] is False)
    check(complete["atomic_snapshot_certified"] is False)
    check(complete["notion_or_crm_write_authorized"] is False)

    renamed = deepcopy(page)
    renamed["properties"]["Accords client"] = renamed["properties"].pop("Contrats")
    check(inline_preview(renamed, PROPERTY) == preview)
    check(refused(replies, "outside_consulted_version_profile", version="2025-09-03"))
    check(refused([], "no_property_response"))
    check(refused(replies[:1], "pagination_interrupted"))
    try:
        collect_relation(CLIENT, PROPERTY, replies[:1])
    except RelationReadError as error:
        check(len(error.partial_reference_ids) == 20)
        check(error.partial_reference_ids == tuple(ids[:20]))
    else:
        check(False)

    repeated = deepcopy(replies)
    repeated[1].body["results"].insert(0, deepcopy(repeated[0].body["results"][-1]))
    duplicate_result = collect_relation(CLIENT, PROPERTY, repeated)
    check(duplicate_result["raw_items"] == 38)
    check(duplicate_result["unique_references"] == 37)
    check(duplicate_result["reference_ids"] == ids)

    mixed = deepcopy(replies)
    mixed[1] = replace(mixed[1], page_id=OTHER_CLIENT)
    check(refused(mixed, "mixed_request_context"))
    mixed[1] = replace(replies[1], property_id="other")
    check(refused(mixed, "mixed_request_context"))
    mixed[1] = replace(replies[1], version="2025-09-03")
    check(refused(mixed, "mixed_request_context"))
    wrong_cursor = deepcopy(replies)
    wrong_cursor[1] = replace(wrong_cursor[1], requested_cursor="another-cursor")
    check(refused(wrong_cursor, "cursor_chain_mismatch"))
    repeated_cursor = deepcopy(replies)
    repeated_cursor[1].body.update(has_more=True, next_cursor=CURSOR)
    check(refused(repeated_cursor, "cursor_repeated"))
    check(refused(replies + [deepcopy(replies[1])], "responses_after_final"))

    for value in [None, 1, "true"]:
        bad = deepcopy(replies)
        bad[0].body["has_more"] = value
        check(refused(bad, "more_state_unknown"))
    false_with_cursor = deepcopy(replies)
    false_with_cursor[0].body["has_more"] = False
    check(refused(false_with_cursor, "final_pagination_inconsistent"))
    for value in [None, "", 1]:
        bad = deepcopy(replies)
        bad[0].body["next_cursor"] = value
        check(refused(bad, "next_cursor_missing"))
    bad = deepcopy(replies)
    bad[1].body["next_cursor"] = "unexpected"
    check(refused(bad, "final_pagination_inconsistent"))
    bad = deepcopy(replies)
    bad[1].body["property_item"]["next_url"] = "unexpected"
    check(refused(bad, "final_pagination_inconsistent"))
    for mutation in ["missing", "array", "type", "property"]:
        bad = deepcopy(replies)
        if mutation == "missing":del bad[0].body["property_item"]
        if mutation == "array":bad[0].body["property_item"] = []
        if mutation == "type":bad[0].body["property_item"]["type"] = "rollup"
        if mutation == "property":bad[0].body["property_item"]["id"] = "other"
        check(refused(bad, "wrong_property_metadata"))
    for mutation in ["type", "property", "object"]:
        bad = deepcopy(replies)
        if mutation == "type":bad[0].body["results"][0]["type"] = "people"
        if mutation == "property":bad[0].body["results"][0]["id"] = "other"
        if mutation == "object":bad[0].body["results"][0]["object"] = "page"
        check(refused(bad, "wrong_relation_item"))
    bad = deepcopy(replies)
    bad[0].body["results"][0]["relation"] = None
    check(refused(bad, "relation_reference_missing"))
    for value in [None, "contract-name"]:
        bad = deepcopy(replies)
        bad[0].body["results"][0]["relation"]["id"] = value
        check(refused(bad, "page_identity_missing" if value is None else "page_identity_invalid"))
    broken_second_page = deepcopy(replies)
    broken_second_page[1].body["results"][0]["relation"]["id"] = "not-a-page-id"
    try:
        collect_relation(CLIENT, PROPERTY, broken_second_page)
    except RelationReadError as error:
        check(error.code == "page_identity_invalid")
        check(error.partial_reference_ids == tuple(ids[:20]))
    else:
        check(False)
    bad_list = deepcopy(replies)
    bad_list[0].body["object"] = "property_item"
    check(refused(bad_list, "not_property_list"))
    bad_results = deepcopy(replies)
    bad_results[0].body["results"] = None
    check(refused(bad_results, "results_missing"))

    empty = [Reply(CLIENT, PROPERTY, VERSION, None, native_shaped_body([]))]
    empty_result = collect_relation(CLIENT, PROPERTY, empty)
    check(empty_result["unique_references"] == 0)
    check(empty_result["notion_or_crm_write_authorized"] is False)
    one = [Reply(CLIENT, PROPERTY, VERSION, None, native_shaped_body(ids[:1]))]
    check(collect_relation(CLIENT, PROPERTY, one)["reference_ids"] == ids[:1])
    # La même propriété existe sur plusieurs pages clientes; ne pas les fusionner.
    other = [Reply(OTHER_CLIENT, PROPERTY, VERSION, None,
                   native_shaped_body(ids[:2], owner=OTHER_CLIENT))]
    other_result = collect_relation(OTHER_CLIENT, PROPERTY, other)
    collections = {(x["source_page_id"], x["property_id_as_returned"]): x
                   for x in [complete, other_result]}
    check(len(collections) == 2)
    check(sum(x["unique_references"] for x in collections.values()) == 39)
    check(other_result["reference_ids"] == ids[:2])
    check(replies == original)
    check(CURSOR in {reply.requested_cursor for reply in replies})
    return {
        "checks_passed": count,
        "observed_local_counts": {"fixture_contracts": len(ids),
                                  "inline_preview": len(preview["reference_ids"]),
                                  "wrong_outer_id": len(outer_loss),
                                  "wrong_top_level_next_url": len(top_url_loss),
                                  "correct_nested_reference_id": complete["unique_references"]},
        "complete_collection": complete,
        "duplicate_fixture_raw_vs_unique": [duplicate_result["raw_items"], duplicate_result["unique_references"]],
        "two_client_collection_sizes": [complete["unique_references"], other_result["unique_references"]],
        "scope": {"fictional_vendor_shaped_json": True,
                  "request_context_is_local_harness_not_response_fields": True,
                  "fixture_page_sizes_are_chosen_not_vendor_guarantees": [20, 17],
                  "native_notion_or_crm_calls": False,
                  "persistent_writes": False,
                  "native_permissions_or_atomicity_certified": False,
                  "wrong_parsers_are_explicit_counterexamples": True},
    }


if __name__ == "__main__":
    print(json.dumps(run_checks(), ensure_ascii=False, indent=2))
