import logging
from collections.abc import Sized
from typing import Any, List, Optional

from qcodes.dataset.data_set import SPECS, DataSet, load_by_id, new_data_set
from qcodes.dataset.experiment_settings import _set_default_experiment_id
from qcodes.dataset.sqlite.connection import ConnectionPlus, path_to_dbfile
from qcodes.dataset.sqlite.database import (
    conn_from_dbpath_or_conn,
    connect,
    get_DB_location,
)
from qcodes.dataset.sqlite.queries import (
    finish_experiment,
    get_experiment_name_from_experiment_id,
    get_experiments,
    get_last_experiment,
    get_last_run,
    get_matching_exp_ids,
    get_run_counter,
    get_runid_from_expid_and_counter,
    get_runs,
    get_sample_name_from_experiment_id,
)
from qcodes.dataset.sqlite.queries import new_experiment as ne
from qcodes.dataset.sqlite.query_helpers import VALUES, select_one_where

log = logging.getLogger(__name__)


class Experiment(Sized):
    def __init__(self, path_to_db: Optional[str] = None,
                 exp_id: Optional[int] = None,
                 name: Optional[str] = None,
                 sample_name: Optional[str] = None,
                 format_string: str = "{}-{}-{}",
                 conn: Optional[ConnectionPlus] = None) -> None:
        """
        Create or load an experiment. If exp_id is None, a new experiment is
        created. If exp_id is not None, an experiment is loaded.

        Args:
            path_to_db: The path of the database file to create in/load from.
              If a conn is passed together with path_to_db, an exception is
              raised
            exp_id: The id of the experiment to load
            name: The name of the experiment to create. Ignored if exp_id is
              not None
            sample_name: The sample name for this experiment. Ignored if exp_id
              is not None
            format_string: The format string used to name result-tables.
              Ignored if exp_id is not None.
            conn: connection to the database. If not supplied, the constructor
              first tries to use path_to_db to figure out where to connect to.
              If path_to_db is not supplied either, a new connection
              to the DB file specified in the config is made
        """

        self.conn = conn_from_dbpath_or_conn(conn, path_to_db)

        max_id = len(get_experiments(self.conn))

        if exp_id is not None:
            if exp_id not in range(1, max_id+1):
                raise ValueError('No such experiment in the database')
            self._exp_id = exp_id
        else:

            # it is better to catch an invalid format string earlier than later
            try:
                # the corresponding function from sqlite module will try to
                # format as `(name, exp_id, run_counter)`, hence we prepare
                # for that here
                format_string.format("name", 1, 1)
            except Exception as e:
                raise ValueError("Invalid format string. Can not format "
                                 "(name, exp_id, run_counter)") from e

            log.info(f"creating new experiment in {self.path_to_db}")

            name = name or f"experiment_{max_id+1}"
            sample_name = sample_name or "some_sample"
            self._exp_id = ne(self.conn, name, sample_name, format_string)

    @property
    def exp_id(self) -> int:
        return self._exp_id

    @property
    def path_to_db(self) -> str:
        return self.conn.path_to_dbfile

    @property
    def name(self) -> str:
        return get_experiment_name_from_experiment_id(self.conn, self.exp_id)

    @property
    def sample_name(self) -> str:
        return get_sample_name_from_experiment_id(self.conn, self.exp_id)

    @property
    def last_counter(self) -> int:
        return get_run_counter(self.conn, self.exp_id)

    @property
    def started_at(self) -> int:
        return select_one_where(self.conn, "experiments", "start_time",
                                "exp_id", self.exp_id)

    @property
    def finished_at(self) -> int:
        return select_one_where(self.conn, "experiments", "end_time",
                                "exp_id", self.exp_id)

    @property
    def format_string(self) -> str:
        return select_one_where(self.conn, "experiments", "format_string",
                                "exp_id", self.exp_id)

    def new_data_set(self, name: str,
                     specs: Optional[SPECS] = None,
                     values: Optional[VALUES] = None,
                     metadata: Optional[Any] = None) -> DataSet:
        """
        Create a new dataset in this experiment

        Args:
            name: the name of the new dataset
            specs: list of parameters (as ParamSpecs) to create this data_set
                with
            values: the values to associate with the parameters
            metadata: the metadata to associate with the dataset
        """
        return new_data_set(name, self.exp_id, specs, values, metadata,
                            conn=self.conn)

    def data_set(self, counter: int) -> DataSet:
        """
        Get dataset with the specified counter from this experiment

        Args:
            counter: the counter of the run we want to load

        Returns:
            the dataset
        """
        run_id = get_runid_from_expid_and_counter(self.conn, self.exp_id,
                                                  counter)
        return DataSet(run_id=run_id, conn=self.conn)

    def data_sets(self) -> List[DataSet]:
        """Get all the datasets of this experiment"""
        runs = get_runs(self.conn, self.exp_id)
        return [load_by_id(run['run_id'], conn=self.conn) for run in runs]

    def last_data_set(self) -> DataSet:
        """Get the last dataset of this experiment"""
        run_id = get_last_run(self.conn, self.exp_id)
        if run_id is None:
            raise ValueError('There are no runs in this experiment')
        return load_by_id(run_id)

    def finish(self) -> None:
        """
        Marks this experiment as finished by saving the moment in time
        when this method is called
        """
        finish_experiment(self.conn, self.exp_id)

    def __len__(self) -> int:
        return len(self.data_sets())

    def __repr__(self) -> str:
        out = [
            f"{self.name}#{self.sample_name}#{self.exp_id}@{self.path_to_db}"
        ]
        out.append("-" * len(out[0]))
        out += [
            f"{d.run_id}-{d.name}-{d.counter}-{d.parameters}-{len(d)}"
            for d in self.data_sets()
        ]
        return "\n".join(out)


