from datetime import date, datetime
from typing import List, Optional

from loguru import logger

import optrabot.config as optrabotcfg
from optrabot.broker.brokerconnector import BrokerConnector
from optrabot.broker.brokerfactory import BrokerFactory
from optrabot.broker.order import Leg, OptionRight, Order, OrderAction
from optrabot.config import Config
from optrabot.managedtrade import ManagedTrade
from optrabot.optionhelper import OptionHelper
from optrabot.signaldata import SignalData
from optrabot.symbolinfo import symbol_infos
from optrabot.tradetemplate.templatefactory import Template

# Delta threshold for OTM options - below this delta, options are considered too far OTM
# and not worth requesting additional data for
DELTA_THRESHOLD_MIN = 0.01

"""
Base class for all template processors
"""
class TemplateProcessorBase:
	broker_connector: BrokerConnector

	def __init__(self, template: Template):
		"""
		Initializes the template processor with the given template
		
		Raises:
			ValueError: If no broker connector is available for the template's account
		"""
		self._template = template
		self.broker_connector = BrokerFactory().getBrokerConnectorByAccount(self._template.account)
		if self.broker_connector is None:
			raise ValueError(f'No active broker connection found for account {self._template.account}')
		self._config: Config = optrabotcfg.appConfig
		self._determined_expiration_date: datetime.date = None
		self._dynamic_strike_load_attempted: set = set()  # Track which directions we've tried loading
		self._processing_protected_strikes: set[float] = set()  # Cumulative set of strikes used during position search
		self._condition_failure_reason: Optional[str] = None  # OTB-394: Stores reason when check_conditions fails

	async def composeEntryOrder(self, signalData: SignalData = None) -> Order:
		"""
		Composes the entry order based on the template and the optional signal data
		"""
		logger.debug('Creating entry order for template {}', self._template.name)
		
		# Check if explicit expiration_date is set (has priority over dte)
		if self._template.expiration_date is not None:
			self._determined_expiration_date = self._template.expiration_date
			logger.debug(f'Using explicit expiration date from template: {self._determined_expiration_date}')
		else:
			# Fall back to dte-based calculation
			self._determined_expiration_date = self.broker_connector.determine_expiration_date_by_dte(self._template.symbol, self._template.dte)
			if self._determined_expiration_date == None:
				raise ValueError(f'Unable to determine expiration date for template {self._template.name} with {self._template.dte} DTE !')
			logger.debug(f'Determined expiration date from DTE: {self._determined_expiration_date}')


	def composeTakeProfitOrder(self, managedTrade: ManagedTrade, fillPrice: float) -> Order:
		"""
		Composes the take profit order based on the template and the given fill price
		"""
		logger.debug('Creating take profit order for trade {}', managedTrade.trade.id)

	def composeStopLossOrder(self, managedTrade: ManagedTrade, fillPrice: float) -> Order:
		"""
		Composes the stop loss order based on the template and the given fill price
		"""
		logger.debug('Creating stop loss order for trade {}', managedTrade.trade.id)

	def hasTakeProfit(self) -> bool:
		"""
		Returns True if the template has a take profit defined
		"""
		return self._template.hasTakeProfit()
	
	def get_short_strike_from_delta(self, symbol: str, right: OptionRight, delta: int) -> float:
		"""
		Returns the short strike based on the given delta via the associated broker
		connector and the buffered price data
		"""
		return self.broker_connector.get_strike_by_delta(symbol, right, delta)
	
	def get_strike_by_price(self, symbol: str, right: OptionRight, price: float) -> float:
		"""
		Determines the strike based on a given premium price via the associated broker
		connector and the buffered price data
		"""
		return self.broker_connector.get_strike_by_price(symbol, right, price)

	def get_valid_strike(self, strike: float, expiration_date: datetime.date, higher: bool) -> float:
		"""
		Returns a valid strike price around the given strike. If the strike is not available,
		it will return the nearest higher or lower strike based on the 'higher' parameter.
		"""
		data = self.broker_connector.get_option_strike_data(self._template.symbol, expiration_date)
		try:
			strike_data = data.strikeData[strike]
			return strike
		except KeyError:
			strikes = data.strikeData.keys()
			return OptionHelper.get_next_strike(strike, strikes, higher)

	async def _get_wing_strike_with_dynamic_load(
		self,
		desired_strike: float,
		expiration_date: date,
		higher: bool,
		option_strike_data = None
	) -> Optional[float]:
		"""
		Get the exact wing strike, dynamically loading additional strikes if needed.
		
		Unlike get_next_strike_with_dynamic_load which finds the NEXT strike,
		this method tries to get the EXACT desired strike for a wing position.
		If the strike doesn't exist, it will attempt to load more strikes.
		
		Args:
			desired_strike: The exact strike price needed for the wing
			expiration_date: The expiration date for options
			higher: Direction for loading more strikes (True=higher, False=lower)
			option_strike_data: Optional pre-fetched option strike data
			
		Returns:
			The desired strike if available, or None if not obtainable
		"""
		if option_strike_data is None:
			option_strike_data = self.broker_connector.get_option_strike_data(
				self._template.symbol,
				expiration_date
			)
		
		if option_strike_data is None:
			return None
		
		strikes = list(option_strike_data.strikeData.keys())
		
		# Check if desired strike already exists
		if desired_strike in strikes:
			return desired_strike
		
		# Strike not available - try to load more
		direction = 'higher' if higher else 'lower'
		# Use desired_strike in the key so we can retry for different strikes
		load_key = f"wing_{direction}_{expiration_date}_{desired_strike}"
		
		# Avoid repeated attempts for the exact same strike
		if load_key in self._dynamic_strike_load_attempted:
			logger.debug(f'Already attempted to load {direction} wing strikes for {expiration_date} including {desired_strike}')
			return None
		
		# Request additional strikes
		logger.info(f'Wing strike {desired_strike} not available - requesting additional {direction} strikes')
		self._dynamic_strike_load_attempted.add(load_key)
		
		load_count = self._config.get_dynamic_strike_load_count() if self._config else 10
		success = await self.broker_connector.request_additional_strikes(
			self._template.symbol,
			expiration_date,
			direction,
			count=load_count,
			protected_strikes=self._processing_protected_strikes
		)
		
		if not success:
			logger.debug(f'Failed to load additional {direction} strikes for wing')
			return None
		
		# Retry with updated data
		option_strike_data = self.broker_connector.get_option_strike_data(
			self._template.symbol,
			expiration_date
		)
		strikes = list(option_strike_data.strikeData.keys())
		
		if desired_strike in strikes:
			# Add all newly loaded strikes to protected set
			self._processing_protected_strikes.add(desired_strike)
			logger.info(f'Successfully loaded wing strike {desired_strike}')
			return desired_strike
		
		logger.debug(f'Wing strike {desired_strike} still not available after loading')
		return None
	
	async def get_next_strike_with_dynamic_load(
		self, 
		current_strike: float, 
		expiration_date: date, 
		higher: bool,
		option_strike_data = None
	) -> Optional[float]:
		"""
		Get next strike, dynamically loading additional strikes if needed.
		
		This method wraps OptionHelper.get_next_strike() and adds dynamic loading
		capability when strikes are not available in the current buffer.
		
		Args:
			current_strike: The current strike to find the next from
			expiration_date: The expiration date for options
			higher: True to find higher strike, False for lower
			option_strike_data: Optional pre-fetched option strike data
			
		Returns:
			The next strike price, or None if no more strikes available
			(either due to reaching end of chain or delta < 0.01)
		"""
		if option_strike_data is None:
			option_strike_data = self.broker_connector.get_option_strike_data(
				self._template.symbol, 
				expiration_date
			)
		
		if option_strike_data is None:
			return None
		
		strikes = list(option_strike_data.strikeData.keys())
		next_strike = OptionHelper.get_next_strike(current_strike, strikes, higher)
		
		if next_strike is not None:
			return next_strike
		
		# No strike found in current data - try to load more
		direction = 'higher' if higher else 'lower'
		# Use current_strike in the key so we can retry for different edge strikes
		load_key = f"{direction}_{expiration_date}_{current_strike}"
		
		# Avoid repeated attempts for the exact same edge strike
		if load_key in self._dynamic_strike_load_attempted:
			logger.debug(f'Already attempted to load {direction} strikes beyond {current_strike} for {expiration_date}')
			return None
		
		# Check delta limit before requesting
		if not await self._is_strike_within_delta_limit(current_strike, expiration_date, higher, option_strike_data):
			logger.debug(f'Strike {current_strike} is beyond delta threshold - not loading more strikes')
			return None
		
		# Request additional strikes
		logger.debug(f'Requesting additional {direction} strikes beyond {current_strike}')
		self._dynamic_strike_load_attempted.add(load_key)
		
		load_count = self._config.get_dynamic_strike_load_count() if self._config else 10
		success = await self.broker_connector.request_additional_strikes(
			self._template.symbol,
			expiration_date,
			direction,
			count=load_count,
			protected_strikes=self._processing_protected_strikes
		)
		
		if not success:
			logger.debug(f'Failed to load additional {direction} strikes')
			return None
		
		# Retry with updated data
		option_strike_data = self.broker_connector.get_option_strike_data(
			self._template.symbol, 
			expiration_date
		)
		strikes = list(option_strike_data.strikeData.keys())
		next_strike = OptionHelper.get_next_strike(current_strike, strikes, higher)
		if next_strike is not None:
			self._processing_protected_strikes.add(next_strike)
		return next_strike
	
	async def _is_strike_within_delta_limit(
		self, 
		strike: float, 
		expiration_date: date, 
		higher: bool,
		option_strike_data = None
	) -> bool:
		"""
		Check if we should request more strikes beyond the current strike.
		
		This checks if the current edge strike has delta above the threshold (0.01).
		If delta is already at or below 0.01, there's no point in loading more strikes
		as they would be even more OTM with lower delta.
		
		Args:
			strike: The current edge strike
			expiration_date: The option expiration date
			higher: True if we want higher strikes (calls), False for lower (puts)
			option_strike_data: Optional pre-fetched strike data
			
		Returns:
			True if more strikes should be requested, False if at delta limit
		"""
		if option_strike_data is None:
			option_strike_data = self.broker_connector.get_option_strike_data(
				self._template.symbol, 
				expiration_date
			)
		
		if option_strike_data is None or strike not in option_strike_data.strikeData:
			# If we don't have data for this strike, allow loading
			return True
		
		strike_price_data = option_strike_data.strikeData[strike]
		
		# For higher strikes (OTM calls), check call delta
		# For lower strikes (OTM puts), check put delta
		if higher:
			delta = abs(strike_price_data.get_call_delta() or 0)
		else:
			delta = abs(strike_price_data.get_put_delta() or 0)
		
		# If delta is at or below threshold, we're too far OTM
		if delta <= DELTA_THRESHOLD_MIN:
			logger.debug(f'Strike {strike} {"call" if higher else "put"} delta {delta:.4f} <= threshold {DELTA_THRESHOLD_MIN}')
			return False
		
		return True
		
	def determine_expiration_by_dte(self) -> datetime.date:
		"""
		Determines the expiration date based on the DTE of the processed template via the associated broker
		connector and the buffered price data
		"""
		expiration_date = self.broker_connector.determine_expiration_date_by_dte(self._template.symbol, self._template.dte)
		if expiration_date == None:
			raise ValueError(f'Unable to determine expiration date for template {self._template.name} with {self._template.dte} DTE !')
		return expiration_date

	def _has_active_trade_in_group(self) -> bool:
		"""
		Checks if there is an active trade from any template in the same template group (including self)
		"""
		from optrabot.trademanager import TradeManager

		# Get all templates in the same group (including self!)
		all_templates = self._config.getTemplates()
		templates_in_group = [t.name for t in all_templates 
							if t.template_group == self._template.template_group]
		
		# Check if any of these templates have active trades
		trade_manager = TradeManager()
		active_trades = trade_manager.getManagedTrades()
		
		for trade in active_trades:
			if trade.isActive() and trade.template.name in templates_in_group:
				logger.info(f'Template "{trade.template.name}" in group "{self._template.template_group}" has an active trade')
				return True
		
		return False

	def check_conditions(self) -> bool:
		"""
		Checks the conditions of the template against the given market data.
		If conditions are not met, the reason is stored in _condition_failure_reason.
		"""
		self._condition_failure_reason = None

		# Check template group exclusivity first (before expensive VIX checks)
		if self._template.template_group is not None:
			if self._has_active_trade_in_group():
				self._condition_failure_reason = f'Template group "{self._template.template_group}" already has an active trade'
				logger.info(f'Template group "{self._template.template_group}" has active trade in another template. Blocking new trade.')
				return False
		
		if self._template.vix_max or self._template.vix_min:
			logger.debug('Checking VIX conditions')
			broker = BrokerFactory().getBrokerConnectorByAccount(self._template.account)
			if broker is None:
				self._condition_failure_reason = f'No broker connection available for account {self._template.account}'
				logger.error('No broker connection available for account {}', self._template.account)
				return False
			
			try:
				vixPrice = broker.getLastPrice(symbol_infos['VIX'].symbol)
			except Exception as e:
				self._condition_failure_reason = 'No VIX price data available'
				logger.warning('No price data for VIX available!')
				return False
			logger.debug('VIX Price: {}', vixPrice)
			if vixPrice:
				if self._template.vix_max:
					if vixPrice > self._template.vix_max:
						self._condition_failure_reason = f'VIX {vixPrice:.2f} exceeds defined maximum of {self._template.vix_max}'
						logger.info(f'Max VIX condition (max: {self._template.vix_max} current: {vixPrice}) not met. Ignoring signal.')
						return False
				if self._template.vix_min:
					if vixPrice < self._template.vix_min:
						self._condition_failure_reason = f'VIX {vixPrice:.2f} below defined minimum of {self._template.vix_min}'
						logger.info(f'Min VIX condition (min: {self._template.vix_min} current: {vixPrice}) not met. Ignoring signal.')
						return False
		return True

	def get_condition_failure_reason(self) -> Optional[str]:
		"""
		Returns the reason why the last check_conditions() call failed.
		Returns None if conditions were met or check_conditions() was not called yet.
		"""
		return self._condition_failure_reason
	
	def invert_leg_actions(self, legs: List[Leg]):
		"""
		Inverts the actions of the given legs (BUY <-> SELL)
		"""
		for leg in legs:
			if leg.action == OrderAction.BUY:
				leg.action = OrderAction.SELL
			elif leg.action == OrderAction.SELL:
				leg.action = OrderAction.BUY