from collections import namedtuple
from unittest import mock
from packaging.version import Version
import os
import pytest
import yaml

import catboost as cb
import numpy as np
import pandas as pd
from sklearn import datasets
from sklearn.pipeline import Pipeline

import newron.catboost
from newron import pyfunc
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
from mlflow.models.utils import _read_example
from newron.models import Model, infer_signature
from mlflow.store.artifact.s3_artifact_repo import S3ArtifactRepository
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
from mlflow.utils.environment import _mlflow_conda_env
from mlflow.utils.model_utils import _get_flavor_configuration
from mlflow.tracking._model_registry import DEFAULT_AWAIT_MAX_SLEEP_SECONDS

from tests.helper_functions import (
    pyfunc_serve_and_score_model,
    _compare_conda_env_requirements,
    _assert_pip_requirements,
    _is_available_on_pypi,
    _compare_logged_code_paths,
)

EXTRA_PYFUNC_SERVING_TEST_ARGS = (
    [] if _is_available_on_pypi("catboost") else ["--env-manager", "local"]
)

ModelWithData = namedtuple("ModelWithData", ["model", "inference_dataframe"])


def get_iris():
    iris = datasets.load_iris()
    X = pd.DataFrame(iris.data[:, :2], columns=iris.feature_names[:2])
    y = pd.Series(iris.target)
    return X, y


def read_yaml(path):
    with open(path, "r") as f:
        return yaml.safe_load(f)


MODEL_PARAMS = {"allow_writing_files": False, "iterations": 10}


@pytest.fixture(
    scope="module",
    params=[
        cb.CatBoost(MODEL_PARAMS),
        cb.CatBoostClassifier(**MODEL_PARAMS),
        cb.CatBoostRegressor(**MODEL_PARAMS),
    ],
    ids=["CatBoost", "CatBoostClassifier", "CatBoostRegressor"],
)
def cb_model(request):
    model = request.param
    X, y = get_iris()
    return ModelWithData(model=model.fit(X, y), inference_dataframe=X)


@pytest.fixture
def reg_model():
    model = cb.CatBoostRegressor(**MODEL_PARAMS)
    X, y = get_iris()
    return ModelWithData(model=model.fit(X, y), inference_dataframe=X)


@pytest.fixture
def model_path(tmpdir):
    return tmpdir.join("model").strpath


@pytest.fixture
def custom_env(tmpdir):
    conda_env_path = os.path.join(tmpdir.strpath, "conda_env.yml")
    _mlflow_conda_env(conda_env_path, additional_pip_deps=["catboost", "pytest"])
    return conda_env_path


@pytest.mark.parametrize("model_type", ["CatBoost", "CatBoostClassifier", "CatBoostRegressor"])
def test_init_model(model_type):
    model = newron.catboost._init_model(model_type)
    assert model.__class__.__name__ == model_type


@pytest.mark.skipif(
    Version(cb.__version__) < Version("0.26.0"),
    reason="catboost < 0.26.0 does not support CatBoostRanker",
)
def test_log_catboost_ranker():
    """
    This is a separate test for the CatBoostRanker model.
    It is separate since the ranking task requires a group_id column which makes the code different.
    """
    # the ranking task requires setting a group_id
    # we are creating a dummy group_id here that doesn't make any sense for the Iris dataset,
    # but is ok for testing if the code is running correctly
    X, y = get_iris()
    dummy_group_id = np.arange(len(X)) % 3
    dummy_group_id.sort()

    model = cb.CatBoostRanker(**MODEL_PARAMS, subsample=1.0)
    model.fit(X, y, group_id=dummy_group_id)

    with newron.start_run():
        model_info = newron.catboost.log_model(model, "model")
        loaded_model = newron.catboost.load_model(model_info.model_uri)
        assert isinstance(loaded_model, cb.CatBoostRanker)
        np.testing.assert_array_almost_equal(model.predict(X), loaded_model.predict(X))


def test_init_model_throws_for_invalid_model_type():
    with pytest.raises(TypeError, match="Invalid model type"):
        newron.catboost._init_model("unsupported")


def test_model_save_load(cb_model, model_path):
    model, inference_dataframe = cb_model
    newron.catboost.save_model(cb_model=model, path=model_path)

    loaded_model = newron.catboost.load_model(model_uri=model_path)
    np.testing.assert_array_almost_equal(
        model.predict(inference_dataframe),
        loaded_model.predict(inference_dataframe),
    )

    loaded_pyfunc = pyfunc.load_model(model_uri=model_path)
    np.testing.assert_array_almost_equal(
        loaded_model.predict(inference_dataframe),
        loaded_pyfunc.predict(inference_dataframe),
    )


