# Copyright 2023 Northern.tech AS
#
#    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.
import json
import pytest
import random
import time
import string
import tempfile
import uuid
import os
import subprocess

from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Generator, List


import docker
import redo
import requests

from redo import retrier

import testutils.api.deviceauth as deviceauth
import testutils.api.inventory as inventory
import testutils.api.tenantadm as tenantadm
import testutils.api.useradm as useradm
import testutils.util.crypto
from testutils.api.client import ApiClient, GATEWAY_HOSTNAME
from testutils.infra.container_manager.kubernetes_manager import isK8S
from testutils.infra.mongo import MongoClient
from testutils.infra.cli import CliUseradm, CliTenantadm
from testutils.infra.device import MenderDevice, MenderDeviceGroup


class User:
    def __init__(self, id, name, pwd, roles=[]):
        self.name = name
        self.pwd = pwd
        self.id = id
        self.token = None
        self.roles = roles


class Authset:
    def __init__(self, id, did, id_data, pubkey, privkey, status):
        self.id = id
        self.did = did
        self.id_data = id_data
        self.pubkey = pubkey
        self.privkey = privkey
        self.status = status


class Device:
    def __init__(self, id, id_data, pubkey, tenant_token="", status="", privkey=""):
        self.id = id
        self.id_data = id_data
        self.pubkey = pubkey
        self.privkey = privkey
        self.tenant_token = tenant_token
        self.authsets = []
        self.token = None
        self.status = status

    def send_inventory(self):
        invd = ApiClient(inventory.URL_DEV)
        r = invd.with_auth(self.token).call(
            "PATCH", inventory.URL_DEVICE_ATTRIBUTES, self.inventory
        )
        assert r.status_code == 200

    def send_auth_request(self):
        devauthd = ApiClient(deviceauth.URL_DEVICES)
        body, sighdr = deviceauth.auth_req(
            self.id_data, self.pubkey, self.privkey, self.tenant_token
        )
        r = devauthd.call("POST", deviceauth.URL_AUTH_REQS, body, headers=sighdr)
        return r


class Tenant:
    def __init__(self, name, id, token):
        self.name = name
        self.users = []
        self.devices = []
        self.id = id
        self.tenant_token = token


def create_random_authset(dauthd1, dauthm, utoken, tenant_token=""):
    """create_device with random id data and keypair"""
    priv, pub = testutils.util.crypto.get_keypair_rsa()
    mac = ":".join(["{:02x}".format(random.randint(0x00, 0xFF), "x") for i in range(6)])
    id_data = {"mac": mac}

    return create_authset(dauthd1, dauthm, id_data, pub, priv, utoken, tenant_token)


def create_authset(
    dauthd1, dauthm, id_data, pubkey, privkey, utoken, tenant_token=""
) -> Authset:
    body, sighdr = deviceauth.auth_req(id_data, pubkey, privkey, tenant_token)

    # submit auth req
    r = dauthd1.call("POST", deviceauth.URL_AUTH_REQS, body, headers=sighdr)
    assert r.status_code == 401, r.text

    # dev must exist and have *this* aset
    api_dev = get_device_by_id_data(dauthm, id_data, utoken)
    assert api_dev is not None

    aset = [
        a
        for a in api_dev["auth_sets"]
        if testutils.util.crypto.compare_keys(a["pubkey"], pubkey)
    ]
    assert len(aset) == 1, str(aset)

    aset = aset[0]

    assert aset["identity_data"] == id_data
    assert aset["status"] == "pending"

    return Authset(aset["id"], api_dev["id"], id_data, pubkey, privkey, "pending")


def create_user(
    name: str,
    pwd: str,
    tid: str = "",
    containers_namespace: str = "backend-tests",
    roles: list = [],
) -> User:
    cli = CliUseradm(containers_namespace)
    uid = cli.create_user(name, pwd, tid, roles=roles)

    return User(uid, name, pwd)


def create_org(
    name: str,
    username: str,
    password: str,
    plan: str = "os",
    addons: List[str] = [],
    containers_namespace: str = "backend-tests",
    container_manager=None,
) -> Tenant:
    cli = CliTenantadm(
        containers_namespace=containers_namespace, container_manager=container_manager
    )
    user_id = None
    tenant_id = cli.create_org(name, username, password, plan=plan, addons=addons)
    tenant_token = json.loads(cli.get_tenant(tenant_id))["tenant_token"]

    host = GATEWAY_HOSTNAME
    if container_manager is not None:
        host = container_manager.get_mender_gateway()
    api = ApiClient(useradm.URL_MGMT, host=host)

    # Try log in every second for 2 minutes.
    # - There usually is a slight delay (in order of ms) for propagating
    #   the created user to the db.
    for _ in retrier(attempts=120, sleepscale=1, sleeptime=1):
        rsp = api.call("POST", useradm.URL_LOGIN, auth=(username, password))
        if rsp.status_code == 200:
            break

    assert (
        rsp.status_code == 200
    ), "User could not log in within two minutes after organization has been created."

    user_token = rsp.text
    rsp = api.with_auth(user_token).call("GET", useradm.URL_USERS)
    users = json.loads(rsp.text)
    for user in users:
        if user["email"] == username:
            user_id = user["id"]
            break
    if user_id is None:
        raise ValueError("Error retrieving user id.")

    tenant = Tenant(name, tenant_id, tenant_token)
    user = User(user_id, username, password)
    user.token = user_token
    tenant.users.append(user)
    return tenant


