#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed 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.

"""Run sqlness compatibility checks for a small release window.

The workflow intentionally keeps YAML small and delegates the maintainable parts
here:

- read `tests/compatibility/ci.toml`
- validate the checked-in recent-release window
- preview selected cases with `compat --dry-run`
- run the real compat check for each sampled upgrade and downgrade version
"""

from __future__ import annotations

import argparse
import ast
import os
import re
import shlex
import subprocess
import sys
from pathlib import Path


VERSION_RE = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--config",
        default="tests/compatibility/ci.toml",
        help="Path to the compatibility CI window config.",
    )
    parser.add_argument(
        "--runner",
        default="./bins/sqlness-runner",
        help="Path to the sqlness-runner binary built by CI.",
    )
    parser.add_argument(
        "--to-bins-dir",
        default="./bins",
        help="Directory containing the PR-built greptime binary.",
    )
    parser.add_argument(
        "--preserve-state",
        action="store_true",
        help="Pass --preserve-state to the real compat run for artifact upload.",
    )
    parser.add_argument(
        "--from-versions-extra",
        help="Comma-separated extra from-versions appended to the ci.toml window (for nightly runs).",
    )
    return parser.parse_args()


def load_versions(
    config_path: Path, key: str, *, required: bool = True
) -> list[str]:
    if not config_path.is_file():
        raise SystemExit(f"Compatibility CI config not found: {config_path}")

    # Parse the simple TOML string array without depending on Python 3.11+
    # tomllib. This intentionally supports only the checked-in string-array
    # shapes used by the CI job.
    content = config_path.read_text(encoding="utf-8")
    content_without_comments = "\n".join(
        line.split("#", 1)[0] for line in content.splitlines()
    )
    match = re.search(
        rf"(?ms)^\s*{re.escape(key)}\s*=\s*(\[[^\]]*\])",
        content_without_comments,
    )
    if match is None:
        if required:
            raise SystemExit(f"{config_path} must define {key}")
        return []

    try:
        versions = ast.literal_eval(match.group(1))
    except (SyntaxError, ValueError) as err:
        raise SystemExit(f"Invalid {key} in {config_path}: {err}") from err

    if not isinstance(versions, list) or not versions:
        raise SystemExit(f"{config_path} must define a non-empty {key} list")

    seen: set[str] = set()
    validated: list[str] = []
    for version in versions:
        if not isinstance(version, str) or VERSION_RE.fullmatch(version) is None:
            raise SystemExit(f"Invalid compat {key} version: {version!r}")
        if version in seen:
            raise SystemExit(f"Duplicate compat {key} version: {version}")
        seen.add(version)
        validated.append(version)

    return validated


def load_extra_versions(raw: str | None, window: list[str]) -> list[str]:
    """Parse --from-versions-extra, skipping versions already in the window.

    Each entry must match VERSION_RE; like load_versions, no version may appear
    twice in the effective list, so window entries and duplicates are skipped.
    Order is preserved.
    """
    if not raw:
        return []
    extra: list[str] = []
    seen: set[str] = set()
    for item in raw.split(","):
        version = item.strip()
        if VERSION_RE.fullmatch(version) is None:
            raise SystemExit(f"Invalid compat from-versions-extra version: {version!r}")
        if version in window or version in seen:
            continue
        seen.add(version)
        extra.append(version)
    return extra


def check_inputs(runner: Path, to_bins_dir: Path) -> None:
    if not runner.is_file():
        raise SystemExit(f"sqlness-runner binary not found: {runner}")
    if not to_bins_dir.is_dir():
        raise SystemExit(f"to-bins directory not found: {to_bins_dir}")
    if not to_bins_dir.joinpath("greptime").is_file():
        raise SystemExit(f"greptime binary not found in to-bins directory: {to_bins_dir}")


def github_group(title: str):
    class Group:
        def __enter__(self) -> None:
            print(f"::group::{title}", flush=True)

        def __exit__(self, exc_type, exc, traceback) -> None:
            print("::endgroup::", flush=True)

    return Group()


def run_command(command: list[str], *, env: dict[str, str] | None = None) -> None:
    print(f"$ {shlex.join(command)}", flush=True)
    subprocess.run(command, check=True, env=env)


def run_compat(
    *,
    base_command: list[str],
    preview_title: str,
    compatibility_title: str,
    preserve_state: bool,
) -> None:
    with github_group(preview_title):
        run_command([*base_command, "--dry-run"])

    real_command = [*base_command]
    if preserve_state:
        real_command.append("--preserve-state")

    env = os.environ.copy()
    env.setdefault("RUST_BACKTRACE", "1")
    with github_group(compatibility_title):
        run_command(real_command, env=env)


def run_for_version(
    *,
    runner: Path,
    to_bins_dir: Path,
    from_version: str,
    preserve_state: bool,
) -> None:
    base_command = [
        str(runner),
        "compat",
        "--from-version",
        from_version,
        "--to-bins-dir",
        str(to_bins_dir),
    ]

    run_compat(
        base_command=base_command,
        preview_title=f"Preview {from_version} -> current",
        compatibility_title=f"Compatibility {from_version} -> current",
        preserve_state=preserve_state,
    )


def run_downgrade_for_version(
    *,
    runner: Path,
    current_bins_dir: Path,
    to_version: str,
    preserve_state: bool,
) -> None:
    for topology in ("distributed", "standalone"):
        base_command = [
            str(runner),
            "compat",
            "--from-bins-dir",
            str(current_bins_dir),
            "--to-version",
            to_version,
            "--topology",
            topology,
            "--test-filter",
            "^downgrade_compatibility$",
            "--expect-cases",
            "1",
        ]

        run_compat(
            base_command=base_command,
            preview_title=f"Preview current -> {to_version} ({topology})",
            compatibility_title=f"Compatibility current -> {to_version} ({topology})",
            preserve_state=preserve_state,
        )


def main() -> int:
    args = parse_args()
    config_path = Path(args.config)
    runner = Path(args.runner)
    to_bins_dir = Path(args.to_bins_dir)

    check_inputs(runner, to_bins_dir)
    from_versions = load_versions(config_path, "from_versions")
    extra_versions = load_extra_versions(args.from_versions_extra, from_versions)
    downgrade_to_versions = load_versions(
        config_path, "downgrade_to_versions", required=False
    )

    print("Compatibility from-version window:", flush=True)
    for version in from_versions:
        print(f"  - {version}", flush=True)
    for version in extra_versions:
        print(f"  - {version} (nightly extra)", flush=True)

    for from_version in [*from_versions, *extra_versions]:
        run_for_version(
            runner=runner,
            to_bins_dir=to_bins_dir,
            from_version=from_version,
            preserve_state=args.preserve_state,
        )

    if downgrade_to_versions:
        print("Compatibility downgrade-to-version window:", flush=True)
        for version in downgrade_to_versions:
            print(f"  - {version}", flush=True)

    for to_version in downgrade_to_versions:
        run_downgrade_for_version(
            runner=runner,
            current_bins_dir=to_bins_dir,
            to_version=to_version,
            preserve_state=args.preserve_state,
        )

    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except subprocess.CalledProcessError as err:
        raise SystemExit(err.returncode) from err
