import copy
import warnings
from collections import OrderedDict
from datetime import datetime
from typing import Any, Dict, ItemsView, List, Tuple, Optional, Set
from moto import settings

from moto.core import CloudFormationModel
from moto.core.utils import camelcase_to_underscores
from moto.ec2.models.fleets import Fleet
from moto.ec2.models.elastic_network_interfaces import NetworkInterface
from moto.ec2.models.launch_templates import LaunchTemplateVersion
from moto.ec2.models.instance_types import (
    INSTANCE_TYPE_OFFERINGS,
    InstanceTypeOfferingBackend,
)
from moto.ec2.models.security_groups import SecurityGroup
from moto.ec2.models.subnets import Subnet
from moto.packages.boto.ec2.blockdevicemapping import BlockDeviceMapping
from moto.packages.boto.ec2.instance import Instance as BotoInstance
from moto.packages.boto.ec2.instance import Reservation

from ..exceptions import (
    AvailabilityZoneNotFromRegionError,
    InvalidInstanceIdError,
    InvalidInstanceTypeError,
    InvalidParameterCombination,
    InvalidParameterValueErrorUnknownAttribute,
    InvalidSecurityGroupNotFoundError,
    OperationNotPermitted4,
)
from ..utils import (
    convert_tag_spec,
    filter_reservations,
    random_eni_attach_id,
    random_instance_id,
    random_private_ip,
    random_reservation_id,
    utc_date_and_time,
)
from .core import TaggedEC2Resource


class InstanceState:
    def __init__(self, name: str = "pending", code: int = 0):
        self.name = name
        self.code = code


class StateReason:
    def __init__(self, message: str = "", code: str = ""):
        self.message = message
        self.code = code


