import os
import time
import pandas as pd
from neo_api_client import NeoAPI
import pyotp
from dotenv import load_dotenv

# Load environment variables
load_dotenv()


class AuthManager:
    """Handles Authentication with Kotak Neo API."""

    def __init__(self):
        self.client = None

    def _get_callbacks(self):
        return {
            "on_message": lambda m: None,
            "on_error": lambda e: None,
            "on_close": lambda c: None,
            "on_open": lambda o: None,
        }

    def login(
        self, consumer_key=None, mobile=None, ucc=None, totp_secret=None, mpin=None
    ):
        c_key = consumer_key or os.getenv("KOTAK_CONSUMER_KEY")
        mob = mobile or os.getenv("KOTAK_MOBILE")
        u_code = ucc or os.getenv("KOTAK_UCC")
        t_secret = totp_secret or os.getenv("KOTAK_TOTP_SECRET")
        u_mpin = mpin or os.getenv("KOTAK_MPIN")

        if not all([c_key, mob, u_code, t_secret, u_mpin]):
            raise ValueError("Missing Credentials. Please check .env file.")

        if len(mob) == 10:
            mob = "+91" + mob

        try:
            self.client = NeoAPI(consumer_key=c_key, environment="prod")
            cbs = self._get_callbacks()
            self.client.on_message = cbs["on_message"]
            self.client.on_error = cbs["on_error"]

            # Clean secret key (remove spaces if any)
            clean_secret = t_secret.replace(" ", "")
            generated_totp = pyotp.TOTP(clean_secret).now()

            self.client.totp_login(
                mobile_number=mob, ucc=u_code.upper(), totp=generated_totp
            )
            self.client.totp_validate(mpin=u_mpin)

            return self.client
        except Exception as e:
            raise ConnectionError(f"Authentication Failed: {str(e)}")


class DataManager:
    """Handles fetching and hydrating data from the API."""

    def __init__(self, client):
        self.client = client

    def fetch_portfolio(self):
        try:
            time.sleep(1)
            holdings = self.client.holdings()
            positions = self.client.positions()
            return holdings, positions
        except Exception as e:
            raise RuntimeError(f"Failed to fetch portfolio: {str(e)}")

    def hydrate_live_prices(self, holdings_data):
        """
        Updates holdings with live market data using batching and smart matching.
        Returns: (updated_data, update_count)
        """
        if not holdings_data or "data" not in holdings_data:
            return holdings_data, 0

        data = holdings_data["data"]
        tokens = []

        # Collect tokens
        for item in data:
            if "instrumentToken" in item and "exchangeSegment" in item:
                tokens.append(
                    {
                        "instrument_token": str(item["instrumentToken"]),
                        "exchange_segment": item["exchangeSegment"],
                    }
                )

        if not tokens:
            return holdings_data, 0

        live_map = {}
        batch_size = 20

        # Fetch in batches
        for i in range(0, len(tokens), batch_size):
            batch = tokens[i : i + batch_size]
            try:
                quotes = self.client.quotes(instrument_tokens=batch)
                self._parse_quotes(quotes, live_map)
                time.sleep(0.1)  # Be polite to API
            except Exception:
                pass

        updates = 0

        # Update Holdings with Safety Checks
        for item in data:
            tk = str(item.get("instrumentToken", ""))
            seg = item.get("exchangeSegment")

            # Try precise match first (Token_Segment)
            key = f"{tk}_{seg}"

            # Fallback for Token only match
            if key not in live_map and tk in live_map:
                key = tk

            if key in live_map:
                live_data = live_map[key]
                new_price = live_data["price"]
                live_sym = str(live_data.get("symbol", "")).upper()
                holding_sym = str(
                    item.get("tradingSymbol") or item.get("instrumentName", "")
                ).upper()

                # --- INTELLIGENT MATCHING ---
                should_update = False

                # 1. Price Sanity
                if new_price <= 0:
                    continue

                # 2. Symbol Check (Prevents Token Collision like KEI vs KPIGREEN)
                clean_live = live_sym.split("-")[0].strip()
                clean_holding = holding_sym.split("-")[0].strip()

                if (clean_live in clean_holding) or (clean_holding in clean_live):
                    should_update = True

                if should_update:
                    item["currentPrice"] = new_price
                    item["closingPrice"] = new_price
                    item["dayChange"] = live_data["change"]
                    item["dayChangePct"] = live_data["p_change"]
                    updates += 1

        return holdings_data, updates

    def _parse_quotes(self, response, price_map):
        data_list = []
        if isinstance(response, list):
            data_list = response
        elif isinstance(response, dict):
            data_list = response.get("data", response.get("message", []))

        for q in data_list:
            if isinstance(q, dict):
                tk = str(q.get("instrument_token", "") or q.get("exchange_token", ""))
                seg = q.get("exchange_segment") or q.get("exchange")

                ltp = float(q.get("ltp") or q.get("last_price") or 0)

                # Extract Change Info
                change = q.get("change") or q.get("net_change")
                p_change = q.get("per_change") or q.get("percent_change")
                sym = q.get("display_symbol") or q.get("trading_symbol")

                val_change = float(change) if change is not None else 0.0
                val_p_change = float(p_change) if p_change is not None else 0.0

                # OHLC Fallback Calculation (Critical for accurate Day P&L)
                if val_change == 0.0 and "ohlc" in q and q["ohlc"]:
                    try:
                        prev_close = float(q["ohlc"].get("close", 0))
                        if prev_close > 0 and ltp > 0:
                            val_change = ltp - prev_close
                            val_p_change = (val_change / prev_close) * 100
                    except Exception:
                        pass

                if tk and ltp > 0:
                    key = f"{tk}_{seg}" if seg else tk
                    price_map[key] = {
                        "price": ltp,
                        "change": val_change,
                        "p_change": val_p_change,
                        "symbol": sym,
                    }


