import json
import os
import copy
import warnings
from dataclasses import dataclass

from typing import Optional

import numpy as np
import tiledb
from tiledb import libtiledb
from tiledb import TileDBError


def check_dataframe_deps():
    pd_error = """Pandas version >= 1.0 required for dataframe functionality.
                  Please `pip install pandas>=1.0` to proceed."""
    pa_error = """PyArrow version >= 1.0 is suggested for dataframe functionality.
                  Please `pip install pyarrow>=1.0`."""

    from distutils.version import LooseVersion

    try:
        import pandas as pd
    except ImportError:
        raise Exception(pd_error)

    if LooseVersion(pd.__version__) < LooseVersion("1.0"):
        raise Exception(pd_error)

    try:
        import pyarrow as pa

        if LooseVersion(pa.__version__) < LooseVersion("1.0"):
            warnings.warn(pa_error)
    except ImportError:
        warnings.warn(pa_error)


# Note: 'None' is used to indicate optionality for many of these options
#       For example, if the `sparse` argument is unspecified we will default
#       to False (dense) unless the input has string or heterogenous indexes.
TILEDB_KWARG_DEFAULTS = {
    "ctx": None,
    "sparse": None,
    "index_dims": None,
    "allows_duplicates": True,
    "mode": "ingest",
    "attr_filters": None,
    "dim_filters": None,
    "coords_filters": None,
    "full_domain": False,
    "tile": None,
    "row_start_idx": None,
    "fillna": None,
    "column_types": None,
    "varlen_types": None,
    "capacity": None,
    "date_spec": None,
    "cell_order": "row-major",
    "tile_order": "row-major",
    "timestamp": None,
    "debug": None,
}


def parse_tiledb_kwargs(kwargs):
    parsed_args = dict(TILEDB_KWARG_DEFAULTS)

    for key in TILEDB_KWARG_DEFAULTS.keys():
        if key in kwargs:
            parsed_args[key] = kwargs.pop(key)

    return parsed_args


@dataclass(frozen=True)
class ColumnInfo:

    dtype: np.dtype
    repr: Optional[str] = None
    nullable: bool = False
    var: bool = False

    @classmethod
    def from_values(cls, array_like, varlen_types=()):
        from pandas.api import types as pd_types

        if pd_types.is_object_dtype(array_like):
            # Note: this does a full scan of the column... not sure what else to do here
            #       because Pandas allows mixed string column types (and actually has
            #       problems w/ allowing non-string types in object columns)
            inferred_dtype = pd_types.infer_dtype(array_like)
            if inferred_dtype == "bytes":
                return cls.from_dtype(np.bytes_)
            elif inferred_dtype == "string":
                # TODO we need to make sure this is actually convertible
                return cls.from_dtype(np.str_)
            else:
                raise NotImplementedError(
                    f"{inferred_dtype} inferred dtype not supported"
                )
        else:
            if not hasattr(array_like, "dtype"):
                array_like = np.asanyarray(array_like)
            return cls.from_dtype(array_like.dtype, varlen_types)

    @classmethod
    def from_dtype(cls, dtype, varlen_types=()):
        from pandas.api import types as pd_types

        dtype = pd_types.pandas_dtype(dtype)
        # Note: be careful if you rearrange the order of the following checks

        # extension types
        if pd_types.is_extension_array_dtype(dtype):
            if pd_types.is_bool_dtype(dtype):
                np_type = np.uint8
            else:
                # XXX Parametrized dtypes such as "foo[int32]") sometimes have a "subtype"
                # property that holds the "int32". If it exists use this, otherwise use
                # the standard type property
                np_type = getattr(dtype, "subtype", dtype.type)

            var = bool(varlen_types and dtype in varlen_types)
            if var:
                # currently TileDB-py doesn't support nullable var-length attributes
                nullable = False
            else:
                # currently nullability is a (private) property of ExtensionArray
                # see https://github.com/pandas-dev/pandas/issues/40574
                nullable = bool(dtype.construct_array_type()._can_hold_na)

            return cls(
                np.dtype(np_type),
                repr=dtype.name,
                nullable=nullable,
                var=var,
            )

        # bool type
        if pd_types.is_bool_dtype(dtype):
            return cls(np.dtype("uint8"), repr="bool")

        # complex types
        if pd_types.is_complex_dtype(dtype):
            raise NotImplementedError("complex dtype not supported")

        # remaining numeric types
        if pd_types.is_numeric_dtype(dtype):
            if dtype == np.float16 or hasattr(np, "float128") and dtype == np.float128:
                raise NotImplementedError(
                    "Only single and double precision float dtypes are supported"
                )
            return cls(dtype)

        # datetime types
        if pd_types.is_datetime64_any_dtype(dtype):
            if dtype == "datetime64[ns]":
                return cls(dtype)
            else:
                raise NotImplementedError(
                    "Only 'datetime64[ns]' datetime dtype is supported"
                )

        # string types
        # don't use pd_types.is_string_dtype() because it includes object types too
        if dtype.type in (np.bytes_, np.str_):
            return cls(dtype)

        raise NotImplementedError(f"{dtype} dtype not supported")