def test_log_model_logs_model_type(cb_model):
    with newron.start_run():
        artifact_path = "model"
        newron.catboost.log_model(cb_model.model, artifact_path)
        model_uri = newron.get_artifact_uri(artifact_path)

    flavor_conf = Model.load(model_uri).flavors["catboost"]
    assert "model_type" in flavor_conf
    assert flavor_conf["model_type"] == cb_model.model.__class__.__name__


# Supported serialization formats:
# https://catboost.ai/docs/concepts/python-reference_catboost_save_model.html
SUPPORTS_DESERIALIZATION = ["cbm", "coreml", "json", "onnx"]
save_formats = SUPPORTS_DESERIALIZATION + ["python", "cpp", "pmml"]


@pytest.mark.allow_infer_pip_requirements_fallback
@pytest.mark.parametrize("save_format", save_formats)
def test_log_model_logs_save_format(reg_model, save_format):
    with newron.start_run():
        artifact_path = "model"
        newron.catboost.log_model(reg_model.model, artifact_path, format=save_format)
        model_uri = newron.get_artifact_uri(artifact_path)

    flavor_conf = Model.load(model_uri).flavors["catboost"]
    assert "save_format" in flavor_conf
    assert flavor_conf["save_format"] == save_format

    if save_format in SUPPORTS_DESERIALIZATION:
        newron.catboost.load_model(model_uri)
    else:
        with pytest.raises(cb.CatBoostError, match="deserialization not supported or missing"):
            newron.catboost.load_model(model_uri)


@pytest.mark.parametrize("signature", [None, infer_signature(get_iris()[0])])
@pytest.mark.parametrize("input_example", [None, get_iris()[0].head(3)])
def test_signature_and_examples_are_saved_correctly(
    reg_model, model_path, signature, input_example
):
    newron.catboost.save_model(
        reg_model.model, model_path, signature=signature, input_example=input_example
    )
    newron_model = Model.load(model_path)
    assert signature == newron_model.signature
    if input_example is None:
        assert newron_model.saved_input_example_info is None
    else:
        pd.testing.assert_frame_equal(_read_example(newron_model, model_path), input_example)


def test_model_load_from_remote_uri_succeeds(reg_model, model_path, mock_s3_bucket):
    model, inference_dataframe = reg_model
    newron.catboost.save_model(cb_model=model, path=model_path)
    artifact_root = "s3://{bucket_name}".format(bucket_name=mock_s3_bucket)
    artifact_repo = S3ArtifactRepository(artifact_root)
    artifact_path = "model"
    artifact_repo.log_artifacts(model_path, artifact_path=artifact_path)

    model_uri = artifact_root + "/" + artifact_path
    loaded_model = newron.catboost.load_model(model_uri=model_uri)
    np.testing.assert_array_almost_equal(
        model.predict(inference_dataframe),
        loaded_model.predict(inference_dataframe),
    )


def test_log_model(cb_model, tmpdir):
    model, inference_dataframe = cb_model
    with newron.start_run():
        artifact_path = "model"
        conda_env = os.path.join(tmpdir.strpath, "conda_env.yaml")
        _mlflow_conda_env(conda_env, additional_pip_deps=["catboost"])

        model_info = newron.catboost.log_model(model, artifact_path, conda_env=conda_env)
        model_uri = "runs:/{}/{}".format(newron.active_run().info.run_id, artifact_path)
        assert model_info.model_uri == model_uri

        loaded_model = newron.catboost.load_model(model_uri)
        np.testing.assert_array_almost_equal(
            model.predict(inference_dataframe),
            loaded_model.predict(inference_dataframe),
        )

        local_path = _download_artifact_from_uri(model_uri)
        model_config = Model.load(os.path.join(local_path, "MLmodel"))
        assert pyfunc.FLAVOR_NAME in model_config.flavors
        assert pyfunc.ENV in model_config.flavors[pyfunc.FLAVOR_NAME]
        env_path = model_config.flavors[pyfunc.FLAVOR_NAME][pyfunc.ENV]
        assert os.path.exists(os.path.join(local_path, env_path))


