# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.

from math import sin, cos, sqrt, atan2, radians
from json import dumps, loads
from copy import deepcopy
from datetime import datetime, timedelta
from collections import defaultdict
from logging import getLogger

_logger = getLogger(__name__)

try:
    from uszipcode import SearchEngine
except ImportError:
    _logger.warn('module "uszipcode" cannot be loaded, falling back to Google API')
    SearchEngine = None

from odoo import api, fields, models
from odoo.tools.safe_eval import safe_eval
from ..models.res_config_settings import sale_planner_warehouse_ids, sale_planner_carrier_ids


class FakeCollection():
    def __init__(self, vals):
        self._vals = vals

    def __iter__(self):
        for v in self._vals:
            yield v

    def filtered(self, f):
        return self.__class__([v for v in self._vals if f(v)])
        # return filter(f, self.vals)

    def mapped(self, s):
        # note this only maps to one level and doesn't really support recordset
        return [v[s] for v in self._vals]

    def sudo(self, *args, **kwargs):
        return self
    
    def __bool__(self):
        # FakeCollection can be empty, all subclasses cannot (currently)
        if self.__class__ != FakeCollection:
            return True
        return bool(self._vals)


class FakePartner(FakeCollection):
    def __init__(self, **kwargs):
        """
        'delivery.carrier'.verify_carrier(contact) ->
            country_id,
            state_id,
            zip
            company

        city,

        `distance calculations` ->
            date_localization,
            partner_latitude,
            partner_longitude

            computes them when accessed
        """
        self.partner_latitude = 0.0
        self.partner_longitude = 0.0
        self.is_company = False
        self._date_localization = kwargs.pop('date_localization', False)
        if not kwargs.pop('PARENT', False):
            self.parent_id = FakePartner(PARENT=True)
        for attr, value in kwargs.items():
            setattr(self, attr, value)

    @property
    def date_localization(self):
        if not self._date_localization:
            try:
                self._date_localization = 'TODAY!'
                # The fast way.
                if SearchEngine and self.zip:
                    with SearchEngine() as search:
                        zipcode = search.by_zipcode(str(self.zip).split('-')[0])
                        if zipcode and zipcode.lat:
                            self.partner_latitude = zipcode.lat
                            self.partner_longitude = zipcode.lng
                            return self._date_localization

                # The slow way.
                geo_obj = self.env['base.geocoder']
                search = geo_obj.geo_query_address(city=self.city, state=self.state_id.name, country=self.country_id.name)
                result = geo_obj.geo_find(search, force_country=self.country_id.id)
                if result:
                    self.partner_latitude = result[0]
                    self.partner_longitude = result[1]
            except:
                self._date_localization = 'ERROR'
        return self._date_localization

    def __getattr__(self, item):
        return False

    def __getitem__(self, item):
        if item == '__last_update':
            return str(datetime.now())
        return getattr(self, item)


class FakeOrderLine(FakeCollection):
    def __init__(self, **kwargs):
        """
        'delivery.carrier'.get_price_available(order) ->
            state,
            is_delivery,
            product_uom._compute_quantity,
            product_uom_qty,
            product_id
            price_total


        """
        self.state = 'draft'
        self.is_delivery = False
        self.product_uom = self

        for attr, value in kwargs.items():
            setattr(self, attr, value)

    def _compute_quantity(self, qty=1, uom=None):
        """
        This is a non-implementation for when someone wants to call product_uom._compute_quantity
        :param qty:
        :param uom:
        :return:
        """
        return qty

    def __getattr__(self, item):
        return False

    def __getitem__(self, item):
        if item == '__last_update':
            return str(datetime.now())
        return getattr(self, item)


class FakeSaleOrder(FakeCollection):
    """
    partner_id :: used in shipping
    partner_shipping_id :: is used in several places
    order_line :: can be a FakeCollection of FakeOrderLine's or Odoo 'sale.order.line'
    carrier_id :: can be empty, will be overwritten when walking through carriers

    'delivery.carrier'.get_shipping_price_from_so(orders) ->
        id, (int)
        name, (String)
        currency_id, (Odoo 'res.currency')
        company_id,  (Odoo 'res.company')
        warehouse_id, (Odoo 'stock.warehouse')
        carrier_id, (Odoo 'delivery.carrier')

    SaleOrderMakePlan.generate_shipping_options() ->
        pricelist_id, (Odoo 'product.pricelist')
    """
    def __init__(self, **kwargs):
        self.carrier_id = None
        self.id = 0
        self.name = 'Quote'
        self.team_id = None
        self.analytic_account_id = None
        self.amount_total = 0.0
        self.date_order = fields.Date.today()
        self.shipping_account_id = False  # from delivery_hibou
        self.ups_service_type = False  # Added in 12
        for attr, value in kwargs.items():
            setattr(self, attr, value)

    def __iter__(self):
        """
        Emulate a recordset of a single order.
        """
        yield self

    def _compute_amount_total_without_delivery(self):
        return self.amount_total

    def __getattr__(self, item):
        return False

    def __getitem__(self, item):
        if item == '__last_update':
            return str(datetime.now())
        return getattr(self, item)
    
    def _get_estimated_weight(self):
        weight = 0.0
        for order_line in self.order_line.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not l.is_delivery and not l.display_type):
            weight += order_line.product_qty * order_line.product_id.weight
        return weight


