# Copyright 2020 Google Inc. All rights reserved.
#
# 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.
"""Timesketch API search object."""
import datetime
import json
import logging
import re

import pandas

from . import error
from . import resource
from . import searchtemplate


logger = logging.getLogger("timesketch_api.search")


class Chip:
    """Class definition for a query filter chip."""

    # The type of a chip that is defined.
    CHIP_TYPE = ""

    # The chip value defines what property or attribute of the
    # chip class will be used to generate the chip value.
    CHIP_VALUE = ""

    # The value of the chip field.
    CHIP_FIELD = ""

    def __init__(self):
        """Initialize the chip."""
        self._active = True
        self._operator = "must"
        self._chip_field = self.CHIP_FIELD

    @property
    def active(self):
        """A property that returns whether the chip is active or not."""
        return self._active

    @active.setter
    def active(self, active):
        """Decide whether the chip is active or disabled."""
        self._active = bool(active)

    @property
    def chip(self):
        """A property that returns the chip value."""
        return {
            "field": self._chip_field,
            "type": self.CHIP_TYPE,
            "operator": self._operator,
            "active": self._active,
            "value": getattr(self, self.CHIP_VALUE, ""),
        }

    def from_dict(self, chip_dict):
        """Configure the chip from a dictionary."""
        raise NotImplementedError

    def set_include(self):
        """Configure the chip so the content needs to be included in results."""
        self._operator = "must"

    def set_exclude(self):
        """Configure the chip so content needs to be excluded in results."""
        self._operator = "must_not"

    def set_optional(self):
        """Configure the chip so the content is optional in results."""
        self._operator = "should"

    def set_active(self):
        """Set the chip as active."""
        self._active = True

    def set_disable(self):
        """Disable the chip."""
        self._active = False


class DateIntervalChip(Chip):
    """A date interval chip."""

    CHIP_TYPE = "datetime_interval"
    CHIP_VALUE = "interval"

    _DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
    _DATE_FORMAT_MICROSECONDS = "%Y-%m-%dT%H:%M:%S.%f"
    _DATE_ONLY_FORMAT = "%Y-%m-%d"

    def __init__(self):
        """Initialize the chip."""
        super().__init__()
        self._date = None
        self._before = 5
        self._after = 5
        self._unit = "m"

    def add_interval(self, before, after=None, unit="m"):
        """Set the interval of the chip.

        Args:
            before (int): the number of units that should be included
                before the date.
            after (int): optional number of units after the date. If not
                provided the value of before is used.
            unit (str): optional string with the unit of interval. This can
                be s for seconds, m for minutes, d for days and h for hours.
                The default value is m (minutes).

        Raises:
            ValueError if the unit is not correctly formed.
        """
        if after is None:
            after = before

        self.unit = unit

        self._before = before
        self._after = after

    @property
    def after(self):
        """Property that returns the time interval after the date."""
        return self._after

    @after.setter
    def after(self, after):
        """Make changes to the time interval after the date."""
        self._after = after

    @property
    def before(self):
        """Property that returns the time interval before the date."""
        return self._before

    @before.setter
    def before(self, before):
        """Make changes to the time interval before the date."""
        self._before = before

    @property
    def date(self):
        """Property that returns back the date."""
        if not self._date:
            return ""
        if self._date.microsecond == 0:
            return self._date.strftime(self._DATE_FORMAT)
        return self._date.strftime(self._DATE_FORMAT_MICROSECONDS)[:-3]

    @date.setter
    def date(self, date):
        """Make changes to the date."""
        try:
            dt = datetime.datetime.strptime(date, self._DATE_FORMAT_MICROSECONDS)
        except ValueError:
            try:
                dt = datetime.datetime.strptime(date, self._DATE_FORMAT)
            except ValueError:
                try:
                    dt = datetime.datetime.strptime(date, self._DATE_ONLY_FORMAT)
                except ValueError as exc:
                    logger.error(
                        "Unable to add date chip, wrong date format", exc_info=True
                    )
                    raise ValueError("Wrong date format") from exc
        self._date = dt

    def from_dict(self, chip_dict):
        """Configure the chip from a dictionary."""
        value = chip_dict.get("value")
        if not value:
            return

        split_value = value.split()
        if len(split_value) == 3:
            date_time, before, after = split_value
        elif len(split_value) == 4:
            date, time, before, after = split_value
            date_time = f"{date}T{time}"
        else:
            raise ValueError("Unable to configure date chip, wrong date format.")

        self.unit = before[-1]
        if date_time.endswith("Z"):
            self.date = date_time[:-1]
        else:
            self.date = date_time
        self.before = int(before[1:-1])
        self.after = int(after[1:-1])

    @property
    def interval(self):
        """A property that returns back the full interval."""
        return f"{self.date} -{self.before}{self.unit} +{self.after}{self.unit}"

    @property
    def unit(self):
        """Property that returns back the unit used."""
        return self._unit

    @unit.setter
    def unit(self, unit):
        """Make changes to the unit."""
        if unit not in ("s", "m", "d", "h"):
            raise ValueError(
                "Unable to add interval, needs to be one of: "
                "s (seconds), m (minutes), h (hours) or d (days)"
            )
        self._unit = unit