class AnalyticsEngine:
    """Handles Pandas transformations and KPI calculations."""

    @staticmethod
    def process_holdings(json_data):
        if not json_data or "data" not in json_data:
            return pd.DataFrame()

        df = pd.DataFrame(json_data["data"])
        if df.empty:
            return df

        # Rename to Internal Standards
        mapping = {
            "displaySymbol": "tradingSymbol",
            "holdingCost": "investedAmount",
            "mktValue": "currentValue",
            "unrealisedGainLoss": "profitAndLoss",
            "closingPrice": "currentPrice",
        }
        df = df.rename(columns=mapping)
        df = df.loc[:, ~df.columns.duplicated()]

        # Ensure Columns Exist & Clean Data
        cols = [
            "quantity",
            "averagePrice",
            "currentPrice",
            "profitAndLoss",
            "investedAmount",
            "currentValue",
            "dayChange",
            "dayChangePct",
        ]
        for col in cols:
            if col not in df.columns:
                df[col] = 0.0
            df[col] = pd.to_numeric(
                df[col].astype(str).str.replace(",", ""), errors="coerce"
            ).fillna(0.0)

        # Recalculate Fields based on Live Price
        df["investedAmount"] = df.apply(
            lambda r: (
                (r["quantity"] * r["averagePrice"])
                if r["investedAmount"] == 0
                else r["investedAmount"]
            ),
            axis=1,
        )
        df["currentValue"] = df["quantity"] * df["currentPrice"]
        df["profitAndLoss"] = df["currentValue"] - df["investedAmount"]
        df["pnl_pct"] = df.apply(
            lambda r: (
                (r["profitAndLoss"] / r["investedAmount"] * 100)
                if r["investedAmount"] != 0
                else 0
            ),
            axis=1,
        )

        # Day P&L Calculation
        df["dayPnL"] = df["quantity"] * df["dayChange"]

        return df

    @staticmethod
    def get_sector_data(df):
        if "sector" in df.columns and not df.empty:
            return df.groupby("sector")["currentValue"].sum().reset_index()
        return pd.DataFrame()