def _get_column_infos(df, column_types, varlen_types):
    column_infos = {}
    for name, column in df.items():
        if column_types and name in column_types:
            column_infos[name] = ColumnInfo.from_dtype(column_types[name], varlen_types)
        else:
            column_infos[name] = ColumnInfo.from_values(column, varlen_types)
    return column_infos


def _get_attrs(names, column_infos, attr_filters, ctx=None):
    attrs = []
    attr_reprs = {}
    for name in names:
        if isinstance(attr_filters, dict):
            filters = attr_filters.get(name)
        elif attr_filters is not None:
            filters = attr_filters
        else:
            filters = tiledb.FilterList([tiledb.ZstdFilter(1, ctx=ctx)], ctx=ctx)

        column_info = column_infos[name]
        attrs.append(
            tiledb.Attr(
                name=name,
                filters=filters,
                dtype=column_info.dtype,
                nullable=column_info.nullable,
                var=column_info.var,
                ctx=ctx,
            )
        )

        if column_info.repr is not None:
            attr_reprs[name] = column_info.repr

    return attrs, attr_reprs


def dim_for_column(
    ctx, name, dim_info, col, tile=None, full_domain=False, ndim=None, dim_filters=None
):
    if isinstance(col, np.ndarray):
        col_values = col
    else:
        col_values = col.values

    if tile is None:
        if ndim is None:
            raise TileDBError("Unexpected Nonetype ndim")

        if ndim == 1:
            tile = 10000
        elif ndim == 2:
            tile = 1000
        elif ndim == 3:
            tile = 100
        else:
            tile = 10

    dtype = dim_info.dtype

    if full_domain:
        if not dim_info.dtype in (np.bytes_, np.unicode_):
            # Use the full type domain, deferring to the constructor
            (dtype_min, dtype_max) = tiledb.libtiledb.dtype_range(dim_info.dtype)

            dim_max = dtype_max
            if dtype.kind == "M":
                date_unit = np.datetime_data(dtype)[0]
                dim_min = np.datetime64(dtype_min + 1, date_unit)
                tile_max = np.iinfo(np.uint64).max - tile

                # modular arithmetic gives misleading overflow warning.
                with np.errstate(over="ignore"):
                    dtype_range = np.uint64(dtype_max) - np.uint64(dtype_min)

                if np.abs(dtype_range) > tile_max:
                    dim_max = np.datetime64(dtype_max - tile, date_unit)
            elif dtype is np.int64:
                dim_min = dtype_min + 1
            else:
                dim_min = dtype_min

            if dtype.kind != "M" and np.issubdtype(dtype, np.integer):
                tile_max = np.iinfo(np.uint64).max - tile
                if np.abs(np.uint64(dtype_max) - np.uint64(dtype_min)) > tile_max:
                    dim_max = dtype_max - tile
        else:
            dim_min, dim_max = (None, None)

    else:
        dim_min = np.min(col_values)
        dim_max = np.max(col_values)

    if not dim_info.dtype in (np.bytes_, np.unicode_):
        if np.issubdtype(dtype, np.integer) or dtype.kind == "M":
            dim_range = np.uint64(np.abs(np.uint64(dim_max) - np.uint64(dim_min)))
            # we can't make a tile larger than the dimension range
            if dim_range < tile:
                tile = dim_range
            if tile < 1:
                tile = 1
        elif np.issubdtype(dtype, np.float64):
            dim_range = dim_max - dim_min
            if dim_range < tile:
                tile = np.ceil(dim_range)

    dim = tiledb.Dim(
        name=name,
        domain=(dim_min, dim_max),
        dtype=dim_info.dtype,
        tile=tile,
        filters=dim_filters,
    )

    return dim