class Instance(TaggedEC2Resource, BotoInstance, CloudFormationModel):
    VALID_ATTRIBUTES = {
        "instanceType",
        "kernel",
        "ramdisk",
        "userData",
        "disableApiTermination",
        "instanceInitiatedShutdownBehavior",
        "rootDeviceName",
        "blockDeviceMapping",
        "productCodes",
        "sourceDestCheck",
        "groupSet",
        "ebsOptimized",
        "sriovNetSupport",
        "disableApiStop",
    }

    def __init__(
        self,
        ec2_backend: Any,
        image_id: str,
        user_data: Any,
        security_groups: List[SecurityGroup],
        **kwargs: Any,
    ):
        super().__init__()
        self.ec2_backend = ec2_backend
        self.id = random_instance_id()
        self.owner_id = ec2_backend.account_id
        self.lifecycle: Optional[str] = kwargs.get("lifecycle")

        nics = copy.deepcopy(kwargs.get("nics", []))

        launch_template_arg = kwargs.get("launch_template", {})
        if launch_template_arg and not image_id:
            # the image id from the template should be used
            template_version = ec2_backend._get_template_from_args(launch_template_arg)
            self.image_id = template_version.image_id
        else:
            self.image_id = image_id  # type: ignore
        # Check if we have tags to process
        if launch_template_arg:
            template_version = ec2_backend._get_template_from_args(launch_template_arg)
            tag_spec_set = template_version.data.get("TagSpecification", {})
            tags = convert_tag_spec(tag_spec_set)
            instance_tags = tags.get("instance", {})
            self.add_tags(instance_tags)

        self._state = InstanceState("running", 16)
        self._reason = ""
        self._state_reason = StateReason()
        self.user_data = user_data
        self.security_groups = security_groups
        self.instance_type: str = kwargs.get("instance_type", "m1.small")
        self.region_name = kwargs.get("region_name", "us-east-1")
        placement = kwargs.get("placement", None)
        self.subnet_id = kwargs.get("subnet_id")
        if not self.subnet_id:
            self.subnet_id = next(
                (n["SubnetId"] for n in nics if "SubnetId" in n), None
            )
        in_ec2_classic = not bool(self.subnet_id)
        self.key_name = kwargs.get("key_name")
        self.ebs_optimized = kwargs.get("ebs_optimized", False)
        self.source_dest_check = "true"
        self.launch_time = utc_date_and_time()
        self.ami_launch_index = kwargs.get("ami_launch_index", 0)
        self.disable_api_termination = kwargs.get("disable_api_termination", False)
        self.instance_initiated_shutdown_behavior = (
            kwargs.get("instance_initiated_shutdown_behavior") or "stop"
        )
        self.hibernation_options = kwargs.get("hibernation_options")
        self.sriov_net_support = "simple"
        self._spot_fleet_id = kwargs.get("spot_fleet_id", None)
        self._fleet_id = kwargs.get("fleet_id", None)
        self.associate_public_ip = kwargs.get("associate_public_ip", False)
        if in_ec2_classic:
            # If we are in EC2-Classic, autoassign a public IP
            self.associate_public_ip = True

        amis = self.ec2_backend.describe_images(filters={"image-id": self.image_id})
        ami = amis[0] if amis else None
        if ami is None:
            warnings.warn(
                f"Could not find AMI with image-id:{self.image_id}, "
                "in the near future this will "
                "cause an error.\n"
                "Use ec2_backend.describe_images() to "
                "find suitable image for your test",
                PendingDeprecationWarning,
            )

        self.platform = ami.platform if ami else None
        self.virtualization_type = ami.virtualization_type if ami else "paravirtual"
        self.architecture = ami.architecture if ami else "x86_64"
        self.root_device_name = ami.root_device_name if ami else None
        self.disable_api_stop = False
        self.iam_instance_profile = kwargs.get("iam_instance_profile")

        # handle weird bug around user_data -- something grabs the repr(), so
        # it must be clean
        if isinstance(self.user_data, list) and len(self.user_data) > 0:
            if isinstance(self.user_data[0], bytes):
                # string will have a "b" prefix -- need to get rid of it
                self.user_data[0] = self.user_data[0].decode("utf-8")

        if self.subnet_id:
            subnet: Subnet = ec2_backend.get_subnet(self.subnet_id)
            self._placement.zone = subnet.availability_zone

            if self.associate_public_ip is None:
                # Mapping public ip hasnt been explicitly enabled or disabled
                self.associate_public_ip = subnet.map_public_ip_on_launch == "true"
        elif placement:
            self._placement.zone = placement
        else:
            self._placement.zone = ec2_backend.region_name + "a"

        self.block_device_mapping: BlockDeviceMapping = BlockDeviceMapping()

        self._private_ips: Set[str] = set()
        self.prep_nics(
            nics,
            private_ip=kwargs.get("private_ip"),
            associate_public_ip=self.associate_public_ip,
            security_groups=self.security_groups,
        )

    @property
    def vpc_id(self) -> Optional[str]:
        if self.subnet_id:
            subnet: Subnet = self.ec2_backend.get_subnet(self.subnet_id)
            return subnet.vpc_id
        if self.nics and 0 in self.nics:
            return self.nics[0].subnet.vpc_id
        return None

    def __del__(self) -> None:
        try:
            subnet: Subnet = self.ec2_backend.get_subnet(self.subnet_id)
            for ip in self._private_ips:
                subnet.del_subnet_ip(ip)
        except Exception:
            # Its not "super" critical we clean this up, as reset will do this
            # worst case we'll get IP address exaustion... rarely
            pass

    def add_block_device(
        self,
        size: int,
        device_path: str,
        snapshot_id: Optional[str],
        encrypted: bool,
        delete_on_termination: bool,
        kms_key_id: Optional[str],
        volume_type: Optional[str],
    ) -> None:
        volume = self.ec2_backend.create_volume(  # type: ignore[attr-defined]
            size=size,
            zone_name=self._placement.zone,
            snapshot_id=snapshot_id,
            encrypted=encrypted,
            kms_key_id=kms_key_id,
            volume_type=volume_type,
        )
        self.ec2_backend.attach_volume(
            volume.id, self.id, device_path, delete_on_termination
        )

    def setup_defaults(self) -> None:
        # Default have an instance with root volume should you not wish to
        # override with attach volume cmd.
        volume = self.ec2_backend.create_volume(size=8, zone_name=self._placement.zone)
        self.ec2_backend.attach_volume(volume.id, self.id, "/dev/sda1", True)

    def teardown_defaults(self) -> None:
        for device_path in list(self.block_device_mapping.keys()):
            volume = self.block_device_mapping[device_path]
            volume_id = volume.volume_id
            self.ec2_backend.detach_volume(volume_id, self.id, device_path)
            if volume.delete_on_termination:
                self.ec2_backend.delete_volume(volume_id)

    @property
    def get_block_device_mapping(self) -> ItemsView[str, Any]:  # type: ignore[misc]
        return self.block_device_mapping.items()

    @property
    def private_ip(self) -> Optional[str]:
        return self.nics[0].private_ip_address

    @property
    def private_dns(self) -> str:
        formatted_ip = self.private_ip.replace(".", "-")  # type: ignore[union-attr]
        if self.region_name == "us-east-1":
            return f"ip-{formatted_ip}.ec2.internal"
        else:
            return f"ip-{formatted_ip}.{self.region_name}.compute.internal"

    @property
    def public_ip(self) -> Optional[str]:
        return self.nics[0].public_ip

    @property
    def public_dns(self) -> Optional[str]:
        if self.public_ip:
            formatted_ip = self.public_ip.replace(".", "-")
            if self.region_name == "us-east-1":
                return f"ec2-{formatted_ip}.compute-1.amazonaws.com"
            else:
                return f"ec2-{formatted_ip}.{self.region_name}.compute.amazonaws.com"
        return None

    @staticmethod
    def cloudformation_name_type() -> str:
        return ""

    @staticmethod
    def cloudformation_type() -> str:
        # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-instance.html
        return "AWS::EC2::Instance"

    @classmethod
    def create_from_cloudformation_json(  # type: ignore[misc]
        cls,
        resource_name: str,
        cloudformation_json: Any,
        account_id: str,
        region_name: str,
        **kwargs: Any,
    ) -> "Instance":
        from ..models import ec2_backends

        properties = cloudformation_json["Properties"]

        ec2_backend = ec2_backends[account_id][region_name]
        security_group_ids = properties.get("SecurityGroups", [])
        group_names = [
            ec2_backend.get_security_group_from_id(group_id).name
            for group_id in security_group_ids
        ]

        reservation = ec2_backend.add_instances(
            image_id=properties["ImageId"],
            user_data=properties.get("UserData"),
            count=1,
            security_group_names=group_names,
            instance_type=properties.get("InstanceType", "m1.small"),
            is_instance_type_default=not properties.get("InstanceType"),
            subnet_id=properties.get("SubnetId"),
            key_name=properties.get("KeyName"),
            private_ip=properties.get("PrivateIpAddress"),
            block_device_mappings=properties.get("BlockDeviceMappings", {}),
        )
        instance = reservation.instances[0]
        for tag in properties.get("Tags", []):
            instance.add_tag(tag["Key"], tag["Value"])

        # Associating iam instance profile.
        # TODO: Don't forget to implement replace_iam_instance_profile_association once update_from_cloudformation_json
        #  for ec2 instance will be implemented.
        if properties.get("IamInstanceProfile"):
            ec2_backend.associate_iam_instance_profile(
                instance_id=instance.id,
                iam_instance_profile_name=properties.get("IamInstanceProfile"),
            )

        return instance

    @classmethod
    def delete_from_cloudformation_json(  # type: ignore[misc]
        cls,
        resource_name: str,
        cloudformation_json: Any,
        account_id: str,
        region_name: str,
    ) -> None:
        from ..models import ec2_backends

        ec2_backend = ec2_backends[account_id][region_name]
        all_instances = ec2_backend.all_instances()

        # the resource_name for instances is the stack name, logical id, and random suffix separated
        # by hyphens.  So to lookup the instances using the 'aws:cloudformation:logical-id' tag, we need to
        # extract the logical-id from the resource_name
        logical_id = resource_name.split("-")[1]

        for instance in all_instances:
            instance_tags = instance.get_tags()
            for tag in instance_tags:
                if (
                    tag["key"] == "aws:cloudformation:logical-id"
                    and tag["value"] == logical_id
                ):
                    instance.delete(account_id, region_name)

    @property
    def physical_resource_id(self) -> str:
        return self.id

    def start(self) -> InstanceState:
        previous_state = copy.copy(self._state)

        for nic in self.nics.values():
            nic.start()

        self._state.name = "running"
        self._state.code = 16

        self._reason = ""
        self._state_reason = StateReason()

        return previous_state

    def stop(self) -> InstanceState:
        previous_state = copy.copy(self._state)

        for nic in self.nics.values():
            nic.stop()

        self._state.name = "stopped"
        self._state.code = 80

        self._reason = (
            f"User initiated ({datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')})"
        )
        self._state_reason = StateReason(
            "Client.UserInitiatedShutdown: User initiated shutdown",
            "Client.UserInitiatedShutdown",
        )

        return previous_state

    def is_running(self) -> bool:
        return self._state.name == "running"

    def delete(
        self, account_id: str, region: str  # pylint: disable=unused-argument
    ) -> None:
        self.terminate()

    def terminate(self) -> InstanceState:
        previous_state = copy.copy(self._state)

        for nic in self.nics.values():
            nic.stop()

        self.teardown_defaults()

        if self._spot_fleet_id or self._fleet_id:
            fleet = self.ec2_backend.get_spot_fleet_request(self._spot_fleet_id)
            if not fleet:
                fleet = self.ec2_backend.get_fleet(
                    self._spot_fleet_id
                ) or self.ec2_backend.get_fleet(self._fleet_id)
            for spec in fleet.launch_specs:
                if (
                    spec.instance_type == self.instance_type
                    and spec.subnet_id == self.subnet_id
                ):
                    break
            fleet.fulfilled_capacity -= spec.weighted_capacity
            fleet.spot_requests = [
                req for req in fleet.spot_requests if req.instance != self
            ]
            if isinstance(fleet, Fleet):
                fleet.on_demand_instances = [
                    inst
                    for inst in fleet.on_demand_instances
                    if inst["instance"] != self
                ]

        self._state.name = "terminated"
        self._state.code = 48

        self._reason = (
            f"User initiated ({datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')})"
        )
        self._state_reason = StateReason(
            "Client.UserInitiatedShutdown: User initiated shutdown",
            "Client.UserInitiatedShutdown",
        )

        # Disassociate iam instance profile if associated, otherwise iam_instance_profile_associations will
        # be pointing to None.
        if self.ec2_backend.iam_instance_profile_associations.get(self.id):
            self.ec2_backend.disassociate_iam_instance_profile(
                association_id=self.ec2_backend.iam_instance_profile_associations[
                    self.id
                ].id
            )

        return previous_state

    def reboot(self) -> None:
        self._state.name = "running"
        self._state.code = 16

        self._reason = ""
        self._state_reason = StateReason()

    @property
    def dynamic_group_list(self) -> List[SecurityGroup]:
        return self.security_groups

    def _get_private_ip_from_nic(self, nic: Dict[str, Any]) -> Optional[str]:
        private_ip = nic.get("PrivateIpAddress")
        if private_ip:
            return private_ip
        for address in nic.get("PrivateIpAddresses", []):
            if address.get("Primary") == "true":
                return address.get("PrivateIpAddress")
        return None

    def prep_nics(
        self,
        nic_spec: List[Dict[str, Any]],
        private_ip: Optional[str] = None,
        associate_public_ip: Optional[bool] = None,
        security_groups: Optional[List[SecurityGroup]] = None,
    ) -> None:
        self.nics: Dict[int, NetworkInterface] = {}
        for nic in nic_spec:
            if int(nic.get("DeviceIndex")) == 0:  # type: ignore[arg-type]
                nic_associate_public_ip = nic.get("AssociatePublicIpAddress")
                if nic_associate_public_ip is not None:
                    associate_public_ip = nic_associate_public_ip == "true"
                if private_ip is None:
                    private_ip = self._get_private_ip_from_nic(nic)
                break

        if self.subnet_id:
            subnet: Subnet = self.ec2_backend.get_subnet(self.subnet_id)
            if not private_ip:
                private_ip = subnet.get_available_subnet_ip(instance=self)
            else:
                subnet.request_ip(private_ip, instance=self)

            self._private_ips.add(private_ip)
        elif private_ip is None:
            # Preserve old behaviour if in EC2-Classic mode
            private_ip = random_private_ip()

        # Primary NIC defaults
        primary_nic = {
            "SubnetId": self.subnet_id,
            "PrivateIpAddress": private_ip,
            "AssociatePublicIpAddress": associate_public_ip,
        }
        primary_nic = dict((k, v) for k, v in primary_nic.items() if v)

        # If empty NIC spec but primary NIC values provided, create NIC from
        # them.
        if primary_nic and not nic_spec:
            nic_spec = [primary_nic]
            nic_spec[0]["DeviceIndex"] = 0

        # Flesh out data structures and associations
        for nic in nic_spec:
            device_index = int(nic.get("DeviceIndex"))  # type: ignore[arg-type]

            nic_id = nic.get("NetworkInterfaceId")
            if nic_id:
                # If existing NIC found, use it.
                use_nic = self.ec2_backend.get_network_interface(nic_id)
                use_nic.device_index = device_index
                use_nic.public_ip_auto_assign = False

            else:
                # If primary NIC values provided, use them for the primary NIC.
                if device_index == 0 and primary_nic:
                    nic.update(primary_nic)

                if "SubnetId" in nic:
                    nic_subnet: Subnet = self.ec2_backend.get_subnet(nic["SubnetId"])
                else:
                    # Get default Subnet
                    zone = self._placement.zone
                    nic_subnet = self.ec2_backend.get_default_subnet(
                        availability_zone=zone
                    )

                group_ids = nic.get("SecurityGroupId") or []
                if security_groups:
                    group_ids.extend([group.id for group in security_groups])

                use_nic = self.ec2_backend.create_network_interface(
                    nic_subnet,
                    nic.get("PrivateIpAddress"),
                    device_index=device_index,
                    public_ip_auto_assign=nic.get("AssociatePublicIpAddress", False),
                    group_ids=group_ids,
                )

            self.attach_eni(use_nic, device_index)

    def attach_eni(self, eni: NetworkInterface, device_index: int) -> str:
        device_index = int(device_index)
        self.nics[device_index] = eni

        # This is used upon associate/disassociate public IP.
        eni.instance = self
        eni.attachment_id = random_eni_attach_id()
        eni.attach_time = utc_date_and_time()
        eni.status = "in-use"
        eni.device_index = device_index

        return eni.attachment_id

    def detach_eni(self, eni: NetworkInterface) -> None:
        self.nics.pop(eni.device_index, None)  # type: ignore[arg-type]
        eni.instance = None
        eni.attachment_id = None
        eni.attach_time = None
        eni.status = "available"
        eni.device_index = None

    @classmethod
    def has_cfn_attr(cls, attr: str) -> bool:
        return attr in [
            "AvailabilityZone",
            "PrivateDnsName",
            "PublicDnsName",
            "PrivateIp",
            "PublicIp",
        ]

    def get_cfn_attribute(self, attribute_name: str) -> Any:
        from moto.cloudformation.exceptions import UnformattedGetAttTemplateException

        if attribute_name == "AvailabilityZone":
            return self.placement
        elif attribute_name == "PrivateDnsName":
            return self.private_dns
        elif attribute_name == "PublicDnsName":
            return self.public_dns
        elif attribute_name == "PrivateIp":
            return self.private_ip
        elif attribute_name == "PublicIp":
            return self.public_ip
        raise UnformattedGetAttTemplateException()

    def applies(self, filters: List[Dict[str, Any]]) -> bool:
        if filters:
            applicable = False
            for f in filters:
                acceptable_values = f["values"]
                if f["name"] == "instance-state-name":
                    if self._state.name in acceptable_values:
                        applicable = True
                if f["name"] == "instance-state-code":
                    if str(self._state.code) in acceptable_values:
                        applicable = True
            return applicable
        # If there are no filters, all instances are valid
        return True


