import datetime
import json
import logging
import os
import random
import string
import tempfile
import unittest

from flask import Flask
from flask_appbuilder import AppBuilder, ModelView
from flask_appbuilder.charts.views import (
    DirectByChartView,
    DirectChartView,
    GroupByChartView,
)
from flask_appbuilder.cli import export_roles, import_roles
from flask_appbuilder.models.group import aggregate_avg, aggregate_count, aggregate_sum
from flask_appbuilder.models.mongoengine.filters import FilterEqual, FilterStartsWith
from flask_appbuilder.views import CompactCRUDMixin, MasterDetailView
import jinja2

from .base import FABTestCase

logging.basicConfig(format="%(asctime)s:%(levelname)s:%(name)s:%(message)s")
logging.getLogger().setLevel(logging.DEBUG)
log = logging.getLogger(__name__)

try:
    from flask_appbuilder.models.mongoengine.interface import MongoEngineInterface
    from flask_appbuilder.security.mongoengine.manager import SecurityManager
    from mongoengine.connection import disconnect_all
    from flask_mongoengine import MongoEngine
    from .mongoengine.models import Model1, Model2
except ImportError:
    log.error("Import failed. Required dependencies are in requirements-mongo.txt")

"""
    Constant english display string from framework
"""
DEFAULT_INDEX_STRING = "Welcome"
INVALID_LOGIN_STRING = "Invalid login"
ACCESS_IS_DENIED = "Access is Denied"
UNIQUE_VALIDATION_STRING = "Already exists"
NOTNULL_VALIDATION_STRING = "This field is required"
DEFAULT_ADMIN_USER = "admin"
DEFAULT_ADMIN_PASSWORD = "general"