# public api

def experiments(conn: Optional[ConnectionPlus] = None) -> List[Experiment]:
    """
    List all the experiments in the container (database file from config)

    Args:
        conn: connection to the database. If not supplied, a new connection
          to the DB file specified in the config is made

    Returns:
        All the experiments in the container
    """
    conn = conn_from_dbpath_or_conn(conn=conn, path_to_db=None)
    log.info(f"loading experiments from {conn.path_to_dbfile}")
    rows = get_experiments(conn)
    return [load_experiment(row['exp_id'], conn) for row in rows]


def new_experiment(name: str,
                   sample_name: Optional[str],
                   format_string: str = "{}-{}-{}",
                   conn: Optional[ConnectionPlus] = None) -> Experiment:
    """
    Create a new experiment (in the database file from config)

    Args:
        name: the name of the experiment
        sample_name: the name of the current sample
        format_string: basic format string for table-name
            must contain 3 placeholders.
        conn: connection to the database. If not supplied, a new connection
          to the DB file specified in the config is made
    Returns:
        the new experiment
    """
    sample_name = sample_name or "some_sample"
    conn = conn or connect(get_DB_location())
    exp_ids = get_matching_exp_ids(conn, name=name, sample_name=sample_name)
    if len(exp_ids) >= 1:
        log.warning(
            f"There is (are) already experiment(s) with the name of {name} "
            f"and sample name of {sample_name} in the database."
        )
    experiment = Experiment(
        name=name, sample_name=sample_name, format_string=format_string, conn=conn
    )
    _set_default_experiment_id(path_to_dbfile(conn), experiment.exp_id)
    return experiment


def load_experiment(exp_id: int,
                    conn: Optional[ConnectionPlus] = None) -> Experiment:
    """
    Load experiment with the specified id (from database file from config)

    Args:
        exp_id: experiment id
        conn: connection to the database. If not supplied, a new connection
          to the DB file specified in the config is made

    Returns:
        experiment with the specified id
    """
    conn = conn_from_dbpath_or_conn(conn=conn, path_to_db=None)
    if not isinstance(exp_id, int):
        raise ValueError('Experiment ID must be an integer')
    experiment = Experiment(exp_id=exp_id, conn=conn)
    _set_default_experiment_id(path_to_dbfile(conn), experiment.exp_id)
    return experiment


def load_last_experiment() -> Experiment:
    """
    Load last experiment (from database file from config)

    Returns:
        last experiment
    """
    conn = connect(get_DB_location())
    last_exp_id = get_last_experiment(conn)
    if last_exp_id is None:
        raise ValueError('There are no experiments in the database file')
    experiment = Experiment(exp_id=last_exp_id)
    _set_default_experiment_id(get_DB_location(), experiment.exp_id)
    return experiment


def load_experiment_by_name(
    name: str,
    sample: Optional[str] = None,
    conn: Optional[ConnectionPlus] = None,
    load_last_duplicate: bool = False,
) -> Experiment:
    """
    Try to load experiment with the specified name.

    Nothing stops you from having many experiments with the same name and
    sample name. In that case this won't work unless load_last_duplicate
    is set to True. Then, the last of duplicated experiments will be loaded.

    Args:
        name: the name of the experiment
        sample: the name of the sample
        load_last_duplicate: If True, prevent raising error for having
            multiple experiments with the same name and sample name, and
            load the last duplicated experiment, instead.
        conn: connection to the database. If not supplied, a new connection
            to the DB file specified in the config is made

    Returns:
        the requested experiment

    Raises:
        ValueError either if the name and sample name are not unique, unless
        load_last_duplicate is True, or if no experiment found for the
        supplied name and sample.
        .
    """
    conn = conn or connect(get_DB_location())
    if sample is not None:
        args_to_find = {"name": name, "sample_name": sample}
    else:
        args_to_find = {"name": name}
    exp_ids = get_matching_exp_ids(conn, **args_to_find)
    if len(exp_ids) == 0:
        raise ValueError("Experiment not found")
    elif len(exp_ids) > 1:
        _repr = []
        for exp_id in exp_ids:
            exp = load_experiment(exp_id, conn=conn)
            s = (
                f"exp_id:{exp.exp_id} ({exp.name}-{exp.sample_name})"
                f" started at ({exp.started_at})"
            )
            _repr.append(s)
        _repr_str = "\n".join(_repr)
        if load_last_duplicate:
            e = exp
        else:
            raise ValueError(
                f"Many experiments matching your request" f" found:\n{_repr_str}"
            )
    else:
        e = Experiment(exp_id=exp_ids[0], conn=conn)
    _set_default_experiment_id(path_to_dbfile(conn), e.exp_id)
    return e


def load_or_create_experiment(
    experiment_name: str,
    sample_name: Optional[str] = None,
    conn: Optional[ConnectionPlus] = None,
    load_last_duplicate: bool = False,
) -> Experiment:
    """
    Find and return an experiment with the given name and sample name,
    or create one if not found.

    Args:
        experiment_name: Name of the experiment to find or create.
        sample_name: Name of the sample.
        load_last_duplicate: If True, prevent raising error for having
            multiple experiments with the same name and sample name, and
            load the last duplicated experiment, instead.
        conn: Connection to the database. If not supplied, a new connection
            to the DB file specified in the config is made.

    Returns:
        The found or created experiment
    """
    conn = conn or connect(get_DB_location())
    try:
        experiment = load_experiment_by_name(
            experiment_name,
            sample_name,
            load_last_duplicate=load_last_duplicate,
            conn=conn,
        )
    except ValueError as exception:
        if "Experiment not found" in str(exception):
            experiment = new_experiment(experiment_name, sample_name,
                                        conn=conn)
        else:
            raise exception
    return experiment