def get_device_by_id_data(dauthm, id_data, utoken):
    page = 0
    per_page = 20
    qs_params = {}
    found = None
    while True:
        page = page + 1
        qs_params["page"] = page
        qs_params["per_page"] = per_page
        r = dauthm.with_auth(utoken).call(
            "GET", deviceauth.URL_MGMT_DEVICES, qs_params=qs_params
        )
        assert r.status_code == 200
        api_devs = r.json()

        found = [d for d in api_devs if d["identity_data"] == id_data]
        if len(found) > 0:
            break

        if len(api_devs) == 0:
            break

    assert len(found) == 1, "device not found by id data"

    return found[0]


def change_authset_status(dauthm, did, aid, status, utoken):
    r = dauthm.with_auth(utoken).call(
        "PUT",
        deviceauth.URL_AUTHSET_STATUS,
        deviceauth.req_status(status),
        path_params={"did": did, "aid": aid},
    )
    assert r.status_code == 204


def rand_id_data():
    mac = ":".join(["{:02x}".format(random.randint(0x00, 0xFF), "x") for i in range(6)])
    sn = "".join(["{}".format(random.randint(0x00, 0xFF)) for i in range(6)])

    return {"mac": mac, "sn": sn}


def make_pending_device(
    dauthd1: ApiClient, dauthm: ApiClient, utoken: str, tenant_token: str = ""
) -> Device:
    """Create one device with "pending" status."""
    id_data = rand_id_data()

    priv, pub = testutils.util.crypto.get_keypair_rsa()
    new_set = create_authset(
        dauthd1, dauthm, id_data, pub, priv, utoken, tenant_token=tenant_token
    )

    dev = Device(new_set.did, new_set.id_data, pub, tenant_token, privkey=priv)

    dev.authsets.append(new_set)

    dev.status = "pending"

    return dev


def make_accepted_device(
    dauthd1: ApiClient,
    dauthm: ApiClient,
    utoken: str,
    tenant_token: str = "",
    test_type: str = "regular",
) -> Device:
    """Create one device with "accepted" status."""
    test_types = ["regular", "azure", "aws"]
    if test_type not in test_types:
        raise RuntimeError("Given test type is not allowed")
    dev = make_pending_device(dauthd1, dauthm, utoken, tenant_token=tenant_token)
    aset_id = dev.authsets[0].id
    change_authset_status(dauthm, dev.id, aset_id, "accepted", utoken)
    aset = dev.authsets[0]
    aset.status = "accepted"

    # TODO: very bad workaround for Azure IoT Hub backend test; following part is responsible for creating
    # TODO: additonal, unnecessary auth set which causes Azure test to fail
    if test_type == "regular":
        # obtain auth token
        body, sighdr = deviceauth.auth_req(
            aset.id_data, aset.pubkey, aset.privkey, tenant_token
        )
        r = dauthd1.call("POST", deviceauth.URL_AUTH_REQS, body, headers=sighdr)
        assert r.status_code == 200
        dev.token = r.text

    dev.status = "accepted"

    return dev


def decommission_device(
    utoken: str,
    device_id: str,
):
    """Delete one device with "accepted" status."""
    devauthm = ApiClient(deviceauth.URL_MGMT)
    r = devauthm.with_auth(utoken).call(
        "DELETE",
        deviceauth.URL_DEVICE,
        path_params={"id": device_id},
    )
    assert r.status_code == 204


def make_accepted_devices(devauthd, devauthm, utoken, tenant_token="", num_devices=1):
    """Create accepted devices.
    returns list of Device objects."""
    devices = []

    # some 'accepted' devices, single authset
    for _ in range(num_devices):
        dev = make_accepted_device(devauthd, devauthm, utoken, tenant_token)
        devices.append(dev)

    return devices