class FlaskTestCase(FABTestCase):
    mongo = True

    def setUp(self):
        self.app = Flask(__name__)
        self.app.jinja_env.undefined = jinja2.StrictUndefined
        self.basedir = os.path.abspath(os.path.dirname(__file__))
        self.app.config["MONGODB_SETTINGS"] = {"DB": "test"}
        self.app.config["CSRF_ENABLED"] = False
        self.app.config["SECRET_KEY"] = "thisismyscretkey"
        self.app.config["WTF_CSRF_ENABLED"] = False

        self.db = MongoEngine(self.app)
        self.appbuilder = AppBuilder(self.app, security_manager_class=SecurityManager)

        class Model2View(ModelView):
            datamodel = MongoEngineInterface(Model2)
            list_columns = [
                "field_integer",
                "field_float",
                "field_string",
                "field_method",
                "group.field_string",
            ]
            edit_form_query_rel_fields = {
                "group": [["field_string", FilterEqual, "G2"]]
            }
            add_form_query_rel_fields = {"group": [["field_string", FilterEqual, "G1"]]}
            add_exclude_columns = ["excluded_string"]

        class Model22View(ModelView):
            datamodel = MongoEngineInterface(Model2)
            list_columns = [
                "field_integer",
                "field_float",
                "field_string",
                "field_method",
                "group.field_string",
            ]
            add_exclude_columns = ["excluded_string"]
            edit_exclude_columns = ["excluded_string"]
            show_exclude_columns = ["excluded_string"]

        class Model1View(ModelView):
            datamodel = MongoEngineInterface(Model1)
            related_views = [Model2View]
            list_columns = ["field_string", "field_file"]

        class Model1CompactView(CompactCRUDMixin, ModelView):
            datamodel = MongoEngineInterface(Model1)

        class Model1Filtered1View(ModelView):
            datamodel = MongoEngineInterface(Model1)
            base_filters = [["field_string", FilterStartsWith, "a"]]

        class Model1MasterView(MasterDetailView):
            datamodel = MongoEngineInterface(Model1)
            related_views = [Model2View]

        class Model1Filtered2View(ModelView):
            datamodel = MongoEngineInterface(Model1)
            base_filters = [["field_integer", FilterEqual, 0]]

        class Model2GroupByChartView(GroupByChartView):
            datamodel = MongoEngineInterface(Model2)
            chart_title = "Test Model1 Chart"

            definitions = [
                {
                    "group": "field_string",
                    "series": [
                        (
                            aggregate_sum,
                            "field_integer",
                            aggregate_avg,
                            "field_integer",
                            aggregate_count,
                            "field_integer",
                        )
                    ],
                }
            ]

        class Model2DirectByChartView(DirectByChartView):
            datamodel = MongoEngineInterface(Model2)
            chart_title = "Test Model1 Chart"

            definitions = [
                {"group": "field_string", "series": ["field_integer", "field_float"]}
            ]

        class Model2DirectChartView(DirectChartView):
            datamodel = MongoEngineInterface(Model2)
            chart_title = "Test Model1 Chart"
            direct_columns = {"stat1": ("group", "field_integer")}

        class Model1MasterChartView(MasterDetailView):
            datamodel = MongoEngineInterface(Model1)
            related_views = [Model2DirectByChartView]

        self.appbuilder.add_view(Model1View, "Model1", category="Model1")
        self.appbuilder.add_view(Model1CompactView, "Model1Compact", category="Model1")
        self.appbuilder.add_view(Model1MasterView, "Model1Master", category="Model1")
        self.appbuilder.add_view(
            Model1MasterChartView, "Model1MasterChart", category="Model1"
        )
        self.appbuilder.add_view(
            Model1Filtered1View, "Model1Filtered1", category="Model1"
        )
        self.appbuilder.add_view(
            Model1Filtered2View, "Model1Filtered2", category="Model1"
        )

        self.appbuilder.add_view(Model2View, "Model2")
        self.appbuilder.add_view(Model22View, "Model22")
        self.appbuilder.add_view(Model2View, "Model2 Add", href="/model2view/add")
        self.appbuilder.add_view(Model2GroupByChartView, "Model2 Group By Chart")
        self.appbuilder.add_view(Model2DirectByChartView, "Model2 Direct By Chart")
        self.appbuilder.add_view(Model2DirectChartView, "Model2 Direct Chart")

        role_admin = self.appbuilder.sm.find_role("Admin")
        try:
            self.appbuilder.sm.add_user(
                "admin", "admin", "user", "admin@fab.org", role_admin, "general"
            )
        except Exception:
            pass

    def tearDown(self):
        self.appbuilder = None
        self.app = None
        self.db = None
        log.debug("TEAR DOWN")

    @classmethod
    def tearDownClass(cls):
        disconnect_all()

    """ ---------------------------------
            TEST HELPER FUNCTIONS
        ---------------------------------
    """

    def insert_data(self):
        for x, i in zip(string.ascii_letters[:23], range(23)):
            model = Model1(field_string="%stest" % (x), field_integer=i)
            model.save()

    def insert_data2(self):
        models1 = [
            Model1(field_string="G1"),
            Model1(field_string="G2"),
            Model1(field_string="G3"),
        ]
        for model1 in models1:
            try:
                model1.save()
                for x, i in zip(string.ascii_letters[:10], range(10)):
                    model = Model2(
                        field_string="%stest" % (x),
                        field_integer=random.randint(1, 10),
                        field_float=random.uniform(0.0, 1.0),
                        group=model1,
                    )
                    year = random.choice(range(1900, 2012))
                    month = random.choice(range(1, 12))
                    day = random.choice(range(1, 28))
                    model.field_date = datetime.datetime(year, month, day)

                    model.save()
            except Exception as e:
                print("ERROR {0}".format(str(e)))

    def clean_data(self):
        Model1.drop_collection()
        Model2.drop_collection()

    def test_fab_views(self):
        """
        Test views creation and registration
        """
        self.assertEqual(
            len(self.appbuilder.baseviews), 26
        )  # current minimal views are 26

    def test_index(self):
        """
        Test initial access and index message
        """
        client = self.app.test_client()

        # Check for Welcome Message
        rv = client.get("/")
        data = rv.data.decode("utf-8")
        self.assertTrue(DEFAULT_INDEX_STRING in data)

    def test_sec_login(self):
        """
        Test Security Login, Logout, invalid login, invalid access
        """
        client = self.app.test_client()

        # Try to List and Redirect to Login
        rv = client.get("/model1view/list/")
        self.assertEqual(rv.status_code, 302)
        rv = client.get("/model2view/list/")
        self.assertEqual(rv.status_code, 302)

        # Login and list with admin
        self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)
        rv = client.get("/model1view/list/")
        self.assertEqual(rv.status_code, 200)
        rv = client.get("/model2view/list/")
        self.assertEqual(rv.status_code, 200)

        # Logout and and try to list
        self.browser_logout(client)
        rv = client.get("/model1view/list/")
        self.assertEqual(rv.status_code, 302)
        rv = client.get("/model2view/list/")
        self.assertEqual(rv.status_code, 302)

        # Invalid Login
        rv = self.browser_login(client, DEFAULT_ADMIN_USER, "password")
        data = rv.data.decode("utf-8")
        self.assertTrue(INVALID_LOGIN_STRING in data)

    def test_sec_reset_password(self):
        """
        Test Security reset password
        """
        from flask_appbuilder.security.mongoengine.models import User

        client = self.app.test_client()

        # Try Reset My password
        user = User.objects.filter(**{"username": "admin"})[0]
        rv = client.get(
            "/users/action/resetmypassword/{0}".format(user.id), follow_redirects=True
        )
        # Werkzeug update to 0.15.X sends this action to wrong redirect
        # Old test was:
        # data = rv.data.decode("utf-8")
        # self.assertTrue(ACCESS_IS_DENIED in data)
        self.assertEqual(rv.status_code, 404)

        # Reset My password
        rv = self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)
        rv = client.get(
            "/users/action/resetmypassword/{0}".format(user.id), follow_redirects=True
        )
        data = rv.data.decode("utf-8")
        self.assertIn("Reset Password Form", data)
        rv = client.post(
            "/resetmypassword/form",
            data=dict(password="password", conf_password="password"),
            follow_redirects=True,
        )
        self.assertEqual(rv.status_code, 200)
        self.browser_logout(client)
        self.browser_login(client, DEFAULT_ADMIN_USER, "password")
        rv = client.post(
            "/resetmypassword/form",
            data=dict(
                password=DEFAULT_ADMIN_PASSWORD, conf_password=DEFAULT_ADMIN_PASSWORD
            ),
            follow_redirects=True,
        )
        self.assertEqual(rv.status_code, 200)

        # Reset Password Admin
        rv = client.get(
            "/users/action/resetpasswords/{0}".format(user.id), follow_redirects=True
        )
        data = rv.data.decode("utf-8")
        self.assertIn("Reset Password Form", data)
        rv = client.post(
            "/resetmypassword/form",
            data=dict(
                password=DEFAULT_ADMIN_PASSWORD, conf_password=DEFAULT_ADMIN_PASSWORD
            ),
            follow_redirects=True,
        )
        self.assertEqual(rv.status_code, 200)

    def test_model_crud(self):
        """
        Test Model add, delete, edit
        """
        client = self.app.test_client()
        rv = self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)

        rv = client.post(
            "/model1view/add",
            data=dict(
                field_string="test1",
                field_integer="1",
                field_float="0.12",
                field_date="2014-01-01 23:10:07",
            ),
            follow_redirects=True,
        )
        self.assertEqual(rv.status_code, 200)

        model = Model1.objects[0]
        self.assertEqual(model.field_string, "test1")
        self.assertEqual(model.field_integer, 1)

        model1 = Model1.objects(field_string="test1")[0]
        rv = client.post(
            "/model1view/edit/{0}".format(model1.id),
            data=dict(field_string="test2", field_integer="2"),
            follow_redirects=True,
        )
        self.assertEqual(rv.status_code, 200)

        model = Model1.objects[0]
        self.assertEqual(model.field_string, "test2")
        self.assertEqual(model.field_integer, 2)

        rv = client.get(
            "/model1view/delete/{0}".format(model.id), follow_redirects=True
        )
        self.assertEqual(rv.status_code, 200)
        model = Model1.objects
        self.assertEqual(len(model), 0)
        self.clean_data()

    def test_excluded_cols(self):
        """
        Test add_exclude_columns, edit_exclude_columns, show_exclude_columns
        """
        client = self.app.test_client()
        rv = self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)
        rv = client.get("/model22view/add")
        self.assertEqual(rv.status_code, 200)
        data = rv.data.decode("utf-8")
        self.assertTrue("field_string" in data)
        self.assertTrue("field_integer" in data)
        self.assertTrue("field_float" in data)
        self.assertTrue("field_date" in data)
        self.assertTrue("excluded_string" not in data)
        self.insert_data2()
        model2 = Model2.objects[0]
        rv = client.get("/model22view/edit/{0}".format(model2.id))
        self.assertEqual(rv.status_code, 200)
        data = rv.data.decode("utf-8")
        self.assertTrue("field_string" in data)
        self.assertTrue("field_integer" in data)
        self.assertTrue("field_float" in data)
        self.assertTrue("field_date" in data)
        self.assertTrue("excluded_string" not in data)
        rv = client.get("/model22view/show/{0}".format(model2.id))
        self.assertEqual(rv.status_code, 200)
        data = rv.data.decode("utf-8")
        self.assertTrue("Field String" in data)
        self.assertTrue("Field Integer" in data)
        self.assertTrue("Field Float" in data)
        self.assertTrue("Field Date" in data)
        self.assertTrue("Excluded String" not in data)
        self.clean_data()

    def test_query_rel_fields(self):
        """
        Test add and edit form related fields filter
        """
        client = self.app.test_client()
        rv = self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)
        self.insert_data2()

        # Base filter string starts with
        rv = client.get("/model2view/add")
        data = rv.data.decode("utf-8")
        self.assertTrue("G1" in data)
        self.assertTrue("G2" not in data)

        model2 = Model2.objects[0]
        # Base filter string starts with
        rv = client.get("/model2view/edit/{0}".format(model2.id))
        data = rv.data.decode("utf-8")
        self.assertTrue("G2" in data)
        self.assertTrue("G1" not in data)
        self.clean_data()

    def test_model_list_order(self):
        """
        Test Model order on lists
        """
        self.insert_data()

        client = self.app.test_client()
        self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)

        rv = client.post(
            "/model1view/list?_oc_Model1View=field_string&_od_Model1View=asc",
            follow_redirects=True,
        )
        # TODO: fix this 405 Method not allowed error
        # self.assertEqual(rv.status_code, 200)
        rv.data.decode("utf-8")
        # TODO
        # VALIDATE LIST IS ORDERED
        rv = client.post(
            "/model1view/list?_oc_Model1View=field_string&_od_Model1View=desc",
            follow_redirects=True,
        )
        # TODO: fix this 405 Method not allowed error
        # self.assertEqual(rv.status_code, 200)
        rv.data.decode("utf-8")
        # TODO
        # VALIDATE LIST IS ORDERED
        self.clean_data()

    def test_model_add_validation(self):
        """
        Test Model add validations
        """
        client = self.app.test_client()
        self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)

        rv = client.post(
            "/model1view/add",
            data=dict(field_string="test1", field_integer="1"),
            follow_redirects=True,
        )
        self.assertEqual(rv.status_code, 200)

        rv = client.post(
            "/model1view/add",
            data=dict(field_string="test1", field_integer="2"),
            follow_redirects=True,
        )
        self.assertEqual(rv.status_code, 200)
        data = rv.data.decode("utf-8")
        self.assertTrue(UNIQUE_VALIDATION_STRING in data)

        model = Model1.objects()
        self.assertEqual(len(model), 1)

        rv = client.post(
            "/model1view/add",
            data=dict(field_string="", field_integer="1"),
            follow_redirects=True,
        )
        self.assertEqual(rv.status_code, 200)
        data = rv.data.decode("utf-8")
        self.assertTrue(NOTNULL_VALIDATION_STRING in data)

        model = Model1.objects()
        self.assertEqual(len(model), 1)
        self.clean_data()

    def test_model_edit_validation(self):
        """
        Test Model edit validations
        """
        client = self.app.test_client()
        self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)

        client.post(
            "/model1view/add",
            data=dict(field_string="test1", field_integer="1"),
            follow_redirects=True,
        )
        model1 = Model1.objects(field_string="test1")[0]
        client.post(
            "/model1view/add",
            data=dict(field_string="test2", field_integer="1"),
            follow_redirects=True,
        )
        rv = client.post(
            "/model1view/edit/{0}".format(model1.id),
            data=dict(field_string="test2", field_integer="2"),
            follow_redirects=True,
        )
        self.assertEqual(rv.status_code, 200)
        data = rv.data.decode("utf-8")
        self.assertTrue(UNIQUE_VALIDATION_STRING in data)

        rv = client.post(
            "/model1view/edit/{0}".format(model1.id),
            data=dict(field_string="", field_integer="2"),
            follow_redirects=True,
        )
        self.assertEqual(rv.status_code, 200)
        data = rv.data.decode("utf-8")
        self.assertTrue(NOTNULL_VALIDATION_STRING in data)
        self.clean_data()

    def test_model_base_filter(self):
        """
        Test Model base filtered views
        """
        client = self.app.test_client()
        self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)
        self.insert_data()
        models = Model1.objects()
        self.assertEqual(len(models), 23)

        # Base filter string starts with
        rv = client.get("/model1filtered1view/list/")
        data = rv.data.decode("utf-8")
        self.assertTrue("atest" in data)
        self.assertTrue("btest" not in data)

        # Base filter integer equals
        rv = client.get("/model1filtered2view/list/")
        data = rv.data.decode("utf-8")
        self.assertTrue("atest" in data)
        self.assertTrue("btest" not in data)
        self.clean_data()

    def test_model_list_method_field(self):
        """
        Tests a model's field has a method
        """
        client = self.app.test_client()
        self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)
        self.insert_data2()
        rv = client.get("/model2view/list/")
        self.assertEqual(rv.status_code, 200)
        data = rv.data.decode("utf-8")
        self.assertTrue("field_method_value" in data)
        self.clean_data()

    def test_compactCRUDMixin(self):
        """
        Test CompactCRUD Mixin view
        """
        client = self.app.test_client()
        self.browser_login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD)
        self.insert_data2()
        rv = client.get("/model1compactview/list/")
        self.assertEqual(rv.status_code, 200)
        self.clean_data()


