#
# Copyright (c) Daniel Sheffield 2023
#
# All rights reserved
#
# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
from xml.etree.ElementTree import fromstring, ParseError
from decimal import Decimal, InvalidOperation
from itertools import chain, product
from collections import OrderedDict
from typing import List, Tuple, Union, Iterable, Callable
from markdown import markdown

from urwid import (
    connect_signal,
    AttrMap,
    Button,
    Columns,
    Divider,
    Edit,
    Filler,
    LineBox,
    Padding,
    Pile,
    RadioButton,
    Text,
)
from urwid.numedit import FloatEdit
import yaml
from yaml.representer import SafeRepresenter

from .grouped_widget_util import (
    in_same_row,
    to_numbered_field,
    to_unnumbered_field,
    to_named_value,
)

from ..widgets import (
    AutoCompleteEdit,
    FocusWidget,
    AutoCompletePopUp,
    NoTabCheckBox,
)
from ..data.QueryManager import QueryManager
from .ActivityManager import ActivityManager, show_or_exit
from .Banner import banner

def change_style(style, representer):
    def new_representer(dumper, data):
        scalar = representer(dumper, data)
        scalar.style = style
        return scalar
    return new_representer

class folded_str(str):
    pass

class literal_str(str):
    pass

# represent_str does handle some corner cases, so use that
# instead of calling represent_scalar directly
represent_folded_str = change_style('>', SafeRepresenter.represent_str)
represent_literal_str = change_style('|', SafeRepresenter.represent_str)
yaml.add_representer(folded_str, represent_folded_str)
yaml.add_representer(literal_str, represent_literal_str)
def depth_first_elements(tree):
    for e in tree:
        for y in depth_first_elements(e):
            yield y
    yield tree

def get_products_from_xhtml(md: str):
    try:
        xhtml = fromstring(
f"""<root>
{md}
</root>
""")
    except ParseError:
        return
    for e in filter(lambda x: x.tag == 'strong', depth_first_elements(xhtml)):
        yield e.text


def unzip(_iter: List[Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]]) -> Tuple[
    List[AutoCompleteEdit], List[FloatEdit], List[AutoCompleteEdit]
]:
    return zip(*_iter)


def extract_values(x: Union[List[AutoCompleteEdit], List[FloatEdit]]) -> Iterable[str]:
    if isinstance(x, (list, tuple)):
        if len(x) == 0:
            return []
        return ( v.get_edit_text() for v in x )
    raise Exception(f"Unsupported type: {type(x)}")


def blank_ingredients_row(idx: int) -> Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]:
    return (
        AutoCompleteEdit(('bg', f'product#{idx}')),
        FloatEdit(('bg', '')),
        AutoCompleteEdit(('bg', f'unit#{idx}'))
    )

