from copy import copy
import collections
import string
import itertools
import threading
import re
import time
import json

from visidata import options, anytype, stacktrace, vd, drawcache
from visidata import asyncthread, dispwidth, clipstr, iterchars
from visidata import wrapply, TypedWrapper, TypedExceptionWrapper
from visidata import Extensible, AttrDict, undoAttrFunc, ExplodingMock, MissingAttrFormatter
from visidata import getitem, setitem, getitemdef, getitemdeep, setitemdeep, getattrdeep, setattrdeep, iterchunks

class InProgress(Exception):
    @property
    def stacktrace(self):
        return  ['calculation in progress']

INPROGRESS = TypedExceptionWrapper(None, exception=InProgress())  # sentinel

vd.option('col_cache_size', 0, 'max number of cache entries in each cached column')
vd.option('disp_formatter', 'generic', 'formatter to create the text in each cell (also used by text savers)', replay=True)
vd.option('disp_displayer', 'generic', 'displayer to render the text in each cell', replay=False)


class DisplayWrapper:
    def __init__(self, value=None, *, typedval=None, text='', note=None, notecolor=None, error=None):
        self.value = value      # actual value (any type)
        self.typedval = typedval  # consistently typed value (or None)
        self.text = text  # displayed string
        self.note = note        # single unicode character displayed in cell far right
        self.notecolor = notecolor  # configurable color name (like 'color_warning')
        self.error = error      # list of strings for stacktrace

    def __bool__(self):
        return bool(self.value)

    def __eq__(self, other):
        return self.value == other