class DateRangeChip(Chip):
    """A date range chip."""

    CHIP_TYPE = "datetime_range"
    CHIP_VALUE = "date_range"

    _DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
    _DATE_FORMAT_MICROSECONDS = "%Y-%m-%dT%H:%M:%S.%f"

    _DATE_RE = r"^[0-9]{4}-[0-9]{1,2}-[0-9]{2}$"

    def __init__(self):
        """Initialize the date range."""
        super().__init__()
        self._start_date = None
        self._end_date = None
        self._date_re = re.compile(self._DATE_RE)

    def add_end_time(self, end_time):
        """Add an end time to the range.

        Args:
            end_time (str): date string using the format '%Y-%m-%dT%H:%M:%s'

        Raises:
            ValueError: if the date format is incorrectly formatted.
        """
        if end_time.endswith("Z"):
            end_time = end_time[:-1]

        # Check for a whole day, YYYY-MM-DD.
        if self._date_re.match(end_time):
            end_time = f"{end_time}T23:59:59"

        try:
            dt = datetime.datetime.strptime(end_time, self._DATE_FORMAT_MICROSECONDS)
        except ValueError as exc:
            try:
                dt = datetime.datetime.strptime(end_time, self._DATE_FORMAT)
            except ValueError as exc:
                logger.error(
                    "Unable to add date chip, wrong date format", exc_info=True
                )
                raise ValueError("Wrong date format") from exc
        self._end_date = dt

    def add_start_time(self, start_time):
        """Add a start time to the range.

        Args:
            start_time (str): date string using the format '%Y-%m-%dT%H:%M:%s'

        Raises:
            ValueError: if the date format is incorrectly formatted.
        """
        if start_time.endswith("Z"):
            start_time = start_time[:-1]

        # Check for a whole day, YYYY-MM-DD.
        if self._date_re.match(start_time):
            start_time = f"{start_time}T00:00:00"

        try:
            dt = datetime.datetime.strptime(start_time, self._DATE_FORMAT_MICROSECONDS)
        except ValueError as exc:
            try:
                dt = datetime.datetime.strptime(start_time, self._DATE_FORMAT)
            except ValueError as exc:
                logger.error(
                    "Unable to add date chip, wrong date format", exc_info=True
                )
                raise ValueError("Wrong date format") from exc
        self._start_date = dt

    @property
    def end_time(self):
        """Property that returns the end time of a range."""
        if not self._end_date:
            return ""
        if self._end_date.microsecond == 0:
            return self._end_date.strftime(self._DATE_FORMAT)
        return self._end_date.strftime(self._DATE_FORMAT_MICROSECONDS)[:-3]

    @end_time.setter
    def end_time(self, end_time):
        """Sets the new end time."""
        self.add_end_time(end_time)

    @property
    def date_range(self):
        """Property that returns back the range."""
        return f"{self.start_time},{self.end_time}"

    @date_range.setter
    def date_range(self, date_range):
        """Sets the new range of the date range chip."""
        start_time, end_time = date_range.split(",")
        self.add_start_time(start_time)
        self.add_end_time(end_time)

    def from_dict(self, chip_dict):
        """Configure the chip from a dictionary."""
        chip_value = chip_dict.get("value")
        if not chip_value:
            return
        start, end = chip_value.split(",")
        self.start_time = start
        self.end_time = end

    @property
    def start_time(self):
        """Property that returns the start time of a range."""
        if not self._start_date:
            return ""
        if self._start_date.microsecond == 0:
            return self._start_date.strftime(self._DATE_FORMAT)
        return self._start_date.strftime(self._DATE_FORMAT_MICROSECONDS)[:-3]

    @start_time.setter
    def start_time(self, start_time):
        """Sets the new start time of a range."""
        self.add_start_time(start_time)


