import os
import re
import subprocess
import time
from pathlib import Path
from typing import Any

import boto3
import tomli
from botocore.exceptions import BotoCoreError, ClientError

from common import (
    exec_command,
    exit_message,
    get_env_variable,
    get_env_variable_required,
    get_last_version,
    get_version,
    print_message,
)
from common.sonar_helper import SonarHelper
from src.interface.project import ProjectInterface

COMMIT_TAG = get_env_variable("CI_COMMIT_TAG")
COMMIT_BRANCH = get_env_variable("CI_COMMIT_BRANCH")

CI_PROJECT_DIR = get_env_variable_required("CI_PROJECT_DIR")
PACKAGE_DIR_CI = Path(CI_PROJECT_DIR) / "dist"
PYTHON_VENV = ".venv"
PYTHON_ENVIRONMENT = ".venv/bin/python"


def get_environments():
    """
    Ajuste do ambiente virtual para garantir que
    a versão do Python utilizado nos workers e no master do SPARK seja o mesmo.

    Isso é necessário para evitar problemas de compatibilidade
    entre as versões do Python utilizadas no runner e no uv.
    """
    env = os.environ.copy()
    env["PYSPARK_PYTHON"] = PYTHON_ENVIRONMENT
    env["PYSPARK_DRIVER_PYTHON"] = PYTHON_ENVIRONMENT

    return env


class PythonProjectInterface(ProjectInterface):
    """Interface para projetos Python."""

    def __init__(self):
        super().__init__()

        if not self.buildable_project.skip_build():
            print_message("Criando o ambiente virtual")
            create_venv()

    def validate(self) -> None:
        """
        Valida o projeto python executando o validate padrão do devops
        """
        super().validate()

        if self.buildable_project.skip_build():
            return

        exec_command_venv("ruff format --diff", error_message="")
        exec_command_venv("ruff check", error_message="")

    def compile(self) -> None:
        pass

    def unit_test(self) -> None:
        """
        Executa os testes unitários
        """

        if self.buildable_project.skip_build():
            return

        custom_unit_test(environments=get_environments(), source="src")

    def sonar_scanner(self) -> None:
        """
        Executa o sonar scanner
        """

        sonar_helper = SonarHelper(
            skip_build=self.buildable_project.skip_build(),
        )
        sonar_helper.scanner_analyze()

    def package(self):
        pass


def remove_venv_if_exists(path: str = PYTHON_VENV) -> None:
    """
    Remove o ambiente virtual se ele existir.
    """
    if path is None or not isinstance(path, str):
        raise ValueError("O caminho do ambiente virtual deve ser uma string válida.")

    if os.path.exists(path) and os.path.isdir(path):
        exec_command(
            f"rm -rf {path}",
            print_command=True,
            print_output=True,
            error_message="Erro ao remover o ambiente virtual existente.",
        )


def create_venv() -> None:
    """
    Cria o ambiente virtual e instala dependências, de forma adaptativa:
    - Se existir uv.lock, usa uv
    - Senão, usa pip e requirements.txt
    """

    remove_venv_if_exists()

    if Path("uv.lock").exists():
        print_message("[CI] Ambiente com uv.lock — usando uv")

        exec_command(f"uv venv  {PYTHON_VENV}")
        exec_command("uv sync --frozen --all-groups")

    elif Path("requirements.txt").exists():
        print_message("[CI] Ambiente genérico com requirements.txt — usando pip")

        exec_command("python3 -m venv .venv")
        exec_command(".venv/bin/pip install -r requirements.txt")
    else:
        print_message(
            "[CI] Nenhum pyproject.toml ou requirements.txt encontrado — ambiente vazio"
        )


def fix_setup_version(package_path: str) -> None:
    """
    Função para corrigir a versão do setup.py.

    Args:
        package_path (str): Caminho do diretório do pacote.
    """

    setup_file = Path(package_path)
    next_version = _get_version()

    # Lê o arquivo setup.py
    content = setup_file.read_text(encoding="utf-8")

    # Encontra a versão atual
    python_version = re.search(r'version\s*=\s*"([^"]*)"', content).group(1)
    print_message(f"Versão atual encontrada: {python_version}")

    # Substitui a versão antiga pela nova
    updated_content = re.sub(
        r'version\s*=\s*"([^"]*)"', f'version="{next_version}"', content
    )

    # Atualiza o arquivo setup.py
    setup_file.write_text(updated_content, encoding="utf-8")

    print_message(f"Versão atualizada para {next_version} no arquivo {setup_file}.")