def make_device_with_inventory(attributes, utoken, tenant_token):
    devauthm = ApiClient(deviceauth.URL_MGMT)
    devauthd = ApiClient(deviceauth.URL_DEVICES)
    invm = ApiClient(inventory.URL_MGMT)

    d = make_accepted_device(devauthd, devauthm, utoken, tenant_token)
    """
    verify that the status of the device in inventory is "accepted"
    """
    accepted = False
    timeout = 10
    for i in range(timeout):
        r = invm.with_auth(utoken).call("GET", inventory.URL_DEVICE.format(id=d.id))
        if r.status_code == 200:
            dj = r.json()
            for attr in dj["attributes"]:
                if attr["name"] == "status" and attr["value"] == "accepted":
                    accepted = True
                    break
        if accepted:
            break
        time.sleep(1)
    if not accepted:
        raise ValueError(
            "status for device %s has not been propagated within %d seconds"
            % (d.id, timeout)
        )

    submit_inventory(attributes, d.token)

    d.attributes = attributes

    return d


def submit_inventory(attrs, token):
    invd = ApiClient(inventory.URL_DEV)
    r = invd.with_auth(token).call("PATCH", inventory.URL_DEVICE_ATTRIBUTES, attrs)
    assert r.status_code == 200


@contextmanager
def get_mender_artifact(
    artifact_name="test",
    update_module="dummy",
    device_types=("arm1",),
    size=256,
    depends=(),
    provides=(),
):
    data = "".join(random.choices(string.ascii_uppercase + string.digits, k=size))
    f = tempfile.NamedTemporaryFile(delete=False)
    f.write(data.encode("utf-8"))
    f.close()
    #
    filename = f.name
    artifact = "%s.mender" % filename
    args = [
        "mender-artifact",
        "write",
        "module-image",
        "-o",
        artifact,
        "--artifact-name",
        artifact_name,
        "-T",
        update_module,
        "-f",
        filename,
    ]
    for device_type in device_types:
        args.extend(["-t", device_type])
    for depend in depends:
        args.extend(["--depends", depend])
    for provide in provides:
        args.extend(["--provides", provide])
    try:
        subprocess.call(args)
        yield artifact
    finally:
        os.unlink(filename)
        os.path.exists(artifact) and os.unlink(artifact)


def wait_until_healthy(compose_project: str = "", timeout: int = 60):
    """
    wait_until_healthy polls all running containers health check
    endpoints until they return a non-error status code.
    :param compose_project: the docker-compose project ID, if empty it
                            checks all running containers.
    :param timeout: timeout in seconds.
    """
    client = docker.from_env()
    kwargs = {}
    if compose_project != "":
        kwargs["filters"] = {"label": f"com.docker.compose.project={compose_project}"}

    path_map = {
        "mender-auditlogs": "/api/internal/v1/auditlogs/health",
        "mender-deviceconnect": "/api/internal/v1/deviceconnect/health",
        "mender-deviceconfig": "/api/internal/v1/deviceconfig/health",
        "mender-deviceauth": "/api/internal/v1/devauth/health",
        "mender-deployments": "/api/internal/v1/deployments/health",
        "mender-inventory": "/api/internal/v1/inventory/health",
        "mender-tenantadm": "/api/internal/v1/tenantadm/health",
        "mender-useradm": "/api/internal/v1/useradm/health",
        "mender-workflows": "/api/v1/health",
        "s3": "/status",
    }

    containers = client.containers.list(all=True, **kwargs)
    for container in containers:

        container_ip = None
        for _, net in container.attrs["NetworkSettings"]["Networks"].items():
            container_ip = net["IPAddress"]
            break
        if container_ip is None or container_ip == "":
            continue

        service = container.labels.get(
            "com.docker.compose.service", container.name
        ).split("-enterprise")[0]
        if service.startswith("mender-workflows-server"):
            service = "mender-workflows"

        path = path_map.get(service)
        if path is None:
            continue
        port = 8080

        for _ in redo.retrier(attempts=timeout, sleeptime=1):
            try:
                rsp = requests.request("GET", f"http://{container_ip}:{port}{path}")
            except requests.exceptions.ConnectionError:
                # A ConnectionError is expected if the service is not running yet
                continue
            if rsp.status_code < 300:
                break
        else:
            raise TimeoutError(
                f"Timed out waiting for service '{service}' to become healthy"
            )


def update_tenant(tid, addons=None, plan=None, container_manager=None):
    """Call internal PUT tenantadm/tenants/{tid}"""
    update = {}
    if addons is not None:
        update["addons"] = tenantadm.make_addons(addons)

    if plan is not None:
        update["plan"] = plan

    tenantadm_host = (
        tenantadm.HOST
        if isK8S() or container_manager is None
        else container_manager.get_ip_of_service("mender-tenantadm")[0] + ":8080"
    )
    tadm = ApiClient(tenantadm.URL_INTERNAL, host=tenantadm_host, schema="http://")
    res = tadm.call(
        "PUT",
        tenantadm.URL_INTERNAL_TENANT,
        body=update,
        path_params={"tid": tid},
    )
    assert res.status_code == 202