def get_index_metadata(dataframe):
    md = dict()
    for index in dataframe.index.names:
        index_md_name = index
        if index == None:
            index_md_name = "__tiledb_rows"
        # Note: this may be expensive.
        values = dataframe.index.get_level_values(index)
        md[index_md_name] = ColumnInfo.from_values(values).dtype

    return md


def create_dims(
    ctx, dataframe, index_dims, tile=None, full_domain=False, sparse=None, filters=None
):
    import pandas as pd

    per_dim_tile = False
    if tile is not None:
        if isinstance(tile, dict):
            per_dim_tile = True

        # input check, can't do until after per_dim_tile
        if (
            per_dim_tile
            and not all(map(lambda x: isinstance(x, (int, float)), tile.values()))
        ) or (per_dim_tile is False and not isinstance(tile, (int, float))):
            raise ValueError(
                "Invalid tile kwarg: expected int or dict of column names mapped to ints. "
                "Got '{}'".format(tile)
            )

    index = dataframe.index
    index_name_values = []
    dim_types = []

    if isinstance(index, pd.MultiIndex):
        for name in index.names:
            values = index.get_level_values(name)
            index_name_values.append((name, values))
            dim_types.append(ColumnInfo.from_values(values))

    elif isinstance(index, (pd.Index, pd.RangeIndex, pd.Int64Index)):
        values = index.values
        name = getattr(index, "name", None)
        if name is None:
            name = "__tiledb_rows"
            dim_types.append(ColumnInfo.from_dtype(np.dtype("uint64")))
        else:
            dim_types.append(ColumnInfo.from_values(values))
        index_name_values.append((name, values))
    else:
        raise ValueError(f"Unhandled index type {type(index)}")

    if any(d.dtype in (np.bytes_, np.unicode_) for d in dim_types):
        if sparse is False:
            raise TileDBError("Cannot create dense array with string-typed dimensions")
        elif sparse is None:
            sparse = True

    d0 = dim_types[0]
    if not all(d0.dtype == d.dtype for d in dim_types[1:]):
        if sparse is False:
            raise TileDBError(
                "Cannot create dense array with heterogeneous dimension data types"
            )
        elif sparse is None:
            sparse = True

    # Fall back to default dense type if unspecified and not inferred from dimension types
    if sparse is None:
        sparse = False

    ndim = len(dim_types)

    dims = list()
    for idx, (name, values) in enumerate(index_name_values):
        if len(values) < 1:
            raise ValueError(f"Empty column '{name}' cannot be used as dimension")

        # get the FilterList, if any
        if isinstance(filters, dict):
            if name in filters:
                dim_filters = filters[name]
            else:
                dim_filters = None
        elif filters is not None:
            dim_filters = filters
        else:
            dim_filters = None

        if per_dim_tile and name in tile:
            dim_tile = tile[name]
        elif per_dim_tile:
            # in this case we fall back to the default
            dim_tile = None
        else:
            # in this case we use a scalar (type-checked earlier)
            dim_tile = tile

        dims.append(
            dim_for_column(
                ctx,
                name,
                dim_types[idx],
                values,
                tile=dim_tile,
                full_domain=full_domain,
                ndim=ndim,
                dim_filters=dim_filters,
            )
        )

    if index_dims:
        for name in index_dims:
            if per_dim_tile and name in tile:
                dim_tile = tile[name]
            elif per_dim_tile:
                # in this case we fall back to the default
                dim_tile = None
            else:
                # in this case we use a scalar  (type-checked earlier)
                dim_tile = tile

            # get the FilterList, if any
            if isinstance(filters, dict) and name in filters:
                dim_filters = filters[name]
            elif filters is not None:
                dim_filters = filters
            else:
                dim_filters = None

            col = dataframe[name]
            dims.append(
                dim_for_column(
                    ctx,
                    dataframe,
                    col.values,
                    name,
                    tile=dim_tile,
                    dim_filters=dim_filters,
                )
            )

    return dims, sparse