class LabelChip(Chip):
    """Label chip."""

    CHIP_TYPE = "label"
    CHIP_VALUE = "label"

    def __init__(self):
        """Initialize the chip."""
        super().__init__()
        self._label = ""

    def from_dict(self, chip_dict):
        """Configure the chip from a dictionary."""
        chip_value = chip_dict.get("value")
        if not chip_value:
            return

        self.label = chip_value

    @property
    def label(self):
        """Property that returns back the label."""
        return self._label

    @label.setter
    def label(self, label):
        """Make changes to the label."""
        self._label = label

    def use_comment_label(self):
        """Use the comment label."""
        self._label = "__ts_comment"

    def use_star_label(self):
        """Use the star label."""
        self._label = "__ts_star"


class TermChip(Chip):
    """Term chip definition."""

    CHIP_TYPE = "term"
    CHIP_VALUE = "query"

    def __init__(self):
        """Initialize the chip."""
        super().__init__()
        self._query = ""

    @property
    def field(self):
        """Property that returns back the field used to match against."""
        return self._chip_field

    @field.setter
    def field(self, field):
        """Make changes to the field used to match against."""
        self._chip_field = field

    def from_dict(self, chip_dict):
        """Configure the term chip from a dictionary."""
        chip_value = chip_dict.get("value")
        if not chip_value:
            return

        self.field = chip_dict.get("field")
        self.query = chip_value

    @property
    def query(self):
        """Property that returns back the query."""
        return self._query

    @query.setter
    def query(self, query):
        """Make changes to the query."""
        self._query = query