class MongoImportExportTestCase(unittest.TestCase):
    mongo = True

    def setUp(self):
        basedir = os.path.abspath(os.path.dirname(__file__))

        with open(os.path.join(basedir, "data/roles.json"), "r") as fd:
            self.expected_roles = json.loads(fd.read())

        self.app = Flask(__name__)
        self.app.config.from_object("tests.config_security")
        self.app.config["MONGODB_SETTINGS"] = {
            "db": "app",
            "host": "localhost",
            "port": 27017,
        }
        self.db = MongoEngine(self.app)  # noqa: F841
        self.app_builder = AppBuilder(  # noqa: F841
            self.app, security_manager_class=SecurityManager
        )
        self.cli_runner = self.app.test_cli_runner()

    def tearDown(self):
        self.appbuilder = None
        self.app = None
        self.db = None
        self.cli_runner = None
        log.debug("TEAR DOWN")

    @classmethod
    def tearDownClass(cls):
        disconnect_all()

    def test_export_roles(self):
        with tempfile.TemporaryDirectory() as tmp_dir:
            path = os.path.join(tmp_dir, "roles.json")
            export_result = self.cli_runner.invoke(export_roles, [f"--path={path}"])

            self.assertEqual(export_result.exit_code, 0)
            self.assertTrue(os.path.exists(path))

            with open(path, "r") as fd:
                resulting_roles = json.loads(fd.read())

            for expected_role in self.expected_roles:
                match = [
                    r for r in resulting_roles if r["name"] == expected_role["name"]
                ]
                self.assertTrue(match)
                resulting_role = match[0]
                resulting_role_permission_view_menus = {
                    (pvm["permission"]["name"], pvm["view_menu"]["name"])
                    for pvm in resulting_role["permissions"]
                }
                expected_role_permission_view_menus = {
                    (pvm["permission"]["name"], pvm["view_menu"]["name"])
                    for pvm in expected_role["permissions"]
                }
                self.assertTrue(
                    all(
                        [
                            pvm in resulting_role_permission_view_menus
                            for pvm in expected_role_permission_view_menus
                        ]
                    )
                )

    def test_import_roles(self):
        with tempfile.TemporaryDirectory() as tmp_dir:
            self.app_builder.sm.role_model.objects(
                name__nin=[
                    self.app_builder.sm.auth_role_public,
                    self.app_builder.sm.auth_role_admin,
                ]
            ).delete()

            path = os.path.join(tmp_dir, "roles.json")

            with open(path, "w") as fd:
                fd.write(json.dumps(self.expected_roles))

            # before import roles on dst app include only Admin and Public
            self.assertEqual(len(self.app_builder.sm.get_all_roles()), 2)

            import_result = self.cli_runner.invoke(import_roles, [f"--path={path}"])
            self.assertEqual(import_result.exit_code, 0)

            resulting_roles = self.app_builder.sm.get_all_roles()

            for expected_role in self.expected_roles:
                match = [r for r in resulting_roles if r.name == expected_role["name"]]
                self.assertTrue(match)
                resulting_role = match[0]

                expected_role_permission_view_menus = {
                    (pvm["permission"]["name"], pvm["view_menu"]["name"])
                    for pvm in expected_role["permissions"]
                }
                resulting_role_permission_view_menus = {
                    (pvm.permission.name, pvm.view_menu.name)
                    for pvm in resulting_role.permissions
                }
                self.assertTrue(
                    all(
                        [
                            pvm in resulting_role_permission_view_menus
                            for pvm in expected_role_permission_view_menus
                        ]
                    )
                )