def write_array_metadata(array, attr_metadata=None, index_metadata=None):
    """
    :param array: open, writable TileDB array
    :param metadata: dict
    :return:
    """
    if attr_metadata:
        attr_md_dict = {n: str(t) for n, t in attr_metadata.items()}
        array.meta["__pandas_attribute_repr"] = json.dumps(attr_md_dict)
    if index_metadata:
        index_md_dict = {n: str(t) for n, t in index_metadata.items()}
        array.meta["__pandas_index_dims"] = json.dumps(index_md_dict)


def _df_to_np_arrays(df, column_infos, fillna):
    ret = {}
    nullmaps = {}
    for name, column in df.items():
        column_info = column_infos[name]

        if fillna is not None and name in fillna:
            column = column.fillna(fillna[name])

        to_numpy_kwargs = {}
        if not column_info.var:
            to_numpy_kwargs.update(dtype=column_info.dtype)

        if column_info.nullable:
            # use default 0/empty for the dtype
            to_numpy_kwargs.update(na_value=column_info.dtype.type())
            nullmaps[name] = (~column.isna()).to_numpy(dtype=np.uint8)

        ret[name] = column.to_numpy(**to_numpy_kwargs)

    return ret, nullmaps


def from_dataframe(uri, dataframe, **kwargs):
    # deprecated in 0.6.3
    warnings.warn(
        "tiledb.from_dataframe is deprecated; please use .from_pandas",
        DeprecationWarning,
    )

    from_pandas(uri, dataframe, **kwargs)