def distance(lat_1, lon_1, lat_2, lon_2):
    R = 6373.0

    lat1 = radians(lat_1)
    lon1 = radians(lon_1)
    lat2 = radians(lat_2)
    lon2 = radians(lon_2)

    dlon = lon2 - lon1
    dlat = lat2 - lat1

    a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
    c = 2 * atan2(sqrt(a), sqrt(1 - a))

    return R * c


class SaleOrderMakePlan(models.TransientModel):
    _name = 'sale.order.make.plan'
    _description = 'Plan Order'

    order_id = fields.Many2one(
        'sale.order', 'Sale Order',
    )
    planning_option_ids = fields.One2many('sale.order.planning.option', 'plan_id', 'Options')

    @api.model
    def plan_order(self, vals):
        pass

    def select_option(self, option):
        for plan in self:
            self.plan_order_option(plan.order_id, option)

    def _order_fields_for_option(self, option):
        return {
            'warehouse_id': option.warehouse_id.id,
            'requested_date': option.requested_date,
            'date_planned': option.date_planned,
            'carrier_id': option.carrier_id.id,
        }

    @api.model
    def plan_order_option(self, order, option):
        if option.sub_options:
            sub_options = option.sub_options
            if isinstance(sub_options, str):
                sub_options = loads(sub_options)
            if not isinstance(sub_options, dict):
                _logger.warning('Cannot apply option with corrupt sub_options')
                return False
            order_lines = order.order_line
            for wh_id, wh_vals in sub_options.items():
                wh_id = int(wh_id)
                if wh_id == option.warehouse_id.id:
                    continue
                order_lines.filtered(lambda line: line.product_id.id in wh_vals['product_ids']).write({
                    'warehouse_id': wh_id,
                    'date_planned': wh_vals.get('date_planned'),
                })

        order_fields = self._order_fields_for_option(option)

        order.write(order_fields)

        if option.carrier_id:
            order._create_delivery_line(option.carrier_id, option.shipping_price)


    @api.model
    def create(self, values):
        planner = super(SaleOrderMakePlan, self).create(values)

        for option_vals in self.generate_order_options(planner.order_id):
            if type(option_vals) != dict:
                continue
            option_vals['plan_id'] = planner.id
            planner.planning_option_ids |= self.env['sale.order.planning.option'].create(option_vals)

        return planner

    def _fake_order(self, order):
        return FakeSaleOrder(**{
            'id': order.id,
            'name': order.name,
            'partner_id': order.partner_id,
            'partner_shipping_id': order.partner_shipping_id,
            'order_line': order.order_line,
            'currency_id': order.currency_id,
            'company_id': order.company_id,
            'warehouse_id': order.warehouse_id,
            'amount_total': order.amount_total,
            'pricelist_id': order.pricelist_id,
            'env': self.env,
        })

    @api.model
    def generate_order_options(self, order, plan_shipping=True):
        fake_order = self._fake_order(order)
        base_option = self.generate_base_option(fake_order)
        # do we need shipping?
        # we need to collect it because we want multi-warehouse shipping amounts.
        if order.carrier_id:
            base_option['carrier_id'] = order.carrier_id.id

        if plan_shipping and not self.env.context.get('skip_plan_shipping'):
            if base_option.get('date_planned'):
                fake_order.date_order = base_option['date_planned']
            options = self.generate_shipping_options(base_option, fake_order)
        else:
            options = [base_option]

        return options

    def get_warehouses(self, warehouse_id=None, domain=None):
        warehouse = self.env['stock.warehouse'].sudo()
        if warehouse_id:
            return warehouse.browse(warehouse_id)

        if domain:
            if not isinstance(domain, (list, tuple)):
                domain = safe_eval(domain)
        else:
            domain = []
            if 'allowed_company_ids' in self.env.context:
                domain.append(('company_id', 'in', self.env.context['allowed_company_ids']))

        if self.env.context.get('warehouse_domain'):
            if not domain:
                domain = []
            domain.extend(self.env.context.get('warehouse_domain'))
        if domain:
            return warehouse.search(domain)

        # no domain, use global
        warehouse_ids = sale_planner_warehouse_ids(self.env, self.env.user.company_id)
        return warehouse.browse(warehouse_ids)

    def get_shipping_carriers(self, carrier_id=None, domain=None, warehouse_id=None):
        Carrier = self.env['delivery.carrier'].sudo()
        if carrier_id:
            return Carrier.browse(carrier_id)

        if domain:
            if not isinstance(domain, (list, tuple)):
                domain = safe_eval(domain)

        if self.env.context.get('carrier_domain'):
            if not domain:
                domain = []
            domain.extend(self.env.context.get('carrier_domain'))
        if domain:
            return Carrier.search(domain)

        # no domain, use global
        if warehouse_id:
            warehouse = self.env['stock.warehouse'].sudo().browse(warehouse_id)
            if warehouse.sale_planner_carrier_ids:
                return warehouse.sale_planner_carrier_ids.sudo()
        carrier_ids = sale_planner_carrier_ids(self.env, self.env.user.company_id)
        return Carrier.browse(carrier_ids)

    def _generate_base_option(self, order_fake, policy_group):
        flag_force_closest = False
        warehouse_domain = False
        if 'policy' in policy_group:
            policy = policy_group['policy']
            flag_force_closest = policy.always_closest_warehouse
            warehouse_domain = policy.warehouse_filter_id.domain
            # Need to look at warehouse filter.
            # Eventually need to look at shipping filter....

        warehouses = self.get_warehouses(domain=warehouse_domain)
        if flag_force_closest:
            warehouses = self._find_closest_warehouse_by_partner(warehouses, order_fake.partner_shipping_id)
        product_stock = self._fetch_product_stock(warehouses, policy_group['products'])
        sub_options = {}
        wh_date_planning = {}

        p_len = len(policy_group['products'])
        full_candidates = set()
        partial_candidates = set()
        for wh_id, stock in product_stock.items():
            available = sum(1 for p_id, p_vals in stock.items() if self._is_in_stock(p_vals, policy_group['buy_qty'][p_id]))
            if available == p_len:
                full_candidates.add(wh_id)
            elif available > 0:
                partial_candidates.add(wh_id)

        if full_candidates:
            if len(full_candidates) == 1:
                warehouse = warehouses.filtered(lambda wh: wh.id in full_candidates)
            else:
                warehouse = self._find_closest_warehouse_by_partner(
                    warehouses.filtered(lambda wh: wh.id in full_candidates), order_fake.partner_shipping_id)
            date_planned = self._next_warehouse_shipping_date(warehouse)
            #order_fake.warehouse_id = warehouse
            return {'warehouse_id': warehouse.id, 'date_planned': date_planned}

        _logger.error('      partial_candidates: ' + str(partial_candidates))
        if partial_candidates:
            _logger.error('      using...')
            if len(partial_candidates) == 1:
                warehouse = warehouses.filtered(lambda wh: wh.id in partial_candidates)
                #order_fake.warehouse_id = warehouse
                return {'warehouse_id': warehouse.id}

            sorted_warehouses = self._sort_warehouses_by_partner(
                warehouses.filtered(lambda wh: wh.id in partial_candidates), order_fake.partner_shipping_id)
            _logger.error('      sorted_warehouses: ' + str(sorted_warehouses) + ' warehouses: ' + str(warehouses))
            primary_wh = sorted_warehouses[0]  # partial_candidates means there is at least one warehouse
            primary_wh_date_planned = self._next_warehouse_shipping_date(primary_wh)
            wh_date_planning[primary_wh.id] = primary_wh_date_planned
            for wh in sorted_warehouses:
                _logger.error('      wh: ' + str(wh) + ' buy_qty: ' + str(policy_group['buy_qty']))
                if not policy_group['buy_qty']:
                    continue
                stock = product_stock[wh.id]
                for p_id, p_vals in stock.items():
                    _logger.error('      p_id: ' + str(p_id) + ' p_vals: ' + str(p_vals))
                    if p_id in policy_group['buy_qty'] and self._is_in_stock(p_vals, policy_group['buy_qty'][p_id]):
                        if wh.id not in sub_options:
                            sub_options[wh.id] = {
                                'date_planned': self._next_warehouse_shipping_date(wh),
                                'product_ids': [],
                                'product_skus': [],
                            }
                        sub_options[wh.id]['product_ids'].append(p_id)
                        sub_options[wh.id]['product_skus'].append(p_vals['sku'])
                        _logger.error('        removing: ' + str(p_id))
                        del policy_group['buy_qty'][p_id]

            if not policy_group['buy_qty']:
                # item_details can fulfil all items.
                # this is good!!
                #order_fake.warehouse_id = primary_wh
                return {'warehouse_id': primary_wh.id, 'date_planned': primary_wh_date_planned,
                        'sub_options': sub_options}

            # warehouses cannot fulfil all requested items!!
            #order_fake.warehouse_id = primary_wh
            return {'warehouse_id': primary_wh.id}

        # nobody has stock!
        primary_wh = self._find_closest_warehouse_by_partner(warehouses, order_fake.partner_shipping_id)
        return {'warehouse_id': primary_wh.id}

    def generate_base_option(self, order_fake):
        _logger.error('generate_base_option:')
        __start_date = datetime.now() - timedelta(days=30)
        product_lines = list(filter(lambda line: line.product_id.type == 'product', order_fake.order_line))
        if not product_lines:
            return {}

        buy_qty = defaultdict(int)
        for line in product_lines:
            buy_qty[line.product_id.id] += line.product_uom_qty

        products = self.env['product.product']
        for line in product_lines:
            products |= line.product_id

        policy_groups = defaultdict(lambda: {'products': [], 'buy_qty': {}})
        for p in products:
            policy = p.product_tmpl_id.get_planning_policy()
            if policy:
                policy_groups[policy.id]['products'].append(p)
                policy_groups[policy.id]['buy_qty'][p.id] = buy_qty[p.id]
                policy_groups[policy.id]['policy'] = policy
            else:
                policy_groups[0]['products'].append(p)
                policy_groups[0]['buy_qty'][p.id] = buy_qty[p.id]

        for _, policy_group in policy_groups.items():
            product_set = self.env['product.product'].browse()
            for p in policy_group['products']:
                product_set += p
            policy_group['products'] = product_set
            policy_group['base_option'] = self._generate_base_option(order_fake, policy_group)

        option_policy_groups = defaultdict(lambda: {'products': self.env['product.product'].browse(), 'policies': self.env['sale.order.planning.policy'].browse(), 'date_planned': __start_date, 'sub_options': [],})
        for policy_id, policy_group in policy_groups.items():
            base_option = policy_group['base_option']
            _logger.error('  base_option: ' + str(base_option))
            b_wh_id = base_option['warehouse_id']
            if 'policy' in policy_group:
                option_policy_groups[b_wh_id]['policies'] += policy_group['policy']
            if option_policy_groups[b_wh_id].get('date_planned'):
                # The first base_option without a date clears it
                if base_option.get('date_planned'):
                    if base_option['date_planned'] > option_policy_groups[b_wh_id]['date_planned']:
                        option_policy_groups[b_wh_id]['date_planned'] = base_option['date_planned']
                else:
                    # One of our options has no plan date.  Remove it.
                    del option_policy_groups[b_wh_id]['date_planned']
            if 'sub_options' in base_option:
                option_policy_groups[b_wh_id]['sub_options'].append(base_option['sub_options'])
            option_policy_groups[b_wh_id]['products'] += policy_group['products']
            option_policy_groups[b_wh_id]['warehouse_id'] = b_wh_id

        # clean up unused sub_options and collapse used ones
        for o_wh_id, option_group in option_policy_groups.items():
            if not option_group['sub_options']:
                del option_group['sub_options']
            else:
                sub_options = defaultdict(lambda: {'date_planned': __start_date, 'product_ids': [], 'product_skus': []})
                remaining_products = option_group['products']
                for options in option_group['sub_options']:
                    for wh_id, option in options.items():
                        if sub_options[wh_id].get('date_planned'):
                            # The first option without a date clears it
                            if option.get('date_planned'):
                                if option['date_planned'] > sub_options[wh_id]['date_planned']:
                                    sub_options[wh_id]['date_planned'] = option['date_planned']
                            else:
                                del sub_options[wh_id]['date_planned']
                        sub_options[wh_id]['product_ids'] += option['product_ids']
                        sub_options[wh_id]['product_skus'] += option['product_skus']
                        remaining_products = remaining_products.filtered(lambda p: p.id not in sub_options[wh_id]['product_ids'])
                option_group['sub_options'] = sub_options
                if remaining_products:
                    option_group['sub_options'][o_wh_id]['product_ids'] += remaining_products.ids
                    option_group['sub_options'][o_wh_id]['product_skus'] += remaining_products.mapped('default_code')


        # At this point we should have all of the policy options collapsed.
        # Collapse warehouse options.
        base_option = {'date_planned': __start_date, 'products': self.env['product.product'].browse()}
        for wh_id, intermediate_option in option_policy_groups.items():
            _logger.error('    base_option: ' + str(base_option))
            _logger.error('    intermediate_option: ' + str(intermediate_option))
            if 'warehouse_id' not in base_option:
                base_option['warehouse_id'] = wh_id
            b_wh_id = base_option['warehouse_id']

            if base_option.get('date_planned'):
                if intermediate_option.get('date_planned'):
                    if intermediate_option['date_planned'] > base_option['date_planned']:
                        base_option['date_planned'] = intermediate_option['date_planned']
                else:
                    del base_option['date_planned']
                    if 'sub_options' in base_option:
                        for _, option in base_option['sub_options'].items():
                            del option['date_planned']
            if b_wh_id == wh_id:
                if 'sub_options' in intermediate_option and 'sub_options' not in base_option:
                    # Base option will get new sub_options
                    intermediate_option['sub_options'][wh_id]['product_ids'] += base_option['products'].ids
                    intermediate_option['sub_options'][wh_id]['product_skus'] += base_option['products'].mapped('default_code')
                    base_option['sub_options'] = intermediate_option['sub_options']
                elif 'sub_options' in intermediate_option and 'sub_options' in base_option:
                    # Both have sub_options, merge
                    for o_wh_id, option in intermediate_option['sub_options'].items():
                        if o_wh_id not in base_option['sub_options']:
                            base_option['sub_options'][o_wh_id] = option
                        else:
                            base_option['sub_options'][o_wh_id]['product_ids'] += option['product_ids']
                            base_option['sub_options'][o_wh_id]['product_skus'] += option['product_skus']
                        if base_option.get('date_planned'):
                            if option['date_planned'] > base_option['sub_options'][o_wh_id]['date_planned']:
                                base_option['sub_options'][o_wh_id]['date_planned'] = intermediate_option['date_planned']
                elif 'sub_options' in base_option:
                    # merge products from intermediate into base_option's sub_options
                    base_option['sub_options'][wh_id]['product_ids'] += intermediate_option['products'].ids
                    base_option['sub_options'][wh_id]['product_skus'] += intermediate_option['products'].mapped('default_code')
                base_option['products'] += intermediate_option['products']
            else:
                # Promote
                if 'sub_options' not in intermediate_option and 'sub_options' not in base_option:
                    base_option['sub_options'] = {
                        wh_id: {
                            'product_ids': intermediate_option['products'].ids,
                            'product_skus': intermediate_option['products'].mapped('default_code'),
                        },
                        b_wh_id: {
                            'product_ids': base_option['products'].ids,
                            'product_skus': base_option['products'].mapped('default_code'),
                        },
                    }
                    if base_option.get('date_planned'):
                        base_option['sub_options'][wh_id]['date_planned'] = intermediate_option['date_planned']
                        base_option['sub_options'][b_wh_id]['date_planned'] = base_option['date_planned']
                elif 'sub_options' in base_option and 'sub_options' not in intermediate_option:
                    if wh_id not in base_option['sub_options']:
                        base_option['sub_options'][wh_id] = {
                            'product_ids': intermediate_option['products'].ids,
                            'product_skus': intermediate_option['products'].mapped('default_code'),
                        }
                        if base_option.get('date_planned'):
                            base_option['sub_options'][wh_id]['date_planned'] = intermediate_option['date_planned']
                    else:
                        base_option['sub_options'][wh_id]['product_ids'] += intermediate_option['products'].ids
                        base_option['sub_options'][wh_id]['product_skus'] += intermediate_option['products'].mapped('default_code')
                        if base_option.get('date_planned'):
                            if intermediate_option['date_planned'] > base_option['sub_options'][wh_id]['date_planned']:
                                base_option['sub_options'][wh_id]['date_planned'] = intermediate_option['date_planned']
                elif 'sub_options' in intermediate_option and 'sub_options' in base_option:
                    # Both have sub_options, merge
                    for o_wh_id, option in intermediate_option['sub_options'].items():
                        if o_wh_id not in base_option['sub_options']:
                            base_option['sub_options'][o_wh_id] = option
                        else:
                            base_option['sub_options'][o_wh_id]['product_ids'] += option['product_ids']
                            base_option['sub_options'][o_wh_id]['product_skus'] += option['product_skus']
                        if base_option.get('date_planned'):
                            if option['date_planned'] > base_option['sub_options'][o_wh_id]['date_planned']:
                                base_option['sub_options'][o_wh_id]['date_planned'] = intermediate_option['date_planned']
                else:
                    # intermediate_option has sub_options but base_option doesn't
                    base_option['sub_options'] = {
                        b_wh_id: {
                            'product_ids': base_option['products'].ids,
                            'product_skus': base_option['products'].mapped('default_code'),
                        }
                    }
                    if base_option.get('date_planned'):
                        base_option['sub_options'][b_wh_id]['date_planned'] = base_option['date_planned']
                    for o_wh_id, option in intermediate_option['sub_options'].items():
                        if o_wh_id not in base_option['sub_options']:
                            base_option['sub_options'][o_wh_id] = option
                        else:
                            base_option['sub_options'][o_wh_id]['product_ids'] += option['product_ids']
                            base_option['sub_options'][o_wh_id]['product_skus'] += option['product_skus']
                        if base_option.get('date_planned'):
                            if option['date_planned'] > base_option['sub_options'][o_wh_id]['date_planned']:
                                base_option['sub_options'][o_wh_id]['date_planned'] = intermediate_option['date_planned']

        del base_option['products']
        _logger.error('  returning: ' + str(base_option))
        order_fake.warehouse_id = self.get_warehouses(warehouse_id=base_option['warehouse_id'])
        return base_option

    def _is_in_stock(self, p_stock, buy_qty):
        return p_stock['type'] == 'consu' or p_stock['qty_available'] >= buy_qty

    def _find_closest_warehouse_by_partner(self, warehouses, partner):
        if not partner.date_localization:
            partner.geo_localize()
        return self._find_closest_warehouse(warehouses, partner.partner_latitude, partner.partner_longitude)

    def _find_closest_warehouse(self, warehouses, latitude, longitude):
        if not warehouses:
            return warehouses
        distances = {distance(latitude, longitude, wh.partner_id.partner_latitude, wh.partner_id.partner_longitude): wh.id for wh in warehouses}
        wh_id = distances[min(distances)]
        return warehouses.filtered(lambda wh: wh.id == wh_id)

    def _sort_warehouses_by_partner(self, warehouses, partner):
        if not partner.date_localization:
            partner.geo_localize()
        return self._sort_warehouses(warehouses, partner.partner_latitude, partner.partner_longitude)

    def _sort_warehouses(self, warehouses, latitude, longitude):
        distances = {distance(latitude, longitude, wh.partner_id.partner_latitude, wh.partner_id.partner_longitude): wh.id for wh in warehouses}
        wh_distances = sorted(distances)
        return [warehouses.filtered(lambda wh: wh.id == distances[d]) for d in wh_distances]

    def _next_warehouse_shipping_date(self, warehouse):
        if warehouse.shipping_calendar_id:
            return warehouse.shipping_calendar_id.plan_days_end(0, fields.Datetime.now(), compute_leaves=True)
        return False

    @api.model
    def _fetch_product_stock(self, warehouses, products):
        output = {}
        for wh in warehouses:
            products.invalidate_cache(fnames=['qty_available', 'virtual_available', 'incoming_qty', 'outgoing_qty'],
                                      ids=products.ids)
            products = products.with_context({'location': wh.lot_stock_id.id})
            output[wh.id] = {
                p.id: {
                    'qty_available': p.qty_available,
                    'virtual_available': p.virtual_available,
                    'incoming_qty': p.incoming_qty,
                    'outgoing_qty': p.outgoing_qty,
                    'sku': p.default_code or str(p.id),
                    'type': p.type,
                } for p in products}
        return output

    def generate_shipping_options(self, base_option, order_fake):
        # generate a carrier_id, amount, requested_date (promise date)
        # if base_option['carrier_id'] then that is the only carrier we want to collect rates for.
        product_lines = list(filter(lambda line: line.product_id.type in ('product', 'consu'), order_fake.order_line))
        domain = []
        for line in product_lines:
            policy = line.product_id.product_tmpl_id.get_planning_policy()
            if policy and policy.carrier_filter_id:
                domain.extend(safe_eval(policy.carrier_filter_id.domain))
        carriers = self.get_shipping_carriers(base_option.get('carrier_id'), domain=domain, warehouse_id=base_option.get('warehouse_id'))
        _logger.info('generate_shipping_options:: base_option: ' + str(base_option) + ' order_fake: ' + str(order_fake) + ' carriers: ' + str(carriers))

        if not carriers:
            return [base_option]

        if not base_option.get('sub_options'):
            options = []
            # this locic comes from "delivery.models.sale_order.SaleOrder"
            for carrier in carriers:
                carrier_options = self._generate_shipping_carrier_option(base_option, order_fake, carrier)
                if carrier_options:
                    options += carrier_options
            if options:
                return options
            return [base_option]
        else:
            warehouses = self.get_warehouses()
            original_order_fake_warehouse_id = order_fake.warehouse_id
            original_order_fake_order_line = order_fake.order_line
            options = []
            for carrier in carriers:
                new_base_option = deepcopy(base_option)
                has_error = False
                found_carrier_ids = set()
                for wh_id, wh_vals in base_option['sub_options'].items():
                    if has_error:
                        continue
                    order_fake.warehouse_id = warehouses.filtered(lambda wh: wh.id == wh_id)
                    order_fake.order_line = FakeCollection(list(filter(lambda line: line.product_id.id in wh_vals['product_ids'], original_order_fake_order_line)))
                    wh_carrier_options = self._generate_shipping_carrier_option(wh_vals, order_fake, carrier)
                    if not wh_carrier_options:
                        has_error = True
                    else:
                        for _option in wh_carrier_options:
                            if _option.get('carrier_id'):
                                found_carrier_ids.add(_option['carrier_id'])
                        new_base_option['sub_options'][wh_id] = wh_carrier_options

                if has_error:
                    continue

                # now that we've collected details for this carrier, we likely have more than one carrier's rates
                _logger.info('  from ' + str(carrier) + ' found ' + str(found_carrier_ids))
                for carrier_id in found_carrier_ids:
                    carrier_option = deepcopy(base_option)
                    carrier_option['carrier_id'] = False
                    for wh_id, wh_vals in base_option['sub_options'].items():
                        for co in new_base_option['sub_options'].get(wh_id, []):
                            if co.get('carrier_id') == carrier_id:
                                # we have found the rate!
                                carrier_option['carrier_id'] = carrier_id
                                carrier_option['sub_options'][wh_id] = co
                    if carrier_option['carrier_id']:
                        carrier_option['shipping_price'] = self._get_shipping_price_for_options(carrier_option['sub_options'])
                        carrier_option['requested_date'] = self._get_max_requested_date(carrier_option['sub_options'])
                        carrier_option['transit_days'] = self._get_max_transit_days(carrier_option['sub_options'])
                        options.append(carrier_option)

            #restore values in case more processing occurs
            order_fake.warehouse_id = original_order_fake_warehouse_id
            order_fake.order_line = original_order_fake_order_line
            if not options:
                options.append(base_option)
            return options

    def _get_shipping_price_for_options(self, sub_options):
        return sum(wh_option.get('shipping_price', 0.0) for wh_option in sub_options.values())

    def _get_max_requested_date(self, sub_options):
        max_requested_date = None
        for option in sub_options.values():
            requested_date = option.get('requested_date')
            if requested_date and not max_requested_date:
                max_requested_date = requested_date
            elif requested_date:
                if requested_date > max_requested_date:
                    max_requested_date = requested_date
        return max_requested_date

    def _get_max_transit_days(self, sub_options):
        return max(wh_option.get('transit_days', 0) or 0 for wh_option in sub_options.values())

    def _generate_shipping_carrier_option(self, base_option, order_fake, carrier):
        # some carriers look at the order carrier_id
        order_fake.carrier_id = carrier
        date_planned = base_option.get('date_planned')
        order_fake.date_planned = date_planned

        # this logic comes from "delivery.models.sale_order.SaleOrder"
        try:
            result = None
            date_delivered = None
            transit_days = 0
            if carrier.delivery_type not in ['fixed', 'base_on_rule']:
                if hasattr(carrier, 'rate_shipment_multi'):
                    result = carrier.rate_shipment_multi(order=order_fake)
                elif hasattr(carrier, 'rate_shipment_date_planned'):
                    # New API
                    result = carrier.rate_shipment_date_planned(order_fake, base_option.get('date_planned'))
                    if result:
                        if not result.get('success'):
                            return None
                        price_unit, transit_days, date_delivered = result['price'], result.get('transit_days'), result.get('date_delivered')
                elif hasattr(carrier, 'get_shipping_price_for_plan'):
                    # Old API
                    result = carrier.get_shipping_price_for_plan(order_fake, base_option.get('date_planned'))
                if result and isinstance(result, list) and not isinstance(result[0], dict):
                    # this detects the above only if it isn't a list of dictionaries (aka multi-rating result)
                    price_unit, transit_days, date_delivered = result[0]
                elif not result:
                    rate = carrier.rate_shipment(order_fake)
                    if rate and rate.get('success'):
                        price_unit = rate['price']
                        if rate.get('transit_days'):
                            transit_days = rate.get('transit_days')
                        if rate.get('date_delivered'):
                            date_delivered = rate.get('date_delivered')
                    else:
                        _logger.warning('returning None because carrier: ' + str(carrier) + ' returned rate: ' + str(rate))
                        return None
            else:
                carrier = carrier.available_carriers(order_fake.partner_shipping_id)
                if not carrier:
                    return None
                res = carrier.rate_shipment(order_fake)
                price_unit = res['price']
                if order_fake.company_id.currency_id.id != order_fake.pricelist_id.currency_id.id:
                    price_unit = order_fake.company_id.currency_id.with_context(date=order_fake.date_order).compute(price_unit, order_fake.pricelist_id.currency_id)

            if result and isinstance(result, list):
                res = []
                for rate in result:
                    rate_carrier = rate.get('carrier')
                    if not rate_carrier:
                        continue
                    price_unit = rate['price']
                    date_delivered = rate.get('date_delivered')
                    transit_days = rate.get('transit_days')

                    if date_planned and transit_days and not date_delivered:
                        # compute from planned date anc current rate carrier
                        date_delivered = rate_carrier.calculate_date_delivered(date_planned, transit_days)
                    elif date_planned and date_delivered and not transit_days:
                        transit_days = rate_carrier.calculate_transit_days(date_planned, date_delivered)

                    final_price = float(price_unit) * (1.0 + (float(rate_carrier.margin) / 100.0))
                    option = deepcopy(base_option)
                    option['carrier_id'] = rate_carrier.id
                    option['shipping_price'] = final_price
                    option['requested_date'] = fields.Datetime.to_string(date_delivered) if (date_delivered and isinstance(date_delivered, datetime)) else date_delivered
                    option['transit_days'] = transit_days
                    res.append(option)
                return res
            else:
                final_price = float(price_unit) * (1.0 + (float(carrier.margin) / 100.0))
                option = deepcopy(base_option)
                option['carrier_id'] = carrier.id
                option['shipping_price'] = final_price
                option['requested_date'] = fields.Datetime.to_string(date_delivered) if (date_delivered and isinstance(date_delivered, datetime)) else date_delivered
                option['transit_days'] = transit_days
                return [option]
        except Exception as e:
            _logger.info("Exception collecting carrier rates: " + str(e))
            # Want to see more?
            # _logger.exception(e)

        return None