def fix_pyproject_version() -> None:
    """
    Função para corrigir a versão do pyproject.toml usando `uv`.

    """

    print_message("Ajustando versão do pyproject.toml")

    next_version = _get_version()

    exec_command(f"uv version {next_version}")


def _get_version() -> str:
    """
    Função para obter a próxima versão do projeto.

    Return:
        str: próxima versão do projeto.
    """

    # Obtém a próxima versão usando a função get_version
    version_match = get_version()
    print_message(f"Versão atual: {version_match}")

    if COMMIT_TAG:
        return version_match

    print_message("Operando em branch.")
    version_base = get_last_version()

    if version_base is None:
        print_message("Sem versão base, aplicando 1.0.0 como padrão")
        version_base = "1.0.0"

    ci_pipeline_iid = get_env_variable("CI_PIPELINE_IID")

    # PEP 440 - ajustada a versão para o formato da pep 440
    # Não é possivel usar o nome da branch (CI_COMMIT_REF_SLUG) ou outros
    # valores não numéricos diretamente na versão do Python,
    # pois pode conter caracteres inválidos para a versão, então usamos o
    # ci_pipeline_iid para garantir unicidade e evitar problemas de formatação.
    # https://peps.python.org/pep-0440/
    return f"{version_base}.dev{ci_pipeline_iid}"


def build_whl(package_path: str) -> None:
    """
    Função para gerar o pacote WHL e ajustar o nome do arquivo.
    """

    print_message("Iniciando geração do pacote WHL")
    package_dir_local = Path.cwd() / package_path

    # Remove arquivos .whl e .gz existentes
    for file in PACKAGE_DIR_CI.glob("*.whl"):
        file.unlink()
    for file in PACKAGE_DIR_CI.glob("*.gz"):
        file.unlink()

    # Gera o pacote WHL
    print_message("Gerando pacote WHL...")
    subprocess.run(
        ["uv", "build", "-o", PACKAGE_DIR_CI], cwd=package_dir_local, check=True
    )


def get_environment() -> str:
    """
    Retorna o ambiente com base na branch ou tag do commit.

    Return:
        str: O ambiente correspondente (prod, hom ou dev).
    """

    if COMMIT_TAG:
        return "prod"

    if COMMIT_BRANCH == "master":
        return "hom"

    return "dev"


def get_credentials(environment: str) -> dict:
    """
    Obtém as credenciais AWS para o ambiente especificado.

    Args:
        environment (str): O ambiente para o qual as credenciais são necessárias.

    Return:
        dict: Credenciais para autenticação.
    """

    print_message(f"Buscando credenciais para o ambiente {environment}")
    required_args: dict = {
        "environment": environment,
    }

    validade_missing_args(required_args)

    aws_account_id_name = get_env_variable(f"AWS_ACCOUNT_ID_DATA_{environment.upper()}")

    sts_client = boto3.client("sts")
    assumed_role = sts_client.assume_role(
        RoleArn=f"arn:aws:iam::{aws_account_id_name}:role/GitlabPipeline",
        RoleSessionName="AWSCLI-Session",
    )
    credentials = assumed_role["Credentials"]

    sts_client = boto3.client(
        "sts",
        aws_access_key_id=credentials["AccessKeyId"],
        aws_secret_access_key=credentials["SecretAccessKey"],
        aws_session_token=credentials["SessionToken"],
    )

    assumed_role = sts_client.assume_role(
        RoleArn=f"arn:aws:iam::{aws_account_id_name}:role/GitlabPipeline-{environment.lower()}",
        RoleSessionName="AWSCLI-Session",
    )

    return assumed_role["Credentials"]


def get_aws_client(cliente_name: str, credentials: dict) -> boto3.client:
    """
    Cria um cliente S3 usando as credenciais fornecidas.

    Args:
        cliente_name (str): Nome do cliente aws.
        credentials (dict): As credenciais AWS para autenticação.

    Return:
        boto3.client: Cliente S3 autenticado.
    """
    print_message(f"Criando aws {cliente_name} client")

    required_args: dict = {
        "cliente_name": cliente_name,
        "credentials": credentials,
    }

    validade_missing_args(required_args)

    return boto3.client(
        cliente_name,
        aws_access_key_id=credentials["AccessKeyId"],
        aws_secret_access_key=credentials["SecretAccessKey"],
        aws_session_token=credentials["SessionToken"],
    )