def from_pandas(uri, dataframe, **kwargs):
    """Create TileDB array at given URI from a Pandas dataframe

    Supports most Pandas series types, including nullable integers and
    bools.

    :param uri: URI for new TileDB array
    :param dataframe: pandas DataFrame
    :param mode: Creation mode, one of 'ingest' (default), 'schema_only', 'append'

    :Keyword Arguments: optional keyword arguments for TileDB conversion, see
    ``tiledb.from_csv`` for additional details.

    :raises: :py:exc:`tiledb.TileDBError`
    :return: None

    """
    check_dataframe_deps()
    import pandas as pd

    if "tiledb_args" in kwargs:
        tiledb_args = kwargs.pop("tiledb_args")
    else:
        tiledb_args = parse_tiledb_kwargs(kwargs)

    ctx = tiledb_args.get("ctx", None)
    tile_order = tiledb_args["tile_order"]
    cell_order = tiledb_args["cell_order"]
    allows_duplicates = tiledb_args.get("allows_duplicates", False)
    sparse = tiledb_args["sparse"]
    index_dims = tiledb_args.get("index_dims", None)
    mode = tiledb_args.get("mode", "ingest")
    attr_filters = tiledb_args.get("attr_filters", None)
    dim_filters = tiledb_args.get("dim_filters", None)
    coords_filters = tiledb_args.get("coords_filters", None)
    full_domain = tiledb_args.get("full_domain", False)
    capacity = tiledb_args.get("capacity", False)
    tile = tiledb_args.get("tile", None)
    nrows = tiledb_args.get("nrows", None)
    row_start_idx = tiledb_args.get("row_start_idx", None)
    fillna = tiledb_args.get("fillna", None)
    date_spec = tiledb_args.get("date_spec", None)
    column_types = tiledb_args.get("column_types", None)
    varlen_types = tiledb_args.get("varlen_types", None)

    if mode != "append" and tiledb.array_exists(uri):
        raise TileDBError("Array URI '{}' already exists!".format(uri))

    write = True
    create_array = True
    if mode is not None:
        if mode == "schema_only":
            write = False
        elif mode == "append":
            create_array = False
            if not sparse and row_start_idx is None:
                raise TileDBError(
                    "Cannot append to dense array without 'row_start_idx'"
                )
        elif mode != "ingest":
            raise TileDBError("Invalid mode specified ('{}')".format(mode))

    if sparse == False and (index_dims is None or "index_col" not in kwargs):
        full_domain = True

    if capacity is None:
        capacity = 0  # this will use the libtiledb internal default

    if ctx is None:
        ctx = tiledb.default_ctx()

    if date_spec:
        dataframe = dataframe.assign(
            **{
                name: pd.to_datetime(dataframe[name], format=format)
                for name, format in date_spec.items()
            }
        )

    column_infos = _get_column_infos(dataframe, column_types, varlen_types)

    if create_array:
        if nrows:
            if full_domain is None:
                full_domain = False

        # create the domain and attributes
        # if sparse==None then this function may return a default based on types
        dims, sparse = create_dims(
            ctx,
            dataframe,
            index_dims,
            sparse=sparse,
            tile=tile,
            full_domain=full_domain,
            filters=dim_filters,
        )

        domain = tiledb.Domain(*dims, ctx=ctx)

        attr_names = dataframe.columns
        # ignore any column used as a dim/index
        if index_dims:
            attr_names = (name for name in attr_names if name not in index_dims)

        attrs, attr_metadata = _get_attrs(
            attr_names, column_infos, attr_filters, ctx=ctx
        )

        # don't set allows_duplicates=True for dense
        allows_duplicates = allows_duplicates and sparse

        # now create the ArraySchema
        schema = tiledb.ArraySchema(
            domain=domain,
            attrs=attrs,
            cell_order=cell_order,
            tile_order=tile_order,
            coords_filters=coords_filters,
            allows_duplicates=allows_duplicates,
            capacity=capacity,
            sparse=sparse,
        )

        tiledb.Array.create(uri, schema, ctx=ctx)

        tiledb_args["mode"] = "append"

        # write the metadata so we can reconstruct dataframe
        index_metadata = get_index_metadata(dataframe)
        with tiledb.open(uri, "w", ctx=ctx) as A:
            write_array_metadata(A, attr_metadata, index_metadata)

    if write:
        write_dict, nullmaps = _df_to_np_arrays(dataframe, column_infos, fillna)

        if tiledb_args.get("debug", True):
            print("`tiledb.read_pandas` writing '{}' rows".format(len(dataframe)))

        timestamp = tiledb_args.get("timestamp", None)

        try:
            A = tiledb.open(uri, "w", timestamp=timestamp, ctx=ctx)

            if A.schema.sparse:
                coords = []
                for k in range(A.schema.ndim):
                    dim_name = A.schema.domain.dim(k).name
                    if (
                        not create_array
                        and dim_name not in dataframe.index.names
                        and dim_name != "__tiledb_rows"
                    ):
                        # this branch handles the situation where a user did not specify
                        # index_col and is using mode='append'. We would like to try writing
                        # with the columns corresponding to existing dimension name.
                        coords.append(write_dict.pop(dim_name))
                    else:
                        coords.append(dataframe.index.get_level_values(k))

                # TODO ensure correct col/dim ordering
                libtiledb._setitem_impl_sparse(A, tuple(coords), write_dict, nullmaps)

            else:
                if row_start_idx is None:
                    row_start_idx = 0
                row_end_idx = row_start_idx + len(dataframe)
                A._setitem_impl(slice(row_start_idx, row_end_idx), write_dict, nullmaps)
        finally:
            A.close()