class SaleOrderPlanningOption(models.TransientModel):
    _name = 'sale.order.planning.option'
    _description = 'Order Planning Option'

    def create(self, values):
        def datetime_converter(o):
            if isinstance(o, datetime):
                return str(o)
        if not isinstance(values, list):
            values = [values]
        for option_values in values:
            if 'sub_options' in option_values and not isinstance(option_values['sub_options'], str):
                for wh_id, option in option_values['sub_options'].items():
                    if option.get('date_planned'):
                        option['date_planned'] = str(option['date_planned'])
                option_values['sub_options'] = dumps(option_values['sub_options'], default=datetime_converter)
        return super(SaleOrderPlanningOption, self).create(values)

    def _compute_sub_options_text(self):
        for option in self:
            sub_options = option.sub_options
            if sub_options and not isinstance(sub_options, dict):
                sub_options = loads(sub_options)
            if not isinstance(sub_options, dict):
                option.sub_options_text = ''
                continue

            line = ''
            for wh_id, wh_option in sub_options.items():
                product_skus = (str(s) for s in wh_option.get('product_skus', []))
                date_planned = wh_option.get('date_planned') or ''
                product_skus = ', '.join(product_skus)
                requested_date = wh_option.get('requested_date', '') or ''
                shipping_price = float(wh_option.get('shipping_price', 0.0) or 0)
                transit_days = int(wh_option.get('transit_days', 0) or 0)

                line += """WH %d :: %s
  Date Planned:   %s
  Requested Date: %s
  Transit Days:   %d
  Shipping Price: %.2f

""" % (int(wh_id), product_skus, date_planned, requested_date, transit_days, shipping_price)

            option.sub_options_text = line

    plan_id = fields.Many2one('sale.order.make.plan', 'Plan', ondelete='cascade')
    warehouse_id = fields.Many2one('stock.warehouse', string='Warehouse')
    date_planned = fields.Datetime('Planned Date')
    requested_date = fields.Datetime('Requested Date')
    carrier_id = fields.Many2one('delivery.carrier', string='Carrier')
    transit_days = fields.Integer('Transit Days')
    shipping_price = fields.Float('Shipping Price')
    sub_options = fields.Text('Sub Options JSON')
    sub_options_text = fields.Text('Sub Options', compute=_compute_sub_options_text)

    def select_plan(self):
        for option in self:
            option.plan_id.select_option(option)
            return
