#
# 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.
#
import sys
import os.path
import subprocess
from pathlib import Path
import logging
import argparse
import shutil
import ast

# Paths
CLIENT_DIR = Path(__file__).parent
HEADER_DIR = CLIENT_DIR / "templates"
SPEC_DIR = CLIENT_DIR / "spec"
POLARIS_MANAGEMENT_SPEC = SPEC_DIR / "polaris-management-service.yml"
ICEBERG_CATALOG_SPEC = SPEC_DIR / "iceberg-rest-catalog-open-api.yaml"
POLARIS_CATALOG_SPEC = SPEC_DIR / "polaris-catalog-service.yaml"
OPEN_API_GENERATOR_IGNORE = CLIENT_DIR / ".openapi-generator-ignore"

# Open API Generator Configs
PACKAGE_NAME_POLARIS_MANAGEMENT = (
    "--additional-properties=packageName=apache_polaris.sdk.management"
)
PACKAGE_NAME_POLARIS_CATALOG = (
    "--additional-properties=packageName=apache_polaris.sdk.catalog"
)
PYTHON_VERSION = "--additional-properties=pythonVersion=3.10"

# Cleanup
KEEP_TEST_FILES = [
    Path("test/test_cli_parsing.py"),
]
EXCLUDE_PATHS = [
    Path(".gitignore"),
    Path(".openapi-generator"),
    Path(".openapi-generator-ignore"),
    Path(".pytest_cache"),
    Path("test/test_cli_parsing.py"),
    Path("__pycache__"),
    Path("apache_polaris/__pycache__"),
    Path("apache_polaris/cli"),
    Path("apache_polaris/sdk/__pycache__"),
    Path("apache_polaris/sdk/catalog/__pycache__"),
    Path("apache_polaris/sdk/catalog/models/__pycache__"),
    Path("apache_polaris/sdk/catalog/api/__pycache__"),
    Path("apache_polaris/sdk/management/__pycache__"),
    Path("apache_polaris/sdk/management/models/__pycache__"),
    Path("apache_polaris/sdk/management/api/__pycache__"),
    Path("integration_tests"),
    Path(".github/workflows/python.yml"),
    Path(".gitlab-ci.yml"),
    Path("pyproject.toml"),
    Path("requirements.txt"),
    Path("test-requirements.txt"),
    Path("setup.py"),
    Path(".DS_Store"),
    Path("Makefile"),
    Path("poetry.lock"),
    Path("uv.lock"),
    Path("docker-compose.yml"),
    Path(".pre-commit-config.yaml"),
    Path("README.md"),
    Path("generate_clients.py"),
    Path("hatch_build.py"),
    Path(".venv"),
    Path("venv"),
    Path("dist"),
    Path("templates"),
    Path("spec"),
    Path("PKG-INFO"),
    Path("LICENSE"),
    Path("NOTICE"),
]
EXCLUDE_EXTENSIONS = [
    "json",
    "iml",
    "keep",
    "gitignore",
    "pyc",
]

logger = logging.getLogger(__name__)


def clean_old_tests() -> None:
    logger.info("Deleting old tests...")
    test_dir = CLIENT_DIR / "test"
    if not test_dir.exists():
        logger.info(f"Test directory {test_dir} does not exist, skipping test cleanup.")
        return

    for item in test_dir.rglob("*"):
        if item.is_file():
            # Check if the file should be kept relative to CLIENT_DIR
            relative_path = item.relative_to(CLIENT_DIR)
            if relative_path not in KEEP_TEST_FILES:
                try:
                    os.remove(item)
                    logger.debug(f"{relative_path}: removed")
                except OSError as e:
                    logger.error(f"Error removing {relative_path}: {e}")
            else:
                logger.debug(f"{relative_path}: skipped")

    init_py_to_delete = CLIENT_DIR / "test" / "__init__.py"
    if init_py_to_delete.exists():
        try:
            os.remove(init_py_to_delete)
            logger.debug(f"{init_py_to_delete.relative_to(CLIENT_DIR)}: removed")
        except OSError as e:
            logger.error(
                f"Error removing {init_py_to_delete.relative_to(CLIENT_DIR)}: {e}"
            )
    logger.info("Old test deletion complete.")