def _tiledb_result_as_dataframe(readable_array, result_dict):
    import pandas as pd

    # TODO missing key in the rep map should only be a warning, return best-effort?
    # TODO this should be generalized for round-tripping overloadable types
    #      for any array (e.g. np.uint8 <> bool)
    repr_meta = None
    index_dims = None
    if "__pandas_attribute_repr" in readable_array.meta:
        # backwards compatibility
        repr_meta = json.loads(readable_array.meta["__pandas_attribute_repr"])
    if "__pandas_index_dims" in readable_array.meta:
        index_dims = json.loads(readable_array.meta["__pandas_index_dims"])

    rename_cols = dict()
    for col_name, col_val in result_dict.items():
        if repr_meta and col_name in repr_meta:
            new_col = pd.Series(col_val, dtype=repr_meta[col_name])
            result_dict[col_name] = new_col
        elif index_dims and col_name in index_dims:
            new_col = pd.Series(col_val, dtype=index_dims[col_name])
            result_dict[col_name] = new_col
            if col_name == "__tiledb_rows":
                rename_cols["__tiledb_rows"] = None

    for col_key, col_name in rename_cols.items():
        result_dict[col_name] = result_dict.pop(col_key)

    df = pd.DataFrame.from_dict(result_dict)

    if index_dims:
        index_names = [name for name in index_dims.keys() if name in result_dict]
        if index_names:
            df.set_index(index_names, inplace=True)
        elif len(index_dims) == 1:
            index_name = next(iter(index_dims))
            if index_name != "__tiledb_rows":
                df.index.rename(index_name, inplace=True)

    return df


def open_dataframe(uri, *, attrs=None, use_arrow=None, ctx=None):
    """Open TileDB array at given URI as a Pandas dataframe

    If the array was saved using tiledb.from_pandas, then columns
    will be interpreted as non-primitive pandas or numpy types when
    available.

    :param uri:
    :return: dataframe constructed from given TileDB array URI

    **Example:**

    >>> import tiledb
    >>> df = tiledb.open_dataframe("iris.tldb")
    >>> tiledb.object_type("iris.tldb")
    'array'
    """
    check_dataframe_deps()

    # TODO support `distributed=True` option?
    with tiledb.open(uri, ctx=ctx) as A:
        df = A.query(attrs=attrs, use_arrow=use_arrow).df[:]

    if attrs and list(df.columns) != list(attrs):
        df = df[attrs]

    return df


def _iterate_csvs_pandas(csv_list, pandas_args):
    """Iterate over a list of CSV files. Uses pandas.read_csv with pandas_args and returns
    a list of dataframe(s) for each iteration, up to the specified 'chunksize' argument in
    'pandas_args'
    """
    import pandas as pd

    assert "chunksize" in pandas_args
    chunksize = pandas_args["chunksize"]

    rows_read = 0
    result_list = list()

    file_iter = iter(csv_list)
    next_file = next(file_iter, None)
    while next_file is not None:
        df_iter = pd.read_csv(next_file, **pandas_args)
        df_iter.chunksize = chunksize - rows_read

        df = next(df_iter, None)
        while df is not None:
            result_list.append(df)
            rows_read += len(df)
            df_iter.chunksize = chunksize - rows_read

            if rows_read == chunksize:
                yield result_list
                # start over
                rows_read = 0
                df_iter.chunksize = chunksize
                result_list = list()

            df = next(df_iter, None)

        next_file = next(file_iter, None)
        if next_file is None and len(result_list) > 0:
            yield result_list