def send_data_to_s3(
    bucket_name: str,
    bucket_path: str,
    data_dir: Path,
    s3_client: boto3.client,
    suffix: str,
    overwrite_bucket_path: bool = False,
) -> None:
    """
    Envia arquivos de um diretório local para um bucket S3.

    Args:
        bucket_name (str): Nome do bucket S3 de destino.
        bucket_path (str): Caminho dentro do bucket S3 onde os arquivos serão armazenados.
        data_dir (Path): Caminho do diretório local a ser sincronizado.
        s3_client (boto3.client): Cliente S3 autenticado.
        suffix (str): Sufixo dos arquivos a serem sincronizados (ex: '.py', '.json').
        overwrite_bucket_path (bool): Se True, usa caminho relativo ao data_dir.
            Se False, usa caminho relativo ao diretório de trabalho atual.
    """

    required_args: dict = {
        "bucket_name": bucket_name,
        "bucket_path": bucket_path,
        "data_dir": data_dir,
        "s3_client": s3_client,
        "suffix": suffix,
    }

    validade_missing_args(required_args)

    base_path = data_dir if overwrite_bucket_path else Path.cwd()

    for root, _, files in os.walk(data_dir):
        for file in files:
            if not file.endswith(suffix):
                continue

            local_path = Path(os.path.join(root, file))
            key_name = str(local_path.relative_to(base_path))
            abs_path = os.path.abspath(local_path)

            bucket_path_full = (
                f"{bucket_path}/{key_name}"
                if bucket_path and bucket_path != ""
                else key_name
            )

            try:
                s3_client.upload_file(abs_path, bucket_name, bucket_path_full)
                print_message(
                    f"[Dados] Enviado: {local_path} para {bucket_name}/{bucket_path_full}"
                )
            except (BotoCoreError, ClientError) as aws_error:
                exit_message(f"Erro ao enviar {local_path.name} para S3: {aws_error}")


def sync_sql_to_s3(base_dir: str, bucket_name: str, s3_client: boto3.client) -> None:
    """
    Envia arquivos .sql do diretório especificado para o bucket S3.

    Args:
        base_dir (str): Caminho do diretório local a ser sincronizado.
        bucket_name (str): Nome do bucket S3 de destino.
        s3_client (boto3.client): Cliente S3 autenticado.
    """
    print_message(f"Sincronizando a pasta: {base_dir} com o bucket S3: {bucket_name}")
    data_dir = Path.cwd() / base_dir

    required_args: dict = {
        "base_dir": base_dir,
        "bucket_name": bucket_name,
        "s3_client": s3_client,
    }

    validade_missing_args(required_args)

    if not os.path.isdir(data_dir):
        print_message(f"O diretório {data_dir} não existe ou não é válido.")
        return

    if not bucket_name:
        exit_message("O nome do bucket S3 não foi fornecido.")

    try:
        send_data_to_s3(bucket_name, "", data_dir, s3_client, ".sql")
    except Exception as e:
        exit_message(f"Erro inesperado ao sincronizar {data_dir}: {e}")


def sync_gx_folder(
    base_dir: str, bucket_name: str, bucket_path: str, s3_client, suffix: str
) -> None:
    """
    Sincroniza a pasta do GX com o bucket S3.

    Args:
        base_dir (str): Caminho do diretório local a ser sincronizado.
        bucket_name (str): Nome do bucket S3 de destino.
        bucket_path (str): Caminho dentro do bucket S3 onde os arquivos serão armazenados.
        s3_client: Cliente S3 autenticado.
        suffix (str): Sufixo dos arquivos a serem sincronizados (ex: '.py', '.json').
    """

    print_message(
        f"Sincronizando a pasta GX: {base_dir} com o bucket S3: {bucket_name}"
    )
    data_dir = Path.cwd() / base_dir

    required_args: dict = {
        "base_dir": base_dir,
        "bucket_name": bucket_name,
        "bucket_path": bucket_path,
        "s3_client": s3_client,
        "suffix": suffix,
    }

    validade_missing_args(required_args)

    if not (data_dir.exists() and data_dir.is_dir()):
        print_message(f"O diretório {data_dir} não existe ou não é válido.")
        return

    for folder_name in ["checkpoints", "expectations", "validation_definitions"]:
        folder_path = data_dir / folder_name
        if not folder_path.exists() or not any(folder_path.iterdir()):
            exit_message(
                f"[Dados] Pasta de {folder_name} não encontrada ou vazia em {folder_path}"
            )

    print_message(
        "[Dados] Validação concluída: checkpoints, expectations e validation_definitions encontrados"
    )

    try:
        send_data_to_s3(bucket_name, bucket_path, data_dir, s3_client, suffix, True)
    except Exception as e:
        exit_message(f"Erro inesperado ao sincronizar {data_dir}: {e}")