class Column(Extensible):
    '''Base class for all column types.

        - *name*: name of this column; if None, current sheet will assign a name
        - *type*: ``anytype str int float date`` or other type-like conversion function.
        - *cache*: cache behavior

           - ``False`` (default): getValue never caches; calcValue is always called.
           - ``True``: getValue maintains a cache of ``options.col_cache_size``.
           - ``"async"``: ``getValue`` launches thread for every uncached result, returns invalid value until cache entry available.
        - *width*: == 0 if hidden, None if auto-compute next time.
        - *height*: max height, None/0 to auto-compute for each row.
        - *fmtstr*: format string as applied by column type.
        - *getter*: default calcValue calls ``getter(col, row)``.
        - *setter*: default putValue calls ``setter(col, row, val)``.
        - *kwargs*: other attributes to be set on this column.
    '''
    def __init__(self, name=None, *, type=anytype, cache=False, **kwargs):
        self.sheet = ExplodingMock('use addColumn() on all columns')  # owning Sheet, set in .recalc() via Sheet.addColumn
        if name is None:
            if vd.sheet: # get a column name from the current sheet
                name = vd.sheet.incremented_colname()
            else:
                name = ''
        self.name = str(name) # display visible name
        self.fmtstr = ''      # by default, use str()
        self._type = type     # anytype/str/int/float/date/func
        self.getter = lambda col, row: row
        self.setter = None
        self._width = None    # == 0 if hidden, None if auto-compute next time
        self.hoffset = 0      # starting horizontal (char) offset of displayed column value
        self.voffset = 0      # starting vertical (line) offset of displayed column value
        self.height = 1       # max height, None/0 to auto-compute for each row
        self.keycol = 0       # keycol index (or 0 if not key column)
        self.expr = None      # Column-type-dependent parameter
        self.formatter = ''
        self.displayer = ''
        self.defer = False
        self.disp_expert = 0    # do not show if 'nometacols' in options.disp_help_flags

        self.setCache(cache)
        for k, v in kwargs.items():
            setattr(self, k, v)  # instead of __dict__.update(kwargs) to invoke property.setters

    def __copy__(self):
        cls = self.__class__
        ret = cls.__new__(cls)
        ret.__dict__.update(self.__dict__)
        ret.keycol = 0   # column copies lose their key status
        if self._cachedValues is not None:
            ret._cachedValues = collections.OrderedDict()  # an unrelated cache for copied columns
        return ret

    def __str__(self):
        return f'{type(self).__name__}:{self.name}'

    def __repr__(self):
        return f'<{type(self).__name__}: {self.name}>'

    def __deepcopy__(self, memo):
        return self.__copy__()  # no separate deepcopy

    def __getstate__(self):
        return {k:getattr(self, k) for k in 'name typestr width height expr keycol formatter displayer fmtstr voffset hoffset aggstr'.split() if hasattr(self, k)}

    def __setstate__(self, d):
        for attr, v in d.items():
            setattr(self, attr, v)

    def recalc(self, sheet=None):
        'Reset column cache, attach column to *sheet*, and reify column name.'
        if self._cachedValues:
            self._cachedValues.clear()
        if sheet:
            self.sheet = sheet
        self.name = self._name

    @property
    def name(self):
        'Name of this column.'
        return self._name or ''

    @name.setter
    def name(self, name):
        self.setName(name)

    def setName(self, name):
        if name is None:
            name = ''
        if isinstance(name, str):
            name = name.strip()
        else:
            name = str(name)

        if self.sheet:
            name = self.sheet.maybeClean(name)
            if name != self._name:
                self.sheet.setModified()

        self._name = name

    @property
    def typestr(self):
        'Type of this column as string.'
        return self._type.__name__

    @typestr.setter
    def typestr(self, v):
        self.type = vd.getGlobals()[v or 'anytype']

    @property
    def type(self):
        'Type of this column.'
        return self._type

    @type.setter
    def type(self, t):
        if self._type != t:
            vd.addUndo(setattr, self, '_type', self.type)
            if self.sheet:
                self.sheet.setModified()
        if not t:
            self._type = anytype
        elif isinstance(t, str):
            self.typestr = t
        else:
            self._type = t

    @property
    def width(self):
        'Width of this column in characters.  0 or negative means hidden.  None means not-yet-autocomputed.'
        return self._width

    @width.setter
    def width(self, w):
        if self.width != w:
            vd.addUndo(setattr, self, '_width', self.width)
            if self.sheet:
                self.sheet.setModified()
            self._width = w

    @property
    def formatted_help(self):
        return MissingAttrFormatter().format(self.help, sheet=self.sheet, col=self, vd=vd)

    @property
    def help_formatters(self):
        formatters = [k[10:] for k in dir(self) if k.startswith('formatter_')]
        return ' '.join(formatters)

    @property
    def help_displayers(self):
        displayers = [k[10:] for k in dir(self) if k.startswith('displayer_')]
        return ' '.join(displayers)

    @property
    def _formatdict(col):
        if '=' in col.fmtstr:
            return dict(val.split('=', maxsplit=1) for val in col.fmtstr.split())
        return {}

    @property
    def fmtstr(self):
        'Format string to use to display this column.'
        return self._fmtstr or vd.getType(self.type).fmtstr

    @fmtstr.setter
    def fmtstr(self, v):
        if self.sheet and v != self._fmtstr:
            self.sheet.setModified()
        self._fmtstr = v

    def _format_len(self, typedval, **kwargs):
        if isinstance(typedval, dict):
            return f'{{{len(typedval)}}}'
        elif isinstance(typedval, (list, tuple)):
            return f'[{len(typedval)}]'

        return self.formatValue(typedval, **kwargs)

    def formatter_len(self, fmtstr):
        return self._format_len

    def formatter_generic(self, fmtstr):
        return self.formatValue

    def formatter_json(self, fmtstr):
        return lambda v,*args,**kwargs: json.dumps(v)

    def formatter_python(self, fmtstr):
        return lambda v,*args,**kwargs: str(v)

    @drawcache
    def make_formatter(self):
        'Return function for format(v) from the current formatter and fmtstr'
        _formatMaker = getattr(self, 'formatter_'+(self.formatter or self.sheet.options.disp_formatter))
        return _formatMaker(self._formatdict)

    def format(self, *args, **kwargs):
        return self.make_formatter()(*args, **kwargs)

    def formatValue(self, typedval, width=None):
        '''Return displayable string of *typedval* according to ``Column.fmtstr``.
        If *width* is not None, values are clipped to that width when *typedval*
        is a dict/list/tuple, but not for other types.'''
        if typedval is None:
            return None

        if self.type is anytype:
            if isinstance(typedval, (dict, list, tuple)):
                if width is None:
                    return ''.join(iterchars(typedval))
                dispval, dispw = clipstr(iterchars(typedval), width-1) #subtract 1 for left-side margin
                return dispval

        if isinstance(typedval, bytes):
            typedval = typedval.decode(options.encoding, options.encoding_errors)

        gt = vd.getType(self._type)
        return gt.formatter(self._fmtstr or gt.fmtstr, typedval)

    def displayer_generic(self, dw:DisplayWrapper, width=None):
        '''Fit *dw.text* into *width* charcells.
           Generate list of (attr:str, text:str) suitable for clipdraw_chunks.

           The 'generic' displayer does not do any formatting.
        '''
        if width is not None and width > 1 and \
                vd.isNumeric(self) and \
                isinstance(dw.typedval, (int, float)):
            yield ('', dw.text.rjust(width-2))
        else:
            yield ('', dw.text)

    def displayer_full(self, dw:DisplayWrapper, width=None):
        '''Fit *dw.text* into *width* charcells.
           Generate list of (attr:str, text:str) suitable for clipdraw_chunks.

           The 'full' displayer allows formatting like [:color].
        '''
        if width is not None and width > 1 and vd.isNumeric(self):
            yield from iterchunks(dw.text.rjust(width-2))
        else:
            yield from iterchunks(dw.text)

    def display(self, *args, **kwargs):
        f = getattr(self, 'displayer_'+(self.displayer or self.sheet.options.disp_displayer), self.displayer_generic)
        return f(*args, **kwargs)

    def hide(self, hide=True):
        if hide:
            self.setWidth(0)
        else:
            self.setWidth(abs(self.width or self.getMaxWidth(self.sheet.visibleRows)))

    @property
    def readonly(self):
        return self.setter is None

    @property
    def hidden(self):
        'Return True if width of this column is 0 or negative.'
        if self.width is None:
            return False
        return self.width <= 0

    def calcValue(self, row):
        'Calculate and return value for *row* in this column.'
        return (self.getter)(self, row)

    def getTypedValue(self, row):
        'Return the properly-typed value for the given row at this column, or a TypedWrapper object in case of null or error.'
        return wrapply(self.type, wrapply(self.getValue, row))

    def setCache(self, cache):
        '''Set cache behavior for this column to *cache*:

           - ``False`` (default): getValue never caches; calcValue is always called.
           - ``True``: getValue maintains a cache of ``options.col_cache_size``.
           - ``"async"``: ``getValue`` launches thread for every uncached result, maintains cache of infinite size.  Returns invalid value until cache entry available.'''
        self.cache = cache
        self._cachedValues = collections.OrderedDict() if self.cache else None

    @asyncthread
    def _calcIntoCacheAsync(self, row):
        # causes issues when moved into _calcIntoCache gen case
        self._cachedValues[self.sheet.rowid(row)] = INPROGRESS
        self._calcIntoCache(row)

    def _calcIntoCache(self, row):
        ret = wrapply(self.calcValue, row)
        if not isinstance(ret, TypedExceptionWrapper) or ret.val is not INPROGRESS:
            self._cachedValues[self.sheet.rowid(row)] = ret
        return ret

    def getValue(self, row):
        'Return value for *row* in this column, calculating if not cached.'

        if self.defer:
            try:
                row, rowmods = self.sheet._deferredMods[self.sheet.rowid(row)]
                return rowmods[self]
            except KeyError:
                pass

        if self._cachedValues is None:
            return self.calcValue(row)

        k = self.sheet.rowid(row)
        if k in self._cachedValues:
            return self._cachedValues[k]

        if self.cache == 'async':
            ret = self._calcIntoCacheAsync(row)
        else:
            ret = self._calcIntoCache(row)

            cachesize = options.col_cache_size
            if cachesize > 0 and len(self._cachedValues) > cachesize:
                self._cachedValues.popitem(last=False)

        return ret

    def getCell(self, row):
        '''Return DisplayWrapper for displayable cell value.
        For dict/list/tuple cells, the width of the value returned is capped at the column width.'''
        cellval = wrapply(self.getValue, row)
        typedval = wrapply(self.type, cellval)

        if isinstance(typedval, TypedWrapper):
            if isinstance(cellval, TypedExceptionWrapper):  # calc failed
                exc = cellval.exception
                if cellval.forwarded:
                    dispval = str(cellval)  # traceback.format_exception_only(type(exc), exc)[-1].strip()
                else:
                    dispval = options.disp_error_val
                return DisplayWrapper(cellval.val, error=exc.stacktrace,
                                        text=dispval,
                                        note=f'[:onclick error-cell]{options.disp_note_getexc}[:]',
                                        notecolor='color_error')
            elif typedval.val is None:  # early out for strict None
                return DisplayWrapper(None, text='',  # force empty display for None
                                            note=options.disp_note_none,
                                            notecolor='color_note_type')
            elif isinstance(typedval, TypedExceptionWrapper):  # calc succeeded, type failed
                return DisplayWrapper(typedval.val, text=str(cellval),
                                            error=typedval.stacktrace,
                                            note=f'[:onclick error-cell]{options.disp_note_typeexc}[:]',
                                            notecolor='color_warning')
            else:
                return DisplayWrapper(typedval.val, text=str(typedval.val),
                                            error=['unknown'],
                                            note=f'[:onclick error-cell]{options.disp_note_typeexc}[:]',
                                            notecolor='color_warning')
        elif isinstance(typedval, threading.Thread):
            return DisplayWrapper(None,
                                text=options.disp_pending,
                                note=options.disp_note_pending,
                                notecolor='color_note_pending')

        dw = DisplayWrapper(cellval)
        dw.typedval = typedval

        try:
            dw.text = self.format(typedval, width=self.width) or ''

            # annotate cells with raw value type in anytype columns, except for strings
            if self.type is anytype and type(cellval) is not str:
                typedesc = vd.typemap.get(type(cellval), None)
                if typedesc:
                    dw.note = typedesc.icon
                    dw.notecolor = 'color_note_type'

        except Exception as e:  # formatting failure
            e.stacktrace = stacktrace()
            dw.error = e.stacktrace
            try:
                dw.text = str(cellval)
            except Exception as e:
                dw.text = str(e)
            dw.note = options.disp_note_fmtexc
            dw.notecolor = 'color_warning'