class Search(resource.SketchResource):
    """Search object."""

    DEFAULT_SIZE_LIMIT = 10000

    def __init__(self, sketch):
        resource_uri = f"sketches/{sketch.id}/explore/"
        super().__init__(sketch=sketch, resource_uri=resource_uri)

        self._aggregations = ""
        self._chips = []
        self._created_at = ""
        self._description = ""
        self._indices = "_all"
        self._max_entries = self.DEFAULT_SIZE_LIMIT
        self._name = ""
        self._query_dsl = ""
        self._query_filter = {}
        self._query_string = ""
        self._raw_response = None
        self._return_fields = ""
        self._scrolling = None
        self._searchtemplate = ""
        self._total_elastic_size = 0
        self._updated_at = ""

    def _extract_chips(self, query_filter):
        """Extract chips from a query_filter."""
        chips = query_filter.get("chips", [])
        if not chips:
            return

        for chip_dict in chips:
            chip_type = chip_dict.get("type")
            if not chip_type:
                continue

            chip = None
            if chip_type == "datetime_interval":
                chip = DateIntervalChip()
            elif chip_type == "datetime_range":
                chip = DateRangeChip()
            elif chip_type == "label":
                chip = LabelChip()
            elif chip_type == "term":
                chip = TermChip()

            if not chip:
                continue
            chip.from_dict(chip_dict)

            active = chip_dict.get("active", True)
            chip.active = active

            operator = chip_dict.get("operator", "must")
            if operator == "must":
                chip.set_include()
            elif operator == "must_not":
                chip.set_exclude()

            self.add_chip(chip)

    def _execute_query(self, file_name="", count=False):
        """Execute a search request and store the results.

        Args:
            file_name (str): Optional file path to a filename that
                all the results will be saved to. If not provided
                the results will be stored in the search object.
            count (bool): Optional boolean that determines whether
                we want to execute the query or only count the
                number of events that the query would produce. If
                set to True, the results will be stored in the
                search object, and the number of events will be
                returned.

        Returns:
            A dict with the search results or the total number of events
            (if count=True) or None if saved to file.
        """
        query_filter = self.query_filter
        if not isinstance(query_filter, dict):
            raise ValueError("Unable to query with a query filter that isn't a dict.")

        stop_size = self.max_entries
        scrolling = not bool(stop_size and (stop_size < self.DEFAULT_SIZE_LIMIT))

        if self.scrolling is not None:
            scrolling = self.scrolling

        form_data = {
            "query": self.query_string,
            "filter": query_filter,
            "dsl": self.query_dsl,
            "count": count,
            "fields": self.return_fields,
            "enable_scroll": scrolling,
            "file_name": file_name,
        }

        response = self.api.session.post(
            f"{self.api.api_root}/{self.resource_uri}", json=form_data
        )
        if not error.check_return_status(response, logger):
            error.error_message(
                response, message="Unable to query results", error=ValueError
            )

        if file_name:
            with open(file_name, "wb") as fw:
                fw.write(response.content)
            return None

        response_json = error.get_response_json(response, logger)

        if count:
            meta = response_json.get("meta", {})
            self._total_elastic_size = meta.get("total_count", 0)
            return meta.get("total_count", 0)

        scroll_id = response_json.get("meta", {}).get("scroll_id", "")
        form_data["scroll_id"] = scroll_id

        count = len(response_json.get("objects", []))
        total_count = count
        while count > 0:
            if self.max_entries and total_count >= self.max_entries:
                break

            if not scroll_id:
                logger.debug("No scroll ID, will stop.")
                break

            more_response = self.api.session.post(
                f"{self.api.api_root}/{self.resource_uri}", json=form_data
            )
            if not error.check_return_status(more_response, logger):
                error.error_message(
                    response, message="Unable to query results", error=ValueError
                )
            more_response_json = error.get_response_json(more_response, logger)
            count = len(more_response_json.get("objects", []))
            total_count += count
            response_json["objects"].extend(more_response_json.get("objects", []))
            more_meta = more_response_json.get("meta", {})
            added_time = more_meta.get("es_time", 0)
            response_json["meta"]["es_time"] += added_time

        self._total_elastic_size = response_json.get("meta", {}).get(
            "es_total_count", 0
        )
        if self._total_elastic_size != total_count:
            logger.info(
                "%d results were returned, but %d records matched the search query",
                total_count,
                self._total_elastic_size,
            )

        self._raw_response = response_json
        return response_json

    def add_chip(self, chip):
        """Add a chip to the ..."""
        self._chips.append(chip)
        self.commit()

    def add_date_range(self, start_time, end_time):
        """Add a date range chip to the search query.

        Args:
            start_time (str): a string with the start time of the range,
                the format should be '%Y-%m-%dT%H:%M:%S'
            end_time (str): a string with the end time of the range,
                the format should be '%Y-%m-%dT%H:%M:%S'
        """
        chip = DateRangeChip()
        chip.start_time = start_time
        chip.end_time = end_time
        self.add_chip(chip)

    @property
    def chips(self):
        """Property that returns all the chips in the search object."""
        return self._chips

    def commit(self):
        """Commit changes to the search object."""
        self._raw_response = None
        super().commit()

    @property
    def created_at(self):
        """Property that returns back the creation time of a search."""
        return self._created_at

    def delete(self):
        """Deletes the saved search from the store."""
        if not self._resource_id:
            logger.warning(
                "Unable to delete the saved search, it does not appear to be "
                "saved in the first place."
            )
            return False

        resource_url = (
            f"{self.api.api_root}/sketches/{self._sketch.id}/views/"
            f"{self._resource_id}/"
        )
        response = self.api.session.delete(resource_url)
        return error.check_return_status(response, logger)

    @property
    def description(self):
        """Property that returns back the description of the saved search."""
        return self._description

    @description.setter
    def description(self, description):
        """Make changes to the saved search description field."""
        self._description = description
        self.commit()

    @property
    def expected_size(self):
        """Property that returns the expected size of the search query."""
        if self._total_elastic_size:
            return self._total_elastic_size

        _ = self._execute_query(count=True)
        return self._total_elastic_size

    def from_manual(  # pylint: disable=arguments-differ
        self,
        query_string=None,
        query_dsl=None,
        query_filter=None,
        return_fields=None,
        max_entries=None,
        **kwargs,
    ):
        """Explore the sketch.

        Args:
            query_string (str): OpenSearch query string.
            query_dsl (str): OpenSearch query DSL as JSON string.
            query_filter (dict): Filter for the query as a dict.
            return_fields (str): A comma separated string with a list of fields
                that should be included in the response. Optional and defaults
                to None.
            max_entries (int): Optional integer denoting a best effort to limit
                the output size to the number of events. Events are read in,
                10k at a time so there may be more events in the answer back
                than this number denotes, this is a best effort.
            kwargs (dict[str, object]): Depending on the resource they may
                require different sets of arguments to be able to run a raw
                API request.

        Raises:
            ValueError: if unable to query for the results.
            RuntimeError: if the query is missing needed values, or if the
                sketch is archived.
        """
        super().from_manual(**kwargs)
        if not (query_string or query_filter or query_dsl):
            raise RuntimeError("You need to supply a query")

        self._username = self.api.current_user.username
        self._name = "From Explore"
        self._description = "From Explore"

        if query_filter:
            self.query_filter = query_filter

        self.query_string = query_string
        self.query_dsl = query_dsl
        self.return_fields = return_fields

        if max_entries:
            self.max_entries = max_entries

        # TODO: Make use of search templates and aggregations.
        # self._searchtemplate = data.get('searchtemplate', 0)
        # self._aggregations = data.get('aggregation', 0)

        self._created_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
        self._updated_at = self._created_at

        self.resource_data = {}

    def from_saved(self, search_id):  # pylint: disable=arguments-differ
        """Initialize the search object from a saved search.

        Args:
            search_id: integer value for the saved
                search (primary key).
        """
        resource_uri = f"sketches/{self._sketch.id}/views/{search_id}/"
        resource_data = self.api.fetch_resource_data(resource_uri)

        data = resource_data.get("objects", [None])[0]
        if not data:
            logger.error("Unable to get any data back from a saved search.")
            return

        label_string = data.get("label_string", "")
        if label_string:
            self._labels = json.loads(label_string)
        else:
            self._labels = []

        self._aggregations = data.get("aggregation", 0)
        self._created_at = data.get("created_at", "")
        self._description = data.get("description", "")
        self._name = data.get("name", "")
        self.query_dsl = data.get("query_dsl", "")
        query_filter = data.get("query_filter", "")
        if query_filter:
            filter_dict = json.loads(query_filter)
            if "fields" in filter_dict:
                fields = filter_dict.pop("fields")
                return_fields = [x.get("field") for x in fields]
                self.return_fields = ",".join(return_fields)

            indices = filter_dict.get("indices", [])
            if indices:
                self.indices = indices

            self.query_filter = filter_dict
        self.query_string = data.get("query_string", "")
        self._resource_id = search_id
        self._searchtemplate = data.get("searchtemplate", 0)
        self._updated_at = data.get("updated_at", "")
        self._username = data.get("user", {}).get("username", "System")

        self.resource_data = data

    @property
    def indices(self):
        """Return the current set of indices used in the search."""
        return self._indices

    @indices.setter
    def indices(self, indices):
        """Make changes to the current set of indices."""
        if indices == "_all":
            self._indices = "_all"
            self.commit()
            return

        def _is_string_or_int(item):
            return isinstance(item, (str, int))

        if not isinstance(indices, list):
            logger.warning(
                "Indices needs to be a list of strings (indices that were "
                "passed in were not a list)."
            )
            return
        if not all(map(_is_string_or_int, indices)):
            logger.warning(
                "Indices needs to be a list of strings or ints, not all "
                "entries in the indices list are valid string/int."
            )
            return

        if len(indices) == 1 and indices[0] == "_all":
            self._indices = "_all"
            self.commit()
            return

        # Indices here can be either a list of timeline names, IDs or a list
        # of search indices. We need to verify that these exist before saving
        # them.
        valid_ids = set()
        timeline_indices = {}  # Dict[index] = List[name]
        timeline_names = {}  # Dict[name] = id

        for timeline_object in self._sketch.list_timelines():
            timeline_indices.setdefault(timeline_object.index_name, [])
            timeline_indices[timeline_object.index_name].append(timeline_object.name)
            valid_ids.add(timeline_object.id)

            timeline_names[timeline_object.name] = timeline_object.id

        new_indices = []
        for index in indices:
            # Is this an index name, include all timeline IDs.
            if index in timeline_indices:
                new_indices.extend(timeline_indices[index])
                continue

            # Is this a timeline ID?
            if isinstance(index, int):
                if index in valid_ids:
                    new_indices.append(str(index))
                    continue

            if isinstance(index, str):
                if index.isdigit():
                    if int(index) in valid_ids:
                        new_indices.append(index)
                        continue

            # Is this a timeline name?
            if index in timeline_names:
                new_indices.append(timeline_names[index])

        if not new_indices:
            logger.warning("No valid indices found, not changing the value.")
            return

        self._indices = new_indices
        self.commit()

    @property
    def max_entries(self):
        """Return the maximum number of entries in the return value."""
        return self._max_entries

    @max_entries.setter
    def max_entries(self, max_entries):
        """Make changes to the max entries of return values."""
        self._max_entries = max_entries
        if max_entries < self.DEFAULT_SIZE_LIMIT:
            _ = self.query_filter
            self._query_filter["size"] = max_entries
            self._query_filter["terminate_after"] = max_entries
        self.commit()

    @property
    def name(self):
        """Property that returns the query name."""
        return self._name

    @name.setter
    def name(self, name):
        """Make changes to the saved search name."""
        self._name = name
        self.commit()

    def order_ascending(self):
        """Set the order of objects returned back ascending."""
        # Trigger a creation of a query filter if it does not exist.
        _ = self.query_filter
        self._query_filter["order"] = "asc"
        self.commit()

    def order_descending(self):
        """Set the order of objects returned back descending."""
        # Trigger a creation of a query filter if it does not exist.
        _ = self.query_filter
        self._query_filter["order"] = "desc"
        self.commit()

    @property
    def query_dsl(self):
        """Property that returns back the query DSL."""
        return self._query_dsl

    @query_dsl.setter
    def query_dsl(self, query_dsl):
        """Make changes to the query DSL of the search."""
        if query_dsl and isinstance(query_dsl, str):
            query_dsl = json.loads(query_dsl)

        # Special condition of an empty DSL.
        if query_dsl == '""':
            query_dsl = ""

        self._query_dsl = query_dsl
        self.commit()

    @property
    def query_filter(self):
        """Property that returns the query filter."""
        if not self._query_filter:
            self._query_filter = {
                "size": self.DEFAULT_SIZE_LIMIT,
                "terminate_after": self.DEFAULT_SIZE_LIMIT,
                "indices": self.indices,
                "order": "asc",
                "chips": [],
            }

        query_filter = self._query_filter
        query_filter["chips"] = [x.chip for x in self._chips]
        query_filter["indices"] = self.indices
        return query_filter

    @query_filter.setter
    def query_filter(self, query_filter):
        """Make changes to the query filter."""
        if isinstance(query_filter, str):
            try:
                query_filter = json.loads(query_filter)
            except json.JSONDecodeError as exc:
                raise ValueError("Unable to parse the string as JSON") from exc

        if not isinstance(query_filter, dict):
            raise ValueError("Query filter needs to be a dict.")
        self._query_filter = query_filter
        self._extract_chips(query_filter)
        self.commit()

    @property
    def query_string(self):
        """Property that returns back the query string."""
        return self._query_string

    @query_string.setter
    def query_string(self, query_string):
        """Make changes to the query string of a saved search."""
        self._query_string = query_string
        self.commit()

    def remove_chip(self, chip_index):
        """Remove a chip from the saved search."""
        chip_len = len(self._chips)
        if chip_index > (chip_len + 1):
            raise ValueError(
                f"Unable to remove chip, only {chip_len} chips stored "
                f"(no index {chip_index})"
            )

        try:
            _ = self._chips.pop(chip_index)
        except IndexError as exc:
            raise ValueError(
                f"Unable to remove index {chip_index}, out of range"
            ) from exc

        self.commit()

    @property
    def return_fields(self):
        """Property that returns the return_fields."""
        if self._return_fields:
            items = self._return_fields.split(",")
            if "datetime" not in items:
                items.append("datetime")
            return ",".join(items)
        return self._return_fields

    @return_fields.setter
    def return_fields(self, return_fields):
        """Make changes to the return fields."""
        self._return_fields = return_fields
        self.commit()

    @property
    def return_size(self):
        """Return the maximum number of entries in the return value."""
        return self._max_entries

    @return_size.setter
    def return_size(self, return_size):
        """Make changes to the maximum number of entries in the return."""
        self._max_entries = return_size
        self.commit()

    def save(self):
        """Save the search in the database.

        Returns:
            String with the identification of the saved search.

        Raises:
            ValueError: if there are values missing in order to save the query.
            RuntimeError: if the search could not be saved.
        """
        if not self.name:
            raise ValueError("No name for the query saved. Please select a name first.")

        if not (self.query_string or self.query_dsl):
            raise ValueError(
                "Need to have either a query DSL or a query string to be "
                "able to save the search."
            )

        if not self.description:
            logger.warning("No description selected for search, saving without one")

        if self._resource_id:
            resource_url = (
                f"{self.api.api_root}/sketches/{self._sketch.id}/views/"
                f"{self._resource_id}/"
            )
        else:
            resource_url = f"{self.api.api_root}/sketches/{self._sketch.id}/views/"

        query_filter = self.query_filter
        if self.return_fields:
            sketch_data = self._sketch.data
            sketch_meta = sketch_data.get("meta", {})
            mappings = sketch_meta.get("mappings", [])

            use_mappings = []
            for field in self.return_fields.split(","):
                field = field.strip().lower()
                for map_entry in mappings:
                    if map_entry.get("field", "").lower() == field:
                        use_mappings.append(map_entry)
            query_filter["fields"] = use_mappings

        data = {
            "name": self.name,
            "description": self.description,
            "query": self.query_string,
            "filter": query_filter,
            "dsl": self.query_dsl,
            "labels": json.dumps(self.labels),
        }
        response = self.api.session.post(resource_url, json=data)
        status = error.check_return_status(response, logger)
        if not status:
            error.error_message(response, "Unable to save search", error=RuntimeError)

        response_json = error.get_response_json(response, logger)
        search_dict = response_json.get("objects", [{}])[0]
        self._resource_id = search_dict.get("id", 0)
        return f"Saved search to ID: {self._resource_id}"

    def save_as_template(self):
        """Save the search as a search template.

        Returns:
            A search template object (searchtemplate.SearchTemplate).
        """
        if not self._resource_id:
            logger.warning("Search has not been saved first, saving now.")
            return_string = self.save()
            logger.info(return_string)

        template = searchtemplate.SearchTemplate(self.api)
        template.from_search_object(self)

        print(template.save())
        self._searchtemplate = template.id
        return template

    @property
    def scrolling(self):
        """Returns whether scrolling is enabled or not."""
        return self._scrolling

    def scrolling_disable(self):
        """ "Disables scrolling."""
        self._scrolling = False

    def scrolling_enable(self):
        """Enable scrolling."""
        self._scrolling = True

    def to_dict(self):
        """Returns a dict with the respone of the query."""
        if self._raw_response is None:
            self._execute_query()
            if self._raw_response is None:
                raise ValueError("No results to return.")

        return self._raw_response

    def to_file(self, file_name):
        """Saves the content of the query to a file.

        Args:
            file_name (str): Full path to a file that will store the results
                of the query to as a ZIP file. The ZIP file will contain a
                METADATA file and a CSV with the results from the query.

        Returns:
            Boolean that determines if it was successful.
        """
        old_scrolling = self.scrolling
        self._scrolling = True
        self._execute_query(file_name=file_name)
        self._scrolling = old_scrolling
        return True

    def to_pandas(self):
        """Returns a pandas DataFrame with the response of the query."""
        if self._raw_response is None:
            self._raw_response = self._execute_query()
            if self._raw_response is None:
                raise ValueError("No results to return.")

        return_list = []
        timelines = {t.id: t.name for t in self._sketch.list_timelines()}

        return_field_list = []
        return_fields = self.return_fields
        if return_fields:
            if return_fields.startswith("'"):
                return_fields = return_fields[1:]
            if return_fields.endswith("'"):
                return_fields = return_fields[:-1]
            return_field_list = return_fields.split(",")

        for result in self._raw_response.get("objects", []):
            source = result.get("_source", {})
            if not return_fields or "_id" in return_field_list:
                source["_id"] = result.get("_id")
            if not return_fields or "_type" in return_field_list:
                source["_type"] = result.get("_type")
            if not return_fields or "_index" in return_field_list:
                source["_index"] = result.get("_index")
            if not return_fields or "_source" in return_field_list:
                source["_source"] = timelines.get(result.get("__ts_timeline_id"))
            if not return_fields or "__ts_timeline_id" in return_field_list:
                source["_source"] = timelines.get(result.get("__ts_timeline_id"))

            return_list.append(source)

        data_frame = pandas.DataFrame(return_list)
        if "datetime" in data_frame:
            try:
                data_frame["datetime"] = pandas.to_datetime(data_frame.datetime)
            except pandas.errors.OutOfBoundsDatetime:
                pass
        elif "timestamp" in data_frame:
            try:
                data_frame["datetime"] = pandas.to_datetime(
                    data_frame.timestamp / 1e6, utc=True, unit="s"
                )
            except pandas.errors.OutOfBoundsDatetime:
                pass

        return data_frame

    @property
    def updated_at(self):
        """Property that returns back the updated time of a search."""
        return self._updated_at