def sync_folder(
    base_dir: str, bucket_name: str, bucket_path: str, s3_client, suffix: str
) -> None:
    """
    Sincroniza uma pasta local com um bucket S3.

    Args:
        base_dir (str): Caminho do diretório local a ser sincronizado.
        bucket_name (str): Nome do bucket S3 de destino.
        bucket_path (str): Caminho dentro do bucket S3 onde os arquivos serão armazenados.
        s3_client: Cliente S3 autenticado.
        suffix (str): Sufixo dos arquivos a serem sincronizados (ex: '.py', '.json').
    """

    print_message(f"Sincronizando a pasta: {base_dir} com o bucket S3: {bucket_name}")
    data_dir = Path.cwd() / base_dir

    required_args: dict = {
        "base_dir": base_dir,
        "bucket_name": bucket_name,
        "bucket_path": bucket_path,
        "s3_client": s3_client,
        "suffix": suffix,
    }

    validade_missing_args(required_args)

    if not os.path.isdir(data_dir):
        print_message(f"O diretório {data_dir} não existe ou não é válido.")
        return

    else:
        dags_in_folder = any(
            os.path.isfile(os.path.join(base_dir, f)) for f in os.listdir(base_dir)
        )

        if dags_in_folder:
            exit_message(
                f"[Dados] Encontrada pasta {base_dir} com arquivos, mova para a pasta correspondente."
            )

    try:
        send_data_to_s3(bucket_name, bucket_path, data_dir, s3_client, suffix)
    except Exception as e:
        exit_message(f"Erro inesperado ao sincronizar {data_dir}: {e}")


def get_bastion_instance(ec2_client: boto3.client, environment: str) -> str | None:
    """
    Obtém o ID da instância Bastion para o ambiente especificado.

    Args:
        ec2_client (boto3.client): Cliente EC2 autenticado.
        environment (str): O ambiente para o qual a instância Bastion é necessária.
    """
    print_message("Buscando instância bastion")

    required_args: dict = {
        "ec2_client": ec2_client,
        "environment": environment,
    }

    validade_missing_args(required_args)

    response = ec2_client.describe_instances(
        Filters=[
            {"Name": "tag:Name", "Values": [f"{environment}-bastion"]},
            {"Name": "instance-state-name", "Values": ["running"]},
        ]
    )
    instances = response.get("Reservations", [])
    if instances:
        return instances[0]["Instances"][0]["InstanceId"]

    return None


def sync_dags_bastion(
    ssm_client: boto3.client, bastion_instance: str, work_bucket_name: str
):
    """
    Sincroniza os DAGs do Airflow do bucket S3 para a instância Bastion usando o AWS SSM.

    Args:
        ssm_client (boto3.client): Cliente SSM autenticado.
        bastion_instance (str): ID da instância Bastion onde os DAGs serão sincronizados.
        work_bucket_name (str): Nome do bucket S3 de trabalho onde os DAGs estão armazenados.
    """
    print_message("Sincronizando dags do bucket S3 com a instância bastion")

    required_args: dict = {
        "ssm_client": ssm_client,
        "work_bucket_name": work_bucket_name,
    }

    validade_missing_args(required_args)

    if not bastion_instance:
        exit_message("Erro: Instância Bastion não encontrada.")

    command = (
        f"/root/bin/kubectl exec -it $(/root/bin/kubectl get pods --selector=app=airflow-s3-sync -n airflow "
        f"--field-selector=status.phase=Running -o jsonpath='{{.items[0].metadata.name}}') "
        f"-n airflow -- aws s3 sync s3://{work_bucket_name}/airflow/dags /airflow-dags "
        f"--exclude '*' --include '*.py' --include '*.json' --delete"
    )

    response = ssm_client.send_command(
        DocumentName="AWS-RunShellScript",
        InstanceIds=[bastion_instance],
        TimeoutSeconds=120,
        Parameters={
            "commands": ["export KUBECONFIG=/home/ec2-user/.kube/config", command]
        },
    )

    command_id = response.get("Command", {}).get("CommandId")

    if not command_id:
        exit_message("Erro: Falha ao executar o comando. Command ID está vazio.")

    while True:
        time.sleep(10)
        status_response = ssm_client.list_command_invocations(CommandId=command_id)
        status = status_response.get("CommandInvocations", [{}])[0].get("Status", "")
        print_message(f"Command status: {status}")

        if status not in ["Pending", "InProgress", "Delayed"]:
            if status != "Success":
                exit_message(f"Erro: Falha ao executar o comando. Status: {status}.")
            else:
                print_message("Comando executado com sucesso.")
                break