def generate_polaris_management_client() -> None:
    subprocess.check_call(
        [
            "openapi-generator-cli",
            "generate",
            "-i",
            POLARIS_MANAGEMENT_SPEC,
            "-g",
            "python",
            "-o",
            str(CLIENT_DIR),
            PACKAGE_NAME_POLARIS_MANAGEMENT,
            "--additional-properties=apiNamePrefix=polaris",
            PYTHON_VERSION,
            "--additional-properties=generateSourceCodeOnly=true",
            "--skip-validate-spec",
            "--ignore-file-override",
            OPEN_API_GENERATOR_IGNORE,
            "--global-property=apiDocs=false,modelDocs=false,modelTests=false,apiTests=false",
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


def generate_polaris_catalog_client() -> None:
    subprocess.check_call(
        [
            "openapi-generator-cli",
            "generate",
            "-i",
            POLARIS_CATALOG_SPEC,
            "-g",
            "python",
            "-o",
            str(CLIENT_DIR),
            PACKAGE_NAME_POLARIS_CATALOG,
            "--additional-properties=apiNameSuffix=",
            PYTHON_VERSION,
            "--additional-properties=generateSourceCodeOnly=true",
            "--skip-validate-spec",
            "--ignore-file-override",
            OPEN_API_GENERATOR_IGNORE,
            "--global-property=apiDocs=false,modelDocs=false,modelTests=false,apiTests=false",
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


def generate_iceberg_catalog_client() -> None:
    subprocess.check_call(
        [
            "openapi-generator-cli",
            "generate",
            "-i",
            ICEBERG_CATALOG_SPEC,
            "-g",
            "python",
            "-o",
            str(CLIENT_DIR),
            PACKAGE_NAME_POLARIS_CATALOG,
            "--additional-properties=apiNameSuffix=",
            "--additional-properties=apiNamePrefix=Iceberg",
            PYTHON_VERSION,
            "--additional-properties=generateSourceCodeOnly=true",
            "--skip-validate-spec",
            "--ignore-file-override",
            OPEN_API_GENERATOR_IGNORE,
            "--global-property=apiDocs=false,modelDocs=false,modelTests=false,apiTests=false",
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


def _prepend_header_to_file(file_path: Path, header_file_path: Path) -> None:
    try:
        with open(header_file_path, "r") as hf:
            header_content = hf.read()
        with open(file_path, "r+") as f:
            original_content = f.read()
            f.seek(0)
            f.write(header_content + original_content)
    except IOError as e:
        logger.error(f"Error prepending header to {file_path}: {e}")


def prepend_licenses() -> None:
    logger.info("Re-applying license headers...")

    # Combine all paths to exclude into one set.
    all_excluded_paths = set(EXCLUDE_PATHS) | set(KEEP_TEST_FILES)

    for file_path in CLIENT_DIR.rglob("*"):
        if not file_path.is_file():
            continue

        relative_file_path = file_path.relative_to(CLIENT_DIR)

        # Determine file extension, handling dotfiles.
        file_extension = ""
        if (
            relative_file_path.name.startswith(".")
            and "." not in relative_file_path.name[1:]
        ):
            file_extension = relative_file_path.name.lstrip(".")
        else:
            file_extension = relative_file_path.suffix.lstrip(".")

        if file_extension in EXCLUDE_EXTENSIONS:
            logger.debug(f"{relative_file_path}: skipped (extension excluded)")
            continue

        # Check if the path should be excluded.
        is_excluded = False
        for excluded_path in all_excluded_paths:
            if (
                relative_file_path == excluded_path
                or excluded_path in relative_file_path.parents
            ):
                is_excluded = True
                break

        if is_excluded:
            logger.debug(f"{relative_file_path}: skipped (path excluded)")
            continue

        header_file_path = HEADER_DIR / f"header-{file_extension}.txt"

        if header_file_path.is_file():
            _prepend_header_to_file(file_path, header_file_path)
            logger.debug(f"{relative_file_path}: updated")
        else:
            logger.error(f"No header compatible with file {relative_file_path}")
            sys.exit(2)
    logger.info("License fix complete.")


def prepare_spec_dir() -> None:
    logger.info("Preparing spec directory...")
    spec_dir = Path(SPEC_DIR)
    spec_source_dir = CLIENT_DIR.parent.parent / "spec"

    if spec_source_dir.is_dir():
        logger.info(f"Copying spec directory from {spec_source_dir} to {spec_dir}")
        if spec_dir.exists():
            shutil.rmtree(spec_dir)
        shutil.copytree(spec_source_dir, spec_dir)
        logger.info("Spec directory copied to ensure it is up-to-date.")
    elif not spec_dir.is_dir():
        # This will be hit during an sdist build if spec directory wasn't in the package,
        # and we can't find the source to copy from.
        logger.error(
            "Fatal: spec directory is missing and the source to copy it from was not found."
        )
        sys.exit(1)
    else:
        # This is the case for sdist where the spec dir is already there and we don't have
        # the source to copy from.
        logger.info("Source spec directory not found, using existing spec directory.")


def build() -> None:
    prepare_spec_dir()
    clean_old_tests()
    generate_polaris_management_client()
    generate_polaris_catalog_client()
    generate_iceberg_catalog_client()
    fix_catalog_models_init()
    prepend_licenses()


def fix_catalog_models_init() -> None:
    """
    Regenerate the `apache_polaris.sdk.catalog.models.__init__.py` file by consolidating
    imports for all model classes found under `apache_polaris/sdk/catalog/models`.

    This ensures that rerunning the OpenAPI Generator (which overwrites `__init__.py`)
    does not cause missing imports for earlier generated model files.
    """
    logger.info("Fixing catalog models __init__.py...")
    models_dir = CLIENT_DIR / "apache_polaris" / "sdk" / "catalog" / "models"
    init_py = models_dir / "__init__.py"

    # Get all python files in the models directory except __init__.py
    model_files = [
        f for f in models_dir.glob("*.py") if f.is_file() and f.name != "__init__.py"
    ]

    # Generate import statements
    imports = []
    for model_file in sorted(model_files):
        module_name = model_file.stem
        with open(model_file, "r") as f:
            tree = ast.parse(f.read())
        class_name = None
        for node in ast.walk(tree):
            if isinstance(node, ast.ClassDef):
                # Find the first class that doesn't start with an underscore
                if not node.name.startswith("_"):
                    class_name = node.name
                    break
        if class_name:
            imports.append(
                f"from apache_polaris.sdk.catalog.models.{module_name} import {class_name}"
            )
        else:
            logger.warning(f"Could not find a suitable class in {model_file}")

    # Write the new __init__.py
    with open(init_py, "w") as f:
        f.write("\n".join(sorted(imports)))
    logger.info("Catalog models __init__.py fixed.")


def main() -> None:
    parser = argparse.ArgumentParser(description="Generate Polaris Python clients.")
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Enable verbose output for debugging.",
    )
    args = parser.parse_args()

    if args.verbose:
        logging.basicConfig(level=logging.DEBUG, format="%(message)s")
    else:
        logging.basicConfig(level=logging.INFO, format="%(message)s")

    build()


if __name__ == "__main__":
    main()