#        dw.display = self.display(dw)   # set during draw() once colwidth is known
        return dw

    def getDisplayValue(self, row):
        '''Return string displayed in this column for given *row*.
        For dict/list/tuple cells, the width of the display value returned is capped at the column width.'''
        return self.getCell(row).text

    def getFullDisplayValue(self, row):
        '''Return display string for *row* in this column, without width truncation.'''
        typedval = self.getTypedValue(row)
        if isinstance(typedval, TypedWrapper):
            return self.getCell(row).text
        try:
            return self.format(typedval) or ''
        except Exception:
            return self.getCell(row).text

    def putValue(self, row, val):
        'Change value for *row* in this column to *val* immediately.  Does not check the type.  Overridable; by default calls ``.setter(row, val)``.'
        if self.setter:
            return self.setter(self, row, val)

    def setValue(self, row, val, setModified=True):
        'Change value for *row* in this column to *val*.  Call ``putValue`` immediately if not a deferred column (added to deferred parent at load-time); otherwise cache until later ``putChanges``.  Caller must add undo function.'
        if self.defer:
            self.cellChanged(row, val)
        else:
            self.putValue(row, val)
        if self._cachedValues is not None:  #3155 evict stale entry so undo/setValue show on cached col
            self._cachedValues.pop(self.sheet.rowid(row), None)
        if setModified:  #1800
            self.sheet.setModified()

    @asyncthread
    def setValues(self, rows, *values):
        'Set values in this column for *rows* to *values*, recycling values as needed to fill *rows*.'
        if self.readonly:
            vd.fail("cannot set values on readonly column")

        vd.addUndoSetValues([self], rows)

        nsets = 0
        for r, v in zip(rows, itertools.cycle(values)):
            try:
                self.setValue(r, v)
                nsets += 1
            except Exception as e:
                vd.exceptionCaught(e)

        self.recalc()
        if nsets:
            return vd.status(f'set {nsets} cells to {len(values)} values')

    @asyncthread
    def setValuesTyped(self, rows, *values):
        'Set values on this column for *rows* to *values*, coerced to column type, recycling values as needed to fill *rows*.  Abort on type exception.'
        if self.readonly:
            vd.fail("cannot set values on readonly column")

        vd.addUndoSetValues([self], rows)
        nsets = 0
        for r, v in zip(vd.Progress(rows, gerund='setting', total=len(rows)), itertools.cycle(self.type(val) for val in values)):
            try:
                self.setValue(r, v)
                nsets += 1
            except Exception as e:
                vd.exceptionCaught(e)

        self.recalc()

        if nsets:
            return vd.status(f'set {nsets} cells to {len(values)} values')

    def getMaxWidth(self, rows):
        'Return the maximum length of any cell in column or its header (up to drawable window width).'
        drawable_width = self.sheet.windowWidth-1
        nlen = dispwidth(self.name, literal=True)
        w_max = nlen
        for r in rows:
            row_w = self.measureValueWidthCapped(r, maxwidth=drawable_width)
            if w_max < row_w:
                w_max = row_w
            if w_max >= self.sheet.windowWidth:
                break  #1747  early out to speed up wide columns
        return min(w_max+2, drawable_width)

    def measureValueWidthCapped(self, row, maxwidth=None):
        '''Measure the width of the contents of a cell. Stop measuring at *maxwidth*,
           to save time iterating over very long dict/list/tuple values.
           If *maxwidth* is None, return the full width.'''
        # The value classification logic here is taken from getCell,
        # modified to cap the width examined for any dict/list/tuple
        cellval = wrapply(self.getValue, row)
        typedval = wrapply(self.type, cellval)
        if isinstance(typedval, (TypedWrapper, threading.Thread)):
            return dispwidth(self.getCell(row).text, maxwidth=maxwidth, literal=True)
        try:
            text = self.format(typedval, width=maxwidth) or ''
        except Exception:  # formatting failure
            try:
                text = str(cellval)
            except Exception as e:
                text = str(e)
        return dispwidth(text, maxwidth=maxwidth, literal=True)