def validade_missing_args(args: dict) -> None:
    """
    Valida se todos os argumentos obrigatórios estão presentes.

    Args:
        args (dict): Dicionário de argumentos a serem validados.

    Raises:
        ValueError: Se algum dos argumentos obrigatórios não for fornecido.
    """
    missing_args = [arg for arg, value in args.items() if value is None]

    if missing_args:
        exit_message(
            f"Os seguintes argumentos devem ser fornecidos: {', '.join(missing_args)}"
        )


def exec_command_venv(
    command: str,
    use_python=False,
    print_command=True,
    print_output=True,
    error_message: str = None,
    env: dict = None,
):
    """
    Executa um comando dentro de uma venv, sem precisar ativar.

    Args:
        command (str): comando shell a ser executado (ex: 'twine check dist/*')
        use_python (bool): se True, prefixa com 'python -m' e não busca executável direto
        print_command (bool): se True, imprime o comando antes de executar
        print_output (bool): se True, imprime a saída do comando
        error_message (str): mensagem de erro personalizada em caso de falha
        env (dict): dicionário de variáveis de ambiente adicionais a serem passadas

    Exemplo:
        run_in_venv("twine check dist/*")
        run_in_venv("twine", use_python=True)
    """
    cmd = get_env_command(command, use_python)

    print_message(f"Rodando o comando no ambiente virtual ({PYTHON_VENV})")

    # subprocess.run(cmd, check=True)
    exec_command(
        " ".join(cmd),
        env=env,
        print_command=print_command,
        print_output=print_output,
        error_message=error_message,
    )


def get_env_command(command: str, use_python=False) -> list[str]:
    """
    Gera o comando a ser executado no ambiente virtual.

    Args:
        command (str): Comando a ser executado.
        use_python (bool): Se True, prefixa o comando com 'python -m'.
    Returns:
        list[str]: Lista de strings representando o comando e seus argumentos.

    Raises:
        ValueError: Se o comando não for uma string válida.
    """
    if command is None or not isinstance(command, str):
        raise ValueError("O comando deve ser uma string válida.")

    venv_bin = Path(PYTHON_VENV) / "bin"

    # Separar comando e argumentos
    parts = command.strip().split()

    if not parts or len(parts) < 1:
        raise ValueError("O comando não pode estar vazio.")

    exe = parts[0]
    args = parts[1:]

    if use_python:
        return [str(venv_bin / "python"), "-m", exe] + args
    else:
        return [str(venv_bin / exe)] + args


def custom_unit_test(
    environments: dict = None, source: str = "src", python_path: str = None
) -> None:
    """
    Executa os testes unitários com cobertura de código usando uv.

    Args:
        environments (dict): Dicionário de variáveis de ambiente adicionais a serem passadas.
        source (str): Caminho do diretório de origem do código a ser testado. Padrão é "src".
        python_path (str): Caminho do diretório a ser adicionado ao PYTHONPATH. Padrão é "src".
    """

    coverage_command = (
        f"coverage run --source={source} -m pytest -m ci tests --junitxml=report.xml"
    )

    if python_path:
        coverage_command = " ".join(get_env_command(coverage_command))

        python_path = f"PYTHONPATH={python_path}"
        coverage_command = f"{python_path} {coverage_command}"

        exec_command(
            coverage_command,
            env=environments,
            print_command=True,
            print_output=True,
            error_message="Erro ao executar os testes unitários.",
        )
    else:
        exec_command_venv(
            coverage_command,
            env=environments,
            error_message="Erro ao executar os testes unitários.",
        )

    exec_command_venv("coverage report")
    exec_command_venv("coverage xml -i -o coverage/cobertura-coverage.xml")


def read_pyproject_toml(file_path="pyproject.toml") -> Any:
    """
        Lê o arquivo pyproject.toml e retorna seu conteúdo como um dicionário.

    Args:
        file_path (str): Caminho para o arquivo pyproject.toml. Padrão é "pyproject.toml".

    """
    try:
        # Open the file in binary read mode ('rb')
        with open(file_path, "rb") as f:
            data = tomli.load(f)
        return data
    except FileNotFoundError:
        print_message(f"Error: {file_path} not found.")
        return None
    except tomli.TOMLDecodeError:
        print_message(f"Error: {file_path} is not a valid TOML file.")
        raise