class InstanceBackend:
    def __init__(self) -> None:
        self.reservations: Dict[str, Reservation] = OrderedDict()

    def get_instance(self, instance_id: str) -> Instance:
        for instance in self.all_instances():
            if instance.id == instance_id:
                return instance
        raise InvalidInstanceIdError(instance_id)

    def add_instances(
        self,
        image_id: str,
        count: int,
        user_data: Optional[str],
        security_group_names: List[str],
        **kwargs: Any,
    ) -> Reservation:
        location_type = "availability-zone" if kwargs.get("placement") else "region"
        default_region = "us-east-1"
        if settings.ENABLE_KEYPAIR_VALIDATION:
            self.describe_key_pairs(key_names=[kwargs.get("key_name")])  # type: ignore[attr-defined]
        if settings.ENABLE_AMI_VALIDATION:
            self.describe_images(ami_ids=[image_id] if image_id else [])  # type: ignore[attr-defined]
        valid_instance_types = INSTANCE_TYPE_OFFERINGS[location_type]
        if "region_name" in kwargs and kwargs.get("placement"):
            valid_availability_zones = {
                instance["Location"]
                for instance in valid_instance_types[kwargs["region_name"]]
            }
            if kwargs["placement"] not in valid_availability_zones:
                raise AvailabilityZoneNotFromRegionError(kwargs["placement"])
        match_filters = InstanceTypeOfferingBackend().matches_filters
        if not kwargs["is_instance_type_default"] and not any(
            {
                match_filters(
                    valid_instance,
                    {"instance-type": kwargs["instance_type"]},
                    location_type,
                )
                for valid_instance in valid_instance_types.get(
                    kwargs["region_name"]
                    if "region_name" in kwargs
                    else default_region,
                    {},
                )
            },
        ):
            if settings.EC2_ENABLE_INSTANCE_TYPE_VALIDATION:
                raise InvalidInstanceTypeError(kwargs["instance_type"])

        security_groups = [
            self.get_security_group_by_name_or_id(name) for name in security_group_names  # type: ignore[attr-defined]
        ]

        for sg_id in kwargs.pop("security_group_ids", []):
            if isinstance(sg_id, str):
                sg = self.get_security_group_from_id(sg_id)  # type: ignore[attr-defined]
                if sg is None:
                    raise InvalidSecurityGroupNotFoundError(sg_id)
                security_groups.append(sg)
            else:
                security_groups.append(sg_id)

        new_reservation = Reservation(reservation_id=random_reservation_id())

        self.reservations[new_reservation.id] = new_reservation

        tags = kwargs.pop("tags", {})
        instance_tags = tags.get("instance", {})
        volume_tags = tags.get("volume", {})

        for index in range(count):
            kwargs["ami_launch_index"] = index
            new_instance = Instance(
                self, image_id, user_data, security_groups, **kwargs
            )
            new_reservation.instances.append(new_instance)
            new_instance.add_tags(instance_tags)
            block_device_mappings = None
            if "block_device_mappings" not in kwargs:
                new_instance.setup_defaults()
            if "block_device_mappings" in kwargs:
                block_device_mappings = kwargs["block_device_mappings"]
            elif kwargs.get("launch_template"):
                template = self._get_template_from_args(kwargs["launch_template"])
                block_device_mappings = template.data.get("BlockDeviceMapping")
            elif kwargs.get("launch_config"):
                block_device_mappings = kwargs[
                    "launch_config"
                ].block_device_mapping_dict
            if block_device_mappings:
                for block_device in block_device_mappings:
                    device_name = block_device["DeviceName"]
                    volume_size = block_device["Ebs"].get("VolumeSize")
                    volume_type = block_device["Ebs"].get("VolumeType")
                    snapshot_id = block_device["Ebs"].get("SnapshotId")
                    encrypted = block_device["Ebs"].get("Encrypted", False)
                    if isinstance(encrypted, str):
                        encrypted = encrypted.lower() == "true"
                    delete_on_termination = block_device["Ebs"].get(
                        "DeleteOnTermination", False
                    )
                    kms_key_id = block_device["Ebs"].get("KmsKeyId")

                    if block_device.get("NoDevice") != "":
                        new_instance.add_block_device(  # type: ignore[attr-defined]
                            volume_size,
                            device_name,
                            snapshot_id,
                            encrypted,
                            delete_on_termination,
                            kms_key_id,
                            volume_type=volume_type,
                        )
            if kwargs.get("instance_market_options"):
                new_instance.lifecycle = "spot"
            # Tag all created volumes.
            for _, device in new_instance.get_block_device_mapping:
                volumes = self.describe_volumes(volume_ids=[device.volume_id])  # type: ignore
                for volume in volumes:
                    volume.add_tags(volume_tags)

        return new_reservation

    def run_instances(self) -> None:
        """
        The Placement-parameter is validated to verify the availability-zone exists for the current region.

        The InstanceType-parameter can be validated, to see if it is a known instance-type.
        Enable this validation by setting the environment variable `MOTO_EC2_ENABLE_INSTANCE_TYPE_VALIDATION=true`

        The ImageId-parameter can be validated, to see if it is a known AMI.
        Enable this validation by setting the environment variable `MOTO_ENABLE_AMI_VALIDATION=true`

        The KeyPair-parameter can be validated, to see if it is a known key-pair.
        Enable this validation by setting the environment variable `MOTO_ENABLE_KEYPAIR_VALIDATION=true`
        """
        # Logic resides in add_instances
        # Fake method here to make implementation coverage script aware that this method is implemented
        pass

    def start_instances(
        self, instance_ids: List[str]
    ) -> List[Tuple[Instance, InstanceState]]:
        started_instances = []
        for instance in self.get_multi_instances_by_id(instance_ids):
            previous_state = instance.start()
            started_instances.append((instance, previous_state))

        return started_instances

    def stop_instances(
        self, instance_ids: List[str]
    ) -> List[Tuple[Instance, InstanceState]]:
        stopped_instances = []
        for instance in self.get_multi_instances_by_id(instance_ids):
            previous_state = instance.stop()
            stopped_instances.append((instance, previous_state))

        return stopped_instances

    def terminate_instances(
        self, instance_ids: List[str]
    ) -> List[Tuple[Instance, InstanceState]]:
        terminated_instances = []
        if not instance_ids:
            raise InvalidParameterCombination("No instances specified")
        for instance in self.get_multi_instances_by_id(instance_ids):
            if instance.disable_api_termination == "true":
                raise OperationNotPermitted4(instance.id)
            previous_state = instance.terminate()
            terminated_instances.append((instance, previous_state))

        return terminated_instances

    def reboot_instances(self, instance_ids: List[str]) -> List[Instance]:
        rebooted_instances = []
        for instance in self.get_multi_instances_by_id(instance_ids):
            instance.reboot()
            rebooted_instances.append(instance)

        return rebooted_instances

    def modify_instance_attribute(
        self, instance_id: str, key: str, value: Any
    ) -> Instance:
        instance = self.get_instance(instance_id)
        setattr(instance, key, value)
        return instance

    def modify_instance_security_groups(
        self, instance_id: str, new_group_id_list: List[str]
    ) -> Instance:
        instance = self.get_instance(instance_id)
        new_group_list = []
        for new_group_id in new_group_id_list:
            new_group_list.append(self.get_security_group_from_id(new_group_id))  # type: ignore[attr-defined]
        setattr(instance, "security_groups", new_group_list)
        return instance

    def describe_instance_attribute(
        self, instance_id: str, attribute: str
    ) -> Tuple[Instance, Any]:
        if attribute not in Instance.VALID_ATTRIBUTES:
            raise InvalidParameterValueErrorUnknownAttribute(attribute)

        if attribute == "groupSet":
            key = "security_groups"
        else:
            key = camelcase_to_underscores(attribute)
        instance = self.get_instance(instance_id)
        value = getattr(instance, key)
        return instance, value

    def describe_instance_credit_specifications(
        self, instance_ids: List[str]
    ) -> List[Instance]:
        queried_instances = []
        for instance in self.get_multi_instances_by_id(instance_ids):
            queried_instances.append(instance)
        return queried_instances

    def all_instances(self, filters: Any = None) -> List[Instance]:
        instances = []
        for reservation in self.all_reservations():
            for instance in reservation.instances:
                if instance.applies(filters):
                    instances.append(instance)
        return instances

    def all_running_instances(self, filters: Any = None) -> List[Instance]:
        instances = []
        for reservation in self.all_reservations():
            for instance in reservation.instances:
                if instance.state_code == 16 and instance.applies(filters):
                    instances.append(instance)
        return instances

    def get_multi_instances_by_id(
        self, instance_ids: List[str], filters: Any = None
    ) -> List[Instance]:
        """
        :param instance_ids: A string list with instance ids
        :return: A list with instance objects
        """
        result = []

        for reservation in self.all_reservations():
            for instance in reservation.instances:
                if instance.id in instance_ids:
                    if instance.applies(filters):
                        result.append(instance)

        if instance_ids and len(instance_ids) > len(result):
            result_ids = [i.id for i in result]
            missing_instance_ids = [i for i in instance_ids if i not in result_ids]
            raise InvalidInstanceIdError(missing_instance_ids)

        return result

    def get_instance_by_id(self, instance_id: str) -> Optional[Instance]:
        for reservation in self.all_reservations():
            for instance in reservation.instances:
                if instance.id == instance_id:
                    return instance
        return None

    def get_reservations_by_instance_ids(
        self, instance_ids: List[str], filters: Any = None
    ) -> List[Reservation]:
        """Go through all of the reservations and filter to only return those
        associated with the given instance_ids.
        """
        reservations = []
        for reservation in self.all_reservations():
            reservation_instance_ids = [
                instance.id for instance in reservation.instances
            ]
            matching_reservation = any(
                instance_id in reservation_instance_ids for instance_id in instance_ids
            )
            if matching_reservation:
                reservation.instances = [
                    instance
                    for instance in reservation.instances
                    if instance.id in instance_ids
                ]
                reservations.append(reservation)
        found_instance_ids = [
            instance.id
            for reservation in reservations
            for instance in reservation.instances
        ]
        if len(found_instance_ids) != len(instance_ids):
            invalid_id = list(set(instance_ids).difference(set(found_instance_ids)))[0]
            raise InvalidInstanceIdError(invalid_id)
        if filters is not None:
            reservations = filter_reservations(reservations, filters)
        return reservations

    def describe_instances(self, filters: Any = None) -> List[Reservation]:
        return self.all_reservations(filters)

    def describe_instance_status(
        self, instance_ids: List[str], include_all_instances: bool, filters: Any
    ) -> List[Instance]:
        if instance_ids:
            return self.get_multi_instances_by_id(instance_ids, filters)
        elif include_all_instances:
            return self.all_instances(filters)
        else:
            return self.all_running_instances(filters)

    def all_reservations(self, filters: Any = None) -> List[Reservation]:
        reservations = [
            copy.copy(reservation) for reservation in self.reservations.copy().values()
        ]
        if filters is not None:
            reservations = filter_reservations(reservations, filters)
        return reservations

    def _get_template_from_args(
        self, launch_template_arg: Dict[str, Any]
    ) -> LaunchTemplateVersion:
        template = (
            self.describe_launch_templates(  # type: ignore[attr-defined]
                template_ids=[launch_template_arg["LaunchTemplateId"]]
            )[0]
            if "LaunchTemplateId" in launch_template_arg
            else self.describe_launch_templates(  # type: ignore[attr-defined]
                template_names=[launch_template_arg["LaunchTemplateName"]]
            )[0]
        )
        version = launch_template_arg.get("Version", template.latest_version_number)
        template_version = template.get_version(int(version))
        return template_version
