# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

"""Ensure exposed pyclasses default to frozen."""

from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Iterator

PYCLASS_RE = re.compile(
    r"#\[\s*pyclass\s*(?:\((?P<args>.*?)\))?\s*\]",
    re.DOTALL,
)
ARG_STRING_RE = re.compile(
    r"(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*\"(?P<value>[^\"]+)\"",
)
STRUCT_NAME_RE = re.compile(
    r"\b(?:pub\s+)?(?:struct|enum)\s+" r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)",
)


@dataclass
class PyClass:
    module: str
    name: str
    frozen: bool
    source: Path


def iter_pyclasses(root: Path) -> Iterator[PyClass]:
    for path in root.rglob("*.rs"):
        text = path.read_text(encoding="utf8")
        for match in PYCLASS_RE.finditer(text):
            args = match.group("args") or ""
            frozen = re.search(r"\bfrozen\b", args) is not None

            module = None
            name = None
            for arg_match in ARG_STRING_RE.finditer(args):
                key = arg_match.group("key")
                value = arg_match.group("value")
                if key == "module":
                    module = value
                elif key == "name":
                    name = value

            remainder = text[match.end() :]
            struct_match = STRUCT_NAME_RE.search(remainder)
            struct_name = struct_match.group("name") if struct_match else None

            yield PyClass(
                module=module or "datafusion",
                name=name or struct_name or "<unknown>",
                frozen=frozen,
                source=path,
            )


def test_pyclasses_are_frozen() -> None:
    allowlist = {
        # NOTE: Any new exceptions must include a justification comment
        # in the Rust source and, ideally, a follow-up issue to remove
        # the exemption.
        ("datafusion.common", "SqlTable"),
        ("datafusion.common", "SqlView"),
        ("datafusion.common", "DataTypeMap"),
        ("datafusion.expr", "TryCast"),
        ("datafusion.expr", "WriteOp"),
    }

    unfrozen = [
        pyclass
        for pyclass in iter_pyclasses(Path("src"))
        if not pyclass.frozen and (pyclass.module, pyclass.name) not in allowlist
    ]

    if unfrozen:
        msg = (
            "Found pyclasses missing `frozen`; add them to the allowlist only "
            "with a justification comment and follow-up plan:\n"
        )
        msg += "\n".join(
            (f"- {pyclass.module}.{pyclass.name} (defined in {pyclass.source})")
            for pyclass in unfrozen
        )
        assert not unfrozen, msg
