"""Handles interactions with AWS APIs.

Not to be handled directly.
"""

import logging
import os
from datetime import datetime, timezone
from functools import cached_property
from typing import ClassVar, Union

import boto3
import botocore
from botocore.exceptions import ClientError

from ssmbak.typing import Version

logger = logging.getLogger(__name__)


class Resource:
    """Parent to actions.Path.

    Interface between what's in SSM now and corresponding s3 backups
    from the Lambda function. The region for SSM params and bucket
    access need to be the same.

    Attributes:
      region: The AWS region for params and bucket access.
      bucketname: The same bucket that the lambda writes to.
      _CALLS: class attribute strictly for testing efficiency of AWS calls
    """

    _CALLS: ClassVar[dict[str, int]] = {"tags": 0, "versions": 0, "version_objects": 0}

    def __init__(self, region, bucketname):
        self.region = region
        self.bucketname = bucketname

    @classmethod
    def clear_call_cache(cls) -> None:
        """Reset call counts between tests."""
        cls._CALLS = {"tags": 0, "versions": 0, "version_objects": 0}

    @classmethod
    def get_calls(cls) -> dict[str, int]:
        """Access call counts from tests."""
        return cls._CALLS

    @cached_property
    def s3(self) -> boto3.client:
        """boto3 s3 client. There should only be one."""
        return boto3.client(
            "s3", endpoint_url=os.getenv("AWS_ENDPOINT"), region_name=self.region
        )

    @cached_property
    def s3res(self) -> boto3.client:
        """boto3 s3 resource for backup contents. There should only be one."""
        return boto3.resource(
            "s3", endpoint_url=os.getenv("AWS_ENDPOINT"), region_name=self.region
        )

    @cached_property
    def ssm(self) -> boto3.client:
        """boto3 ssm client. There should only be one."""
        return boto3.client(
            "ssm", endpoint_url=os.getenv("AWS_ENDPOINT"), region_name=self.region
        )

    def _key_versions(self, versions: list[dict]) -> dict[str, Version]:
        """Turns a list of dicts into a dict of dicts keyed by s3/param key.

        Arguments:
          versions: un-keyed list of versions as returned by AWS
        """
        keyed_versions = {}
        for name in {x["Key"] for x in versions}:
            key_versions = [x for x in versions if x["Key"] == name]
            keyed_versions[name] = key_versions[0]
        return keyed_versions

    def _tagtime(self, version: dict) -> datetime:
        """Extracts datetime from the backup version.

        Corresponds to the time of the event and not necessary when it
        was backed-up, e.g. if there was some failures that held event
        processing up in a queue.

        In the case of deleted versions, no tags can be written so we
        just use the time of backup (LastModified).

        Arguments:
          version: dict of s3 version with processed tagset.
        """
        try:
            ssmbak_time = datetime.fromtimestamp(int(version["tagset"]["ssmbakTime"]))
            tagtime = ssmbak_time.astimezone(timezone.utc)
        except KeyError:
            tagtime = version["LastModified"]
        return tagtime

    def _get_tagset(self, name: str, versionid: str) -> dict[str, str]:
        """Get the tagset from S3 for the object version, using time
        of original event not backup.

        Arguments:
          name: name of the s3 object/ssm param in question
          versionid: s3 object versionid

        Returns:
        {
            "ssmbakTime": "1659560971",
            "ssmbakType": "SecureString",
            "ssmbakDescription": "fancy description", --OPTIONAL
        }
        """
        try:
            logger.debug("actually getting tagset for %s %s", name, versionid)
            Resource._CALLS["tags"] += 1
            tagset = self.s3.get_object_tagging(
                Bucket=self.bucketname, Key=name, VersionId=versionid
            )["TagSet"]
            nice_tagset = {x["Key"]: x["Value"] for x in tagset}
        except ClientError as e:
            if e.response["Error"]["Code"] in ["MethodNotAllowed"]:
                nice_tagset = {}
            else:
                raise e
        return nice_tagset

    def _make_ssm_kwargs(self, param: dict) -> dict[str, Union[str, bool]]:
        """Preps the kwargs for boto3 client ssm.put_parameter().

        Arguments:
          param: dict as generated by preview

        Returns:
        {
            "Name": "/testyssmbak/AP66LQ",
            "Value": "RUL38Y",
            "Type": "SecureString",
            "Overwrite": True,
            "Description": "fancy description",
        }
        """
        kwargs = {
            "Name": param["Name"],
            "Value": param["Value"],
            "Type": param["Type"],
            "Overwrite": True,
        }
        if "Description" in param:
            kwargs["Description"] = param["Description"]
        return kwargs

    def _restore_preview(self, param: dict) -> None:
        """Sets the ssm param to the desired state.

        Arguments:
          param: dict as generated by preview
        """
        if "Deleted" in param and param["Deleted"]:
            self.ssm.delete_parameter(Name=param["Name"])
        else:
            ssm_kwargs = self._make_ssm_kwargs(param)
            self.ssm.put_parameter(**ssm_kwargs)

    def _ssm_del_multi(self, names: list) -> None:
        """Delete SSM Params efficiently"""
        batch_size = 10
        chunks = [names[x : x + batch_size] for x in range(0, len(names), batch_size)]
        for chunk in chunks:
            logger.debug("deleting %s", chunk)
            self.ssm.delete_parameters(Names=chunk)

    def _ssmgetpath(self, path: str, recurse=False) -> dict[str, Version]:
        """Gets params currently in place.

        Needed to determine what to delete when _getting_versions.

        Arguments:
          path: can correspond to just one key
          recurse: A boolean to operate on all paths/keys under path/

        Returns:
          The same format used everywhere, keyed by param name.

          {
              "/testyssmbak/82P11M": {
                  "Name": "/testyssmbak/82P11M",
                  "Type": "String",
                  "Value": "ES9IT7",
                  "Version": 2,
                  "LastModifiedDate": datetime.datetime(
                      2024, 6, 9, 9, 37, 34, 202000, tzinfo=tzlocal()
                  ),
                  "ARN": "arn:aws:ssm:us-west-2:000000000000:parameter/testyssmbak/82P11M",
                  "DataType": "text",
              },
          }
        """
        paginator = self.ssm.get_paginator("get_parameters_by_path")
        paginated = paginator.paginate(
            Path=path, Recursive=recurse, WithDecryption=True
        )
        result = paginated.build_full_result()
        keyed_params = {}
        if result["Parameters"]:
            params = result["Parameters"]
        elif not path.endswith("/"):
            try:
                param = self.ssm.get_parameter(Name=path, WithDecryption=True)[
                    "Parameter"
                ]
                params = [param]
            except KeyError:  # nothing found
                params = []
        else:
            params = []
        for name in {x["Name"] for x in params}:
            keyed_params[name] = [x for x in params if x["Name"] == name][0]
        return keyed_params

    def _get_object_versions(self, key: str) -> botocore.paginate.PageIterator:
        """Get a versions iterator from AWS

        Arguments:
          key: a single s3 key

        They come a thousand at a time.
        """
        logger.debug("actually getting versions for %s", key)
        paginator = self.s3.get_paginator("list_object_versions")
        # they come back most recent (LastModified) first
        Resource._CALLS["versions"] += 1
        return paginator.paginate(Bucket=self.bucketname, Prefix=key)

    def _get_versions(
        self, key: str, checktime: datetime, recurse: bool = False
    ) -> dict[str, Version]:
        """Efficiently looks for the version most recently backed-up before checktime.

        The objects come from AWS a thousand at a time, but only with
        modified times corresponding to when they were backed-up (LastModified) and
        not when the original event was reported. Typically it's less
        than a minute, but in the case of an outage it might be
        longer. To be safe, we check the event times, encoded in s3
        tags by the Lambda (ssmbakTime).

        Arguments:
          key: a single s3 key or path
          checktime: the point in time for which to retrieve relative latest version
          recurse: operate on all paths/keys under key/

        Returns:
          The same keyed versions as everywhere.

          {
              "/testyssmbak/88JCRX": {
                  "ETag": '"9d2f3ea8da7b4feba87aeb4da1fcb5e0"',
                  "Size": 6,
                  "StorageClass": "STANDARD",
                  "Key": "/testyssmbak/88JCRX",
                  "VersionId": "vuyAs6cfwwSbMUi4o8O1qA",
                  "IsLatest": True,
                  "LastModified": datetime.datetime(
                      2024, 6, 9, 16, 45, 4, tzinfo=tzutc()
                  ),
                  "Owner": {
                      "DisplayName": "webfile",
                      "ID": "75aaa08ebf849d0f8e7faeebf76c078efc7c6caea54ba06a",
                  },
                  "tagset": {"ssmbakTime": "1717951504", "ssmbakType": "SecureString"},
              },
          }
        """
        versions = []
        paginated = self._get_object_versions(key)
        there_nows = self._ssmgetpath(key, recurse=recurse)
        for param_page in paginated:
            to_extend = []
            try:
                for deleted_version in param_page["DeleteMarkers"]:
                    # only need to delete if it's there now
                    if deleted_version["Key"] in there_nows:
                        deleted_version["Deleted"] = True
                        to_extend.append(deleted_version)
            except KeyError:
                logger.debug("no delete markers")
            try:
                to_extend.extend(param_page["Versions"])
            except KeyError:
                logger.debug("no versions")
            if not recurse or not key.endswith("/"):
                if key in [x["Key"] for x in to_extend + versions]:
                    to_extend = [x for x in to_extend if x["Key"] == key]
                else:
                    n = key.count("/")
                    to_extend = [x for x in to_extend if x["Key"].count("/") == n]
            for version in to_extend:
                if version["Key"] not in [x["Key"] for x in versions]:
                    version["tagset"] = self._get_tagset(
                        version["Key"], version["VersionId"]
                    )
                    if self._tagtime(version) <= checktime:
                        versions.append(version)
        return self._key_versions(versions)

    def _get_version_body(self, name: str, versionid: str) -> str:
        """Uses s3 object resource to get the contents of the version.

        Should only be run after all last versions are got.

        Arguments:
          name: single s3 key
          versionid: s3 object versionid

        Returns:
          String of the backed-up ssm paramter's value.
        """
        Resource._CALLS["version_objects"] += 1
        logger.debug("actually getting contents for %s", name)
        version = self.s3res.ObjectVersion(self.bucketname, name, versionid)
        try:
            res = version.get()  # boto3 issue #832
            body = self._get_contents(res)
        except ClientError as e:
            # NoSuchVersion to accommodate localstack
            if e.response["Error"]["Code"] in ["MethodNotAllowed", "NoSuchVersion"]:
                body = ""
            else:
                raise e
        return body

    def _get_contents(self, version: dict) -> str:
        """Reads and decodes the s3 object's body."""
        stuff = ""
        try:
            stuff = version["Body"].read().decode("utf-8").strip()
        except ClientError as e:
            if e.response["Error"]["Code"] == "MethodNotAllowed":
                # it was deleted
                pass
        return stuff