# ---- basic Columns

class WritableColumn(Column):
    @property
    def readonly(self):
        return False

class AttrColumn(WritableColumn):
    'Column using getattr/setattr with *attr*.'
    def __init__(self, name=None, expr=None, **kwargs):
        super().__init__(name,
            expr=expr if expr is not None else name,
            getter=lambda col,row: getattrdeep(row, col.expr, None),
            **kwargs)

    def putValue(self, row, val):
        super().putValue(row, val)
        setattrdeep(row, self.expr, val)


class ItemColumn(WritableColumn):
    'Column using getitem/setitem with *expr*.'
    def __init__(self, name=None, expr=None, **kwargs):
        super().__init__(name,
            expr=expr if expr is not None else name,
            getter=lambda col,row: getitemdeep(row, col.expr, None),
            **kwargs)

    def putValue(self, row, val):
        super().putValue(row, val)
        setitemdeep(row, self.expr, val)


class SubColumnFunc(Column):
    'Column compositor; preprocess row with *subfunc*(row, *expr*) before passing to *origcol*.getValue and *origcol*.setValue.'
    def __init__(self, name='', origcol=None, expr=None, subfunc=getitemdef, **kwargs):
        super().__init__(name, type=origcol.type, width=origcol.width, expr=expr, **kwargs)
        self.origcol = origcol
        self.subfunc = subfunc

    @property
    def readonly(self):
        return self.origcol.readonly

    def calcValue(self, row):
        subrow = self.subfunc(row, self.expr)
        if subrow is not None:
            # call getValue to use deferred values from source sheet
            return self.origcol.getValue(subrow)

    def putValue(self, row, value):
        subrow = self.subfunc(row, self.expr)
        if subrow is None:
            vd.fail('no source row')
        self.origcol.setValue(subrow, value)

    def recalc(self, sheet=None):
        Column.recalc(self, sheet)
        self.origcol.recalc()  # reset cache but don't change sheet