def new_tenant_client(
    test_env, name: str, tenant: str, docker: bool = False, network: str = "mender"
) -> MenderDevice:
    """Create new Mender client in the test environment with the given name for the given tenant.

    The passed test_env must implement new_tenant_client and/or new_tenant_docker_client.

    This helper attaches the recently created Mender client to the test environment, so that systemd
    logs can be printed on test failures.
    """

    pre_existing_clients = set(test_env.get_mender_clients(network=network))
    if docker:
        test_env.new_tenant_docker_client(name, tenant)
    else:
        test_env.new_tenant_client(name, tenant)
    all_clients = set(test_env.get_mender_clients(network=network))
    new_client = all_clients - pre_existing_clients
    assert len(new_client) == 1
    device = MenderDevice(new_client.pop())
    if hasattr(test_env, "device_group"):
        test_env.device_group.append(device)
    else:
        test_env.device = device
        test_env.device_group = MenderDeviceGroup(
            test_env.get_mender_clients(network=network)
        )
    return device


def create_tenant_test_setup() -> Tenant:
    """Creates a tenant and a user belonging to the tenant (both tenant and user are created with random names)."""
    uuidv4 = str(uuid.uuid4())
    tenant, username, password = (
        "test.mender.io-" + uuidv4,
        "some.user+" + uuidv4 + "@example.com",
        "secretsecret",
    )
    tenant = create_org(tenant, username, password, "enterprise")
    user = create_user(
        "foo+" + uuidv4 + "@user.com", "correcthorsebatterystaple", tid=tenant.id
    )

    response = ApiClient(useradm.URL_MGMT).call(
        "POST", useradm.URL_LOGIN, auth=(user.name, user.pwd)
    )
    assert response.status_code == 200
    user.utoken = response.text
    tenant.users = [user]
    return tenant


def create_user_test_setup() -> User:
    """Create a user with random name, log user in."""
    uuidv4 = str(uuid.uuid4())
    user_name, password = (
        "some.user+" + uuidv4 + "@example.com",
        "secretsecret",
    )

    user = create_user(user_name, password)
    useradmm = ApiClient(useradm.URL_MGMT)
    # log in user
    response = useradmm.call("POST", useradm.URL_LOGIN, auth=(user.name, user.pwd))
    assert response.status_code == 200
    user.utoken = response.text
    return user


def useExistingTenant() -> bool:
    return bool(os.environ.get("USE_EXISTING_TENANT"))


# to check that it was called at least once:
# curl -v -X PUT "http://httpd:1080/mockserver/verify" -d '{ "httpRequest": {"method":"POST","path":"/webhook/inventory/8700E8AD-1277-4982-B06C-9B6048ED1A75"}, "times":{"atLeast":1} }'
# to set the expected request: the following allows all the calls
# curl -v -X PUT "http://httpd:1080/mockserver/expectation" -d '{"httpResponse":{"body":"ok"}}'
# to get all the requests:
# curl -v -X PUT "http://localhost:32768/mockserver/retrieve?type=REQUESTS&format=JSON" -d '{"path": "/webhook/called"}'
class MockedHttp:
    def __init__(self, url: string = "http://mock-httpd:1080"):
        self.url = url
        # this will allow all the requests and accept them with 'ok' response
        r = requests.put(
            url + "/mockserver/expectation", json={"httpResponse": {"body": "ok"}}
        )
        assert r.status_code == 201

    def request_seen(self, path, method="POST"):
        r = requests.put(
            self.url + "/mockserver/verify",
            json={
                "httpRequest": {"method": method, "path": path},
                "times": {"atLeast": 1},
            },
        )
        return r.status_code == 202

    def request_get_all(self, path):
        r = requests.put(
            self.url + "/mockserver/retrieve?type=REQUESTS&format=JSON",
            json={
                "path": path,
            },
        )
        all_requests_recorded = []
        if r.status_code != 200:
            return all_requests_recorded
        for i in r.json():
            all_requests_recorded.append(i["body"]["json"])
        return all_requests_recorded


def retry(
    period: timedelta = timedelta(milliseconds=100),
    timeout: timedelta = timedelta(minutes=2),
) -> Generator[datetime, None, None]:
    if timeout < period:
        raise ValueError(f"retry period {period} is larger than timeout {timeout}")

    now = datetime.now()
    last_attempt = now + timeout
    yield now
    while now <= last_attempt:
        delay = min(period, last_attempt - now)
        time.sleep(delay.total_seconds())
        now = datetime.now()
        yield now