class RecipeEditor(FocusWidget):

    def keypress(self, size, key):
        if isinstance(key, tuple):
            return None

        if getattr(self._w.original_widget, 'original_widget', None) is None:
            return super().keypress(size, key)
        _c = self.container.get_focus_path()
        _p = next((w[1] for w,p in self.iter_focus_paths() if p == _c))

        if key == 'tab':
            self.advance_focus()
        elif key == 'shift tab':
            self.advance_focus(reverse=True)
        elif key == 'ctrl delete':
            self.clear()
        elif key == 'ctrl w':
            self.save()
        else:
            if _p.keypress((size[0],) if getattr(_p, 'contents', None) is None else size, key):
                return super().keypress(size, key)
            return None
        return None

    def apply_choice(self, name, value):
        self.apply_changes(name, value)

        data = dict(filter(
            in_same_row(name),
            map(to_numbered_field, self.data.items())
        ))

        for k,v in data.items():
            if f'{k[0]}#{k[1]}' == name or v:
                continue
            _data = dict(map(lambda x: (x[0][0], x[1]), data.items()))
            options = self.query_manager.unique_suggestions(k[0], **_data)
            if len(options) == 1:
                self.apply_changes(f'{k[0]}#{k[1]}', list(options)[0])

    def apply_changes(self, name, value):
        self.data = {
            name: value if name != 'organic' else {
                'yes': True, 'no': False,
                True: True, False: False,
                'mixed': '',
            }[value],
        }

    @property
    def data(self):
        zipped = zip(
            ['product', 'quantity', 'unit'],
            map(extract_values, unzip(self.ingredients)),
        )
        ret = dict(chain(
            *[ map(to_named_value(n), enumerate(l)) for n,l in zipped ],
            [ ('organic', self.organic.state) ]
        ))
        return ret

    @data.setter
    def data(self, _data: dict):
        for k,v in _data.items():
            if len(k.split('#')) > 1:
                name, idx = k.split('#', 1)
                w = self.ingredients[int(idx)][ next(( pos for pos, n in zip(
                    [0, 1, 2],
                    ['product', 'quantity', 'unit']
                ) if n == name ))]
                w.set_edit_text(v)
            if k == 'organic':
                self.organic.set_state(v)
    @property
    def components(self):
        return self._components

    @components.setter
    def components(self, _data: dict):
        self._components = _data

    def clear(self):
        self.ingredients = []
        self.add_ingredient()
        self.organic.set_state('mixed')
        self.instructions.set_edit_text('')
        self.fname.set_edit_text('')
        self.notice.set_text('')
        return self.update()

    def init_ingredients(self):
        right_pane = LineBox(Pile([AttrMap(
            AutoCompletePopUp(
                ingredient[0],
                self.apply_choice,
                lambda: self.activity_manager.show(self.update(ingredient[0]))
            ), 'streak') for ingredient in self.ingredients]),
            title='Product',
            title_align='left'
        )
        left_pane = LineBox(
            Pile([AttrMap(
                ingredient[1],
                'streak'
            ) for ingredient in self.ingredients]),
            title='Quantity',
            title_align='left'
        )
        middle_pane = LineBox(
            Pile([AttrMap(AutoCompletePopUp(
                ingredient[2],
                self.apply_choice,
                lambda: self.activity_manager.show(self.update(ingredient[2]))
            ), 'streak') for ingredient in self.ingredients]),
            title='Unit',
            title_align='left'
        )
        gutter = Pile([
            *[ Divider() for _ in product(
                range(1), self.ingredients[:-1]
            )],
            Divider(),
            Divider(),
            self.buttons['add'],
        ])
        return left_pane, middle_pane, right_pane, gutter

    def add_ingredient(self):
        self.ingredients.append(
            blank_ingredients_row(len(self.ingredients))
        )
        l, m, r, gutter = self.init_ingredients()
        self.components['left_pane'][1].original_widget.contents = list(l.original_widget.contents)
        self.components['middle_pane'][1].original_widget.contents = list(m.original_widget.contents)
        self.components['right_pane'].original_widget.contents = list(r.original_widget.contents)
        self.components['gutter'][1].contents = list(gutter.contents)
        for widget in self.ingredients:
            connect_signal(widget[0], 'postchange', lambda w,_: self.update(w))
            connect_signal(widget[0], 'apply', lambda w, name: self.autocomplete_callback(
                w, self.autocomplete_options(name, dict(map(
                    to_unnumbered_field,
                    filter(in_same_row(name), map(to_numbered_field, self.data.items())
                ))))
            ))
            connect_signal(widget[1], 'postchange', lambda w,_: self.update(w))
            connect_signal(widget[2], 'postchange', lambda w,_: self.update(w))
            connect_signal(widget[2], 'apply', lambda w, name: self.autocomplete_callback(
                w, self.autocomplete_options(name, dict(map(
                    to_unnumbered_field,
                    filter(in_same_row(name), map(to_numbered_field, self.data.items())
                ))))
            ))

    def save(self):
        yml = dict()
        yml['ingredients'] = list(map(' '.join, filter(
            lambda x: None not in map(lambda x: x or None, x), [
                list(map(lambda x: x.get_edit_text(), x)) for x in self.ingredients
        ])))
        if self.feeds:
            n, d = self.feeds.as_integer_ratio()
            yml['feeds'] = float(self.feeds) if d != 1 else n
        else:
            yml['feeds'] = None
        yml['instructions'] = literal_str('\n'.join(map(
			lambda x: x.strip(),
			self.instructions.get_text()[0].splitlines()
		)).strip())
        fname = self.fname.get_edit_text()
        if not fname:
            return
        with open(fname, 'w') as f:
            yaml.dump(yml, f)


    def update(self, widget = None):
        data = self.data
        organic = None if data['organic'] == 'mixed' else data['organic']
        sort = 'ts'
        self.notices[None] = set()
        for r in filter(
            lambda x: next(filter(lambda x: x is widget or widget is None, x), None),
            self.ingredients
        ):
            product, quantity, unit = map(lambda x: x.get_edit_text(), r)
            if product not in self.notices:
                self.notices[product] = set()
            self.found[product] = '>'

            try:
                quantity = Decimal(quantity)
            except InvalidOperation:
                quantity = None

            if None in (sort or None, product or None, unit or None, quantity or None):
                continue

            df = self.query_manager.get_historic_prices_data(unit, sort=sort, product=product, organic=organic)
            if not df.empty:
                distinct = df['quantity'].unique()
                if len(distinct) == 1 and None in distinct:
                    self.notices[product].add(f"Product '{product}' has no conversion to '{unit}'")
                    continue
                df = df.dropna()
                self.found[product] = '='

            else:
                if organic is None:
                    self.notices[product].add(f"Product '{product}' not found in database")
                    continue

                df = self.query_manager.get_historic_prices_data(unit, sort=sort, product=product, organic=None)
                if df.empty:
                    self.notices[product].add(f"Product '{product}' not found in database")
                    continue
                distinct = df['quantity'].unique()
                if len(distinct) == 1 and None in distinct:
                    self.notices[product].add(f"Product '{product}' has no conversion to '{unit}'")
                    continue
                df = df.dropna()
                self.notices[product].add(f"Using {'non-' if organic else ''}organic {product}")
                self.found[product] = '~'

            assert len(df['avg'].unique()) == 1, f"There should be only one average price: {df['avg'].unique()}"

            _last, _avg, _min, _max = list(
                map(Decimal,df[['last', 'avg','min','max']].iloc[0])
            )
            self.prices[product] = [
                i*quantity for i in (_last, _min, _avg, _max)
            ]

        for k in list(self.notices):
            if k not in [
                *map(lambda x: x[0].get_edit_text(), self.ingredients),
                None
            ]:
                del self.notices[k]
        for k in list(self.prices):
            if k not in map(lambda x: x[0].get_edit_text(), self.ingredients):
                del self.prices[k]
        for k in list(self.found):
            if k not in [
                *map(lambda x: x[0].get_edit_text(), self.ingredients),
                ''
            ]:
                del self.found[k]

        price = [
            sum([self.prices[p][i] for p in self.prices]) for i in range(4)
        ]
        price_as = next(filter(
            lambda x: self.price_as[x[1]].state,
            enumerate(self.price_as)
        ))[0]
        not_found = next(
            filter(lambda x: x == '>', self.found.values()), next(
                filter(lambda x: x == '~', self.found.values()), next(
                    filter(lambda x: x == '=', self.found.values()),
                    None,
                )
            )
        )
        self.price.set_text(
            f'Cost: {not_found}{price[price_as]}'
            f', Per Serve: {not_found}{price[price_as]/self.feeds}'
        )
        ingredients = list(filter(lambda x: x, map(lambda x: x[0].get_edit_text(), self.ingredients)))
        parsed_products = list(get_products_from_xhtml(markdown(self.instructions.get_edit_text())))
        fname = self.fname.get_edit_text()
        if not fname:
            self.notice.set_text('No file name set')
            return self
        if not parsed_products:
            self.notice.set_text('Failed to parse recipe instructions')
            return self
        products = set(parsed_products)
        for product in products - set(ingredients):
            if product not in self.notices:
                self.notices[product] = set()
            self.notices[product].add(f"Product '{product}' not found in list of ingredients")
        for ingredient in set(ingredients) - products:
            if ingredient not in self.notices:
                self.notices[ingredient] = set()
            self.notices[ingredient].add(f"Ingredient '{ingredient}' is not used")
        if len(set(ingredients)) != len(ingredients):
            self.notices[None].add("Some ingredients listed more than once")

        self.notice.set_text('\n'.join(sorted(filter(lambda x: x, [
            '\n'.join(sorted(v)) for v in self.notices.values()
        ]))) or 'None')

        return self

    def __init__(self,
        activity_manager: ActivityManager,
        query_manager: QueryManager,
        fname: str,
        recipe: dict,
    ):
        self.notices = dict()
        button_group = []
        self.price_as = OrderedDict([
          ('last', RadioButton(button_group, ('streak', 'Last'), state="first True")),
          ('min', RadioButton(button_group, ('streak', 'Min'), state="first True")),
          ('avg', RadioButton(button_group, ('streak', 'Avg'), state="first True")),
          ('max', RadioButton(button_group, ('streak', 'Max'), state="first True")),
        ])
        for b in self.price_as.values():
            connect_signal(b, 'postchange', lambda w,_: self.update(w))
        self.fname = Edit('', fname)
        self.prices = dict()
        self.found = dict()
        self.components = dict()
        self.buttons = {
          'clear': Button(('streak', 'Clear')),
          'exit': Button(('streak', 'Exit')),
          'add': Button(('streak', 'Add')),
          'save': Button(('streak', 'Save')),
        }
        self.ingredients: List[Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]] = [
            (
                AutoCompleteEdit(('bg', f'product#{idx}'), edit_text=ingredient[0]),
                FloatEdit(('bg', ''), default=ingredient[1]),
                AutoCompleteEdit(('bg', f'unit#{idx}'), edit_text=ingredient[2]),
            ) for idx, ingredient in enumerate(recipe['ingredients'])
        ]

        self.organic = NoTabCheckBox(('bg', "Organic"), state='mixed', has_mixed=True)
        self.instructions = Edit('', edit_text=recipe['instructions'] or u'', multiline=True, allow_tab=True)
        self.feeds = Decimal(recipe['feeds'])
        self.price = Text('')
        self.notice = Text('')
        feeds = FloatEdit('Serves: ', self.feeds)

        bottom_pane = [
            self.organic,
            LineBox(self.instructions, title='Instructions'),
            feeds,
            Columns([('weight', 2, self.price), *[(9, w) for _,w in self.price_as.items()], Divider()]),
            LineBox(self.notice, title='Errors', title_align='left'),
        ]
        self.activity_manager = activity_manager
        self.query_manager = query_manager
        self.autocomplete_options = lambda name, data: self.query_manager.unique_suggestions(name.split('#', 1)[0], **data)
        self.autocomplete_callback = lambda widget, options: len(options) > 0 and widget._emit('open', options)
        connect_signal(self.organic, 'postchange', lambda *_: self.update())

        connect_signal(self.buttons['save'], 'click', lambda _: self.save())
        connect_signal(self.buttons['add'], 'click', lambda _: self.add_ingredient())
        connect_signal(self.buttons['clear'], 'click', lambda _: self.clear())
        connect_signal(self.buttons['exit'], 'click', lambda _: show_or_exit('esc'))
        connect_signal(self.instructions, 'postchange', lambda w,_: self.update(w))

        left_pane, middle_pane, right_pane, gutter = self.init_ingredients()

        self.components = {
            'top_pane': Columns([
                (9, Pile([
                    Divider(),
                    AttrMap(self.buttons['clear'], 'streak'),
                    Divider(),
                ])),
                Divider(),
                (60, LineBox(Columns([
                    self.fname, (8, self.buttons['save'])
                ]), title='Recipe')),
                Divider(),
                (9, Pile([
                    Divider(),
                    AttrMap(self.buttons['exit'], 'streak'),
                    Divider(),
                ]))
            ], dividechars=1),
            'bottom_pane': Pile(bottom_pane),
            'right_pane': right_pane,
            'middle_pane': (15, middle_pane),
            'left_pane': (12, left_pane),
            'gutter': (8, gutter)
        }

        widget = Pile([
            banner(u'Recipe Editor'),
            Divider(),
            self.components['top_pane'],
            Columns([
                self.components['left_pane'],
                self.components['middle_pane'],
                self.components['right_pane'],
                (1,Divider()),
                self.components['gutter'],
            ], dividechars=0),
            self.components['bottom_pane'],
        ])
        widget = Filler(widget, 'top')
        widget = AttrMap(widget, 'bg')
        super().__init__(widget, [
                'instructions',
                'ingredients', #'quantity', 'units',
                'add',
                'organic',
                'clear', 'fname', 'save', 'exit',
            ], lambda: chain(
                self.buttons.items(),
                chain(*[product(['ingredients',], [*x[1:],x[0]]) for x in self.ingredients]),
                [
                    ('fname', self.fname),
                    ('instructions', self.instructions),
                    ('organic', self.organic)
                ],
            )
        )
        self.add_ingredient()
        self.update()