def SubColumnAttr(attrname, c, **kwargs):
    if 'name' not in kwargs:
        kwargs['name'] = c.name
    return SubColumnFunc(origcol=c, subfunc=getattrdeep, expr=attrname, **kwargs)

def SubColumnItem(idx, c, **kwargs):
    if 'name' not in kwargs:
        kwargs['name'] = c.name
    return SubColumnFunc(origcol=c, subfunc=getitemdef, expr=idx, **kwargs)


class SettableColumn(WritableColumn):
    'Column using rowid to store and retrieve values internally.'
    def putValue(self, row, value):
        self._store[self.sheet.rowid(row)] = value

    def calcValue(self, row):
        return self._store.get(self.sheet.rowid(row), None)


SettableColumn.init('_store', dict, copy=True)


vd.addGlobals(
    INPROGRESS=INPROGRESS,
    Column=Column,
    setitem=setitem,
    getattrdeep=getattrdeep,
    setattrdeep=setattrdeep,
    getitemdef=getitemdef,
    AttrColumn=AttrColumn,
    ItemColumn=ItemColumn,
    WritableColumn=WritableColumn,
    SettableColumn=SettableColumn,
    SubColumnFunc=SubColumnFunc,
    SubColumnItem=SubColumnItem,
    SubColumnAttr=SubColumnAttr,

# synonyms
    ColumnItem=ItemColumn,
    ColumnAttr=AttrColumn,
    DisplayWrapper=DisplayWrapper
)