def test_log_model_calls_register_model(cb_model, tmpdir):
    artifact_path = "model"
    registered_model_name = "registered_model"
    with newron.start_run() as run, mock.patch("newron.register_model") as register_model_mock:
        conda_env_path = os.path.join(tmpdir.strpath, "conda_env.yaml")
        _mlflow_conda_env(conda_env_path, additional_pip_deps=["catboost"])
        newron.catboost.log_model(
            cb_model.model,
            artifact_path,
            conda_env=conda_env_path,
            registered_model_name=registered_model_name,
        )
        model_uri = "runs:/{}/{}".format(run.info.run_id, artifact_path)
        register_model_mock.assert_called_once_with(
            model_uri, registered_model_name, await_registration_for=DEFAULT_AWAIT_MAX_SLEEP_SECONDS
        )


def test_log_model_no_registered_model_name(cb_model, tmpdir):
    with newron.start_run(), mock.patch("newron.register_model") as register_model_mock:
        artifact_path = "model"
        conda_env_path = os.path.join(tmpdir.strpath, "conda_env.yaml")
        _mlflow_conda_env(conda_env_path, additional_pip_deps=["catboost"])
        newron.catboost.log_model(cb_model.model, artifact_path, conda_env=conda_env_path)
        register_model_mock.assert_not_called()


def test_model_save_persists_specified_conda_env_in_newron_model_directory(
    reg_model, model_path, custom_env
):
    newron.catboost.save_model(cb_model=reg_model.model, path=model_path, conda_env=custom_env)
    pyfunc_conf = _get_flavor_configuration(model_path=model_path, flavor_name=pyfunc.FLAVOR_NAME)
    saved_conda_env_path = os.path.join(model_path, pyfunc_conf[pyfunc.ENV])
    assert os.path.exists(saved_conda_env_path)
    assert saved_conda_env_path != custom_env
    assert read_yaml(saved_conda_env_path) == read_yaml(custom_env)


def test_model_save_persists_requirements_in_newron_model_directory(
    reg_model, model_path, custom_env
):
    newron.catboost.save_model(cb_model=reg_model.model, path=model_path, conda_env=custom_env)

    saved_pip_req_path = os.path.join(model_path, "requirements.txt")
    _compare_conda_env_requirements(custom_env, saved_pip_req_path)


def test_model_save_accepts_conda_env_as_dict(reg_model, model_path):
    conda_env = newron.catboost.get_default_conda_env()
    conda_env["dependencies"].append("pytest")
    newron.catboost.save_model(cb_model=reg_model.model, path=model_path, conda_env=conda_env)

    pyfunc_conf = _get_flavor_configuration(model_path=model_path, flavor_name=pyfunc.FLAVOR_NAME)
    saved_conda_env_path = os.path.join(model_path, pyfunc_conf[pyfunc.ENV])
    assert os.path.exists(saved_conda_env_path)
    assert read_yaml(saved_conda_env_path) == conda_env


def test_model_log_persists_specified_conda_env_in_newron_model_directory(reg_model, custom_env):
    artifact_path = "model"
    with newron.start_run():
        newron.catboost.log_model(reg_model.model, artifact_path, conda_env=custom_env)
        model_uri = newron.get_artifact_uri(artifact_path)

    local_path = _download_artifact_from_uri(artifact_uri=model_uri)
    pyfunc_conf = _get_flavor_configuration(model_path=local_path, flavor_name=pyfunc.FLAVOR_NAME)
    saved_conda_env_path = os.path.join(local_path, pyfunc_conf[pyfunc.ENV])
    assert os.path.exists(saved_conda_env_path)
    assert saved_conda_env_path != custom_env
    assert read_yaml(saved_conda_env_path) == read_yaml(custom_env)


def test_model_log_persists_requirements_in_newron_model_directory(reg_model, custom_env):
    artifact_path = "model"
    with newron.start_run():
        newron.catboost.log_model(reg_model.model, artifact_path, conda_env=custom_env)
        model_uri = newron.get_artifact_uri(artifact_path)

    local_path = _download_artifact_from_uri(artifact_uri=model_uri)
    saved_pip_req_path = os.path.join(local_path, "requirements.txt")
    _compare_conda_env_requirements(custom_env, saved_pip_req_path)