def from_csv(uri, csv_file, **kwargs):
    """
    Create TileDB array at given URI from a CSV file or list of files

    :param uri: URI for new TileDB array
    :param csv_file: input CSV file or list of CSV files.
                     Note: multi-file ingestion requires a `chunksize` argument. Files will
                     be read in batches of at least `chunksize` rows before writing to the
                     TileDB array.

    :Keyword Arguments:
        - Any ``pandas.read_csv`` supported keyword argument.
        - TileDB-specific arguments:
            * ``allows_duplicates``: Generated schema should allow duplicates
            * ``cell_order``: Schema cell order
            * ``tile_order``: Schema tile order
            * ``mode``: (default ``ingest``), Ingestion mode: ``ingest``, ``schema_only``,
              ``append``
            * ``full_domain``: Dimensions should be created with full range of the dtype
            * ``attr_filters``: FilterList to apply to Attributes: FilterList or Dict[str -> FilterList]
                for any attribute(s). Unspecified attributes will use default.
            * ``dim_filters``: FilterList to apply to Dimensions: FilterList or Dict[str -> FilterList]
                for any dimensions(s). Unspecified dimensions will use default.
            * ``coords_filters``: FilterList to apply to all coordinates (Dimensions)
            * ``sparse``: (default True) Create sparse schema
            * ``tile``: Dimension tiling: accepts either an int that applies the tiling to all dimensions
                or a dict("dim_name": int) to specifically assign tiling to a given dimension
            * ``capacity``: Schema capacity.
            * ``timestamp``: Write TileDB array at specific timestamp.
            * ``row_start_idx``: Start index to start new write (for row-indexed ingestions).
            * ``date_spec``: Dictionary of {``column_name``: format_spec} to apply to date/time
              columns which are not correctly inferred by pandas 'parse_dates'.
              Format must be specified using the Python format codes:
              https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior
    :return: None

    **Example:**

    >>> import tiledb
    >>> tiledb.from_csv("iris.tldb", "iris.csv")
    >>> tiledb.object_type("iris.tldb")
    'array'
    """
    check_dataframe_deps()
    import pandas

    if "tiledb_args" in kwargs:
        tiledb_args = kwargs.get("tiledb_args")
    else:
        tiledb_args = parse_tiledb_kwargs(kwargs)

    multi_file = False
    debug = tiledb_args.get("debug", False)

    pandas_args = copy.deepcopy(kwargs)

    ##########################################################################
    # set up common arguments
    ##########################################################################
    if isinstance(csv_file, str) and not os.path.isfile(csv_file):
        # for non-local files, use TileDB VFS i/o
        ctx = tiledb_args.get("ctx", tiledb.default_ctx())
        vfs = tiledb.VFS(ctx=ctx)
        csv_file = tiledb.FileIO(vfs, csv_file, mode="rb")
    elif isinstance(csv_file, (list, tuple)):
        # TODO may be useful to support a filter callback here
        multi_file = True

    mode = tiledb_args.get("mode", None)
    if mode is not None:
        # For schema_only mode we need to pass a max read count into
        #   pandas.read_csv
        # Note that 'nrows' is a pandas arg!
        if mode == "schema_only" and not "nrows" in kwargs:
            pandas_args["nrows"] = 500
        elif mode not in ["ingest", "append"]:
            raise TileDBError("Invalid mode specified ('{}')".format(mode))

    if mode != "append" and tiledb.array_exists(uri):
        raise TileDBError("Array URI '{}' already exists!".format(uri))

    # this is a pandas pass-through argument, do not pop!
    chunksize = kwargs.get("chunksize", None)

    if multi_file and not (chunksize or mode == "schema_only"):
        raise TileDBError("Multiple input CSV files requires a 'chunksize' argument")

    if multi_file:
        input_csv_list = csv_file
    else:
        input_csv = csv_file

    ##########################################################################
    # handle multi_file and chunked arguments
    ##########################################################################
    # we need to use full-domain for multi or chunked reads, because we
    # won't get a chance to see the full range during schema creation
    if multi_file or chunksize is not None:
        if not "nrows" in kwargs:
            tiledb_args["full_domain"] = True

    ##########################################################################
    # read path
    ##########################################################################
    if multi_file:
        array_created = False
        if mode == "append":
            array_created = True

        rows_written = 0

        # multi-file or chunked always writes to full domain
        # TODO: allow specifying dimension range for schema creation
        tiledb_args["full_domain"] = True

        for df_list in _iterate_csvs_pandas(input_csv_list, pandas_args):
            if df_list is None:
                break
            df = pandas.concat(df_list)
            tiledb_args["row_start_idx"] = rows_written

            from_pandas(uri, df, tiledb_args=tiledb_args, pandas_args=pandas_args)

            rows_written += len(df)

            if mode == "schema_only":
                break

    elif chunksize is not None:
        rows_written = 0
        # for chunked reads, we need to iterate over chunks
        df_iter = pandas.read_csv(input_csv, **pandas_args)
        df = next(df_iter, None)
        while df is not None:
            # tell from_pandas what row to start the next write
            tiledb_args["row_start_idx"] = rows_written

            from_pandas(uri, df, tiledb_args=tiledb_args, pandas_args=pandas_args)

            tiledb_args["mode"] = "append"
            rows_written += len(df)

            df = next(df_iter, None)

    else:
        df = pandas.read_csv(csv_file, **kwargs)

        kwargs.update(tiledb_args)
        from_pandas(uri, df, **kwargs)