def test_log_model_with_pip_requirements(reg_model, tmpdir):
    # Path to a requirements file
    req_file = tmpdir.join("requirements.txt")
    req_file.write("a")
    with newron.start_run():
        newron.catboost.log_model(reg_model.model, "model", pip_requirements=req_file.strpath)
        _assert_pip_requirements(newron.get_artifact_uri("model"), ["newron", "a"], strict=True)

    # List of requirements
    with newron.start_run():
        newron.catboost.log_model(
            reg_model.model, "model", pip_requirements=[f"-r {req_file.strpath}", "b"]
        )
        _assert_pip_requirements(
            newron.get_artifact_uri("model"), ["newron", "a", "b"], strict=True
        )

    # Constraints file
    with newron.start_run():
        newron.catboost.log_model(
            reg_model.model, "model", pip_requirements=[f"-c {req_file.strpath}", "b"]
        )
        _assert_pip_requirements(
            newron.get_artifact_uri("model"),
            ["newron", "b", "-c constraints.txt"],
            ["a"],
            strict=True,
        )


def test_log_model_with_extra_pip_requirements(reg_model, tmpdir):
    default_reqs = newron.catboost.get_default_pip_requirements()

    # Path to a requirements file
    req_file = tmpdir.join("requirements.txt")
    req_file.write("a")
    with newron.start_run():
        newron.catboost.log_model(reg_model.model, "model", extra_pip_requirements=req_file.strpath)
        _assert_pip_requirements(newron.get_artifact_uri("model"), ["newron", *default_reqs, "a"])

    # List of requirements
    with newron.start_run():
        newron.catboost.log_model(
            reg_model.model, "model", extra_pip_requirements=[f"-r {req_file.strpath}", "b"]
        )
        _assert_pip_requirements(
            newron.get_artifact_uri("model"), ["newron", *default_reqs, "a", "b"]
        )

    # Constraints file
    with newron.start_run():
        newron.catboost.log_model(
            reg_model.model, "model", extra_pip_requirements=[f"-c {req_file.strpath}", "b"]
        )
        _assert_pip_requirements(
            newron.get_artifact_uri("model"),
            ["newron", *default_reqs, "b", "-c constraints.txt"],
            ["a"],
        )


def test_model_save_without_specified_conda_env_uses_default_env_with_expected_dependencies(
    reg_model, model_path
):
    newron.catboost.save_model(reg_model.model, model_path)
    _assert_pip_requirements(model_path, newron.catboost.get_default_pip_requirements())


def test_model_log_without_specified_conda_env_uses_default_env_with_expected_dependencies(
    reg_model,
):
    artifact_path = "model"
    with newron.start_run():
        newron.catboost.log_model(reg_model.model, artifact_path)
        model_uri = newron.get_artifact_uri(artifact_path)

    _assert_pip_requirements(model_uri, newron.catboost.get_default_pip_requirements())


def test_pyfunc_serve_and_score(reg_model):
    model, inference_dataframe = reg_model
    artifact_path = "model"
    with newron.start_run():
        newron.catboost.log_model(model, artifact_path)
        model_uri = newron.get_artifact_uri(artifact_path)

    resp = pyfunc_serve_and_score_model(
        model_uri,
        data=inference_dataframe,
        content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON_SPLIT_ORIENTED,
        extra_args=EXTRA_PYFUNC_SERVING_TEST_ARGS,
    )
    scores = pd.read_json(resp.content.decode("utf-8"), orient="records").values.squeeze()
    np.testing.assert_array_almost_equal(scores, model.predict(inference_dataframe))


def test_pyfunc_serve_and_score_sklearn(reg_model):
    model, inference_dataframe = reg_model
    model = Pipeline([("model", reg_model.model)])

    with newron.start_run():
        newron.sklearn.log_model(model, artifact_path="model")
        model_uri = newron.get_artifact_uri("model")

    resp = pyfunc_serve_and_score_model(
        model_uri,
        inference_dataframe.head(3),
        pyfunc_scoring_server.CONTENT_TYPE_JSON_SPLIT_ORIENTED,
        extra_args=EXTRA_PYFUNC_SERVING_TEST_ARGS,
    )
    scores = pd.read_json(resp.content.decode("utf-8"), orient="records").values.squeeze()
    np.testing.assert_array_almost_equal(scores, model.predict(inference_dataframe.head(3)))


def test_log_model_with_code_paths(cb_model):
    artifact_path = "model"
    with newron.start_run(), mock.patch(
        "newron.catboost._add_code_from_conf_to_system_path"
    ) as add_mock:
        newron.catboost.log_model(cb_model.model, artifact_path, code_paths=[__file__])
        model_uri = newron.get_artifact_uri(artifact_path)
        _compare_logged_code_paths(__file__, model_uri, newron.catboost.FLAVOR_NAME)
        newron.catboost.load_model(model_uri=model_uri)
        add_mock.assert_called()