#!/usr/bin/env python3
"""
Cariflex - FlexMeasures Scheduling Service
Creates EV charging schedules based on OpenADR price signals.
Sends schedules to CitrineOS via OCPP profiles.
"""

import json, logging, os, re, sys
from datetime import datetime, timezone, timedelta
import requests

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("cariflex-scheduling")

# Config
FM_HOST = os.getenv("FM_HOST", "https://cariflex.digitribe.fr")
FM_EMAIL = os.getenv("FM_EMAIL", "admin@digitribe.fr")
FM_PWD = os.getenv("FM_PWD", "Digitribe972")
CITRINEOS_URL = os.getenv("CITRINEOS_URL", "http://cariflex-citrineos-server:8080")

# EV Charge Point -> FM SensorID -> OCPP connector
EVSE_CONFIG = {
    # sensor_id: (charge_point_id, connector_id, max_power_kw)
    61: ("CP001", 1, 22),
    62: ("CP002", 1, 22),
    63: ("CP003", 1, 22),
    64: ("CP004", 1, 22),
    65: ("CP005", 1, 22),
    66: ("CP006", 1, 11),
    67: ("CP007", 1, 11),
    68: ("CP008", 1, 11),
    69: ("CP009", 1, 11),
    70: ("CP010", 1, 11),
}

fm_session = None


def fm_login():
    global fm_session
    try:
        s = requests.Session(); s.verify = False
        r = s.get(f"{FM_HOST}/login", timeout=15)
        m = re.search(r"csrf_token[^>]*value=[\"\\']([^\"\\']+)", r.text)
        if m:
            r = s.post(f"{FM_HOST}/login", data={"email":FM_EMAIL,"password":FM_PWD,"csrf_token":m.group(1),"remember":"y"}, allow_redirects=True, timeout=15)
            if "dashboard" in r.url or r.status_code == 200:
                fm_session = s
                return True
    except Exception as e:
        logger.error(f"FM login: {e}")
    return False


def get_price_forecast():
    """Get latest price forecast from FM (sensor 84 - OpenADR prices)."""
    if not fm_session:
        return None
    try:
        r = fm_session.get(f"{FM_HOST}/api/v3_0/sensors/84/data?limit=48", timeout=30)
        if r.status_code == 200:
            return r.json()
    except Exception as e:
        logger.error(f"Price forecast: {e}")
    return None


def get_load_control_signal():
    """Get latest load control signal from FM (sensor 86)."""
    if not fm_session:
        return None
    try:
        r = fm_session.get(f"{FM_HOST}/api/v3_0/sensors/86/data?limit=1", timeout=30)
        if r.status_code == 200:
            data = r.json()
            if data and len(data) > 0:
                return float(data[-1].get("event_value", 0))
    except Exception as e:
        logger.error(f"Load control: {e}")
    return 0


def get_ev_soc(sensor_id):
    """Get current SOC for an EV sensor."""
    try:
        r = fm_session.get(f"{FM_HOST}/api/v3_0/sensors/{sensor_id}/data?limit=1", timeout=30)
        if r.status_code == 200:
            data = r.json()
            if data and len(data) > 0:
                return float(data[-1].get("event_value", 0))
    except Exception as e:
        logger.error(f"EV SOC: {e}")
    return 50.0  # Default 50%


def calculate_charging_schedule(prices, load_control, current_soc, max_power_kw):
    """
    Calculate optimal charging schedule based on prices and load control.
    
    Returns list of (timestamp, power_kw) tuples.
    """
    schedule = []
    now = datetime.now(timezone.utc)
    
    # Sort price periods by cheapest first
    sorted_prices = sorted(prices, key=lambda x: x.get("event_value", 0))
    
    # Load control factor: 0=normal, 0.5=reduce, 1=cut
    lc_factor = 1.0 - load_control  # 1.0 = full, 0.5 = half, 0 = cut
    
    # SOC target: charge to 80% during cheap hours
    target_soc = 80
    soc_needed = max(0, target_soc - current_soc)
    
    if soc_needed <= 0:
        return schedule  # Already charged
    
    # Calculate how many hours needed at max power
    hours_needed = (soc_needed * 0.6) / max_power_kw  # Rough estimate (60% of 100kWh battery)
    
    # Select cheapest hours
    selected_hours = sorted_prices[:int(hours_needed) + 1]
    
    for price_point in selected_hours:
        ts = price_point.get("start", now.isoformat())
        price = price_point.get("event_value", 0)
        
        # Calculate power based on price and load control
        if price < 50:  # Cheap
            power = max_power_kw * lc_factor
        elif price < 100:  # Medium
            power = (max_power_kw * 0.5) * lc_factor
        else:  # Expensive
            power = 0  # Don't charge during expensive hours
        
        if power > 0:
            schedule.append((ts, power))
    
    return schedule


def send_ocpp_charging_profile(charge_point_id, connector_id, power_limit_kw, start_time):
    """Send charging profile to EVSE via CitrineOS (OCPP)."""
    try:
        payload = {
            "connectorId": connector_id,
            "csChargingProfiles": {
                "chargingProfileId": 1,
                "stackLevel": 0,
                "chargingProfilePurpose": "TxDefaultProfile",
                "chargingProfileKind": "Absolute",
                "chargingSchedule": {
                    "startSchedule": start_time,
                    "chargingRateUnit": "W",
                    "chargingSchedulePeriod": [
                        {
                            "startPeriod": 0,
                            "limit": int(power_limit_kw * 1000)  # kW -> W
                        },
                        {
                            "startPeriod": 3600,  # After 1 hour
                            "limit": int(power_limit_kw * 1000)
                        }
                    ]
                }
            }
        }
        
        # CitrineOS uses OCPP 2.0.1 API
        r = requests.put(
            f"{CITRINEOS_URL}/api/v1/ocpp-charge-points/{charge_point_id}/set-charging-profile",
            json=payload, timeout=10
        )
        
        logger.info(f"OCPP profile -> {charge_point_id}: {power_limit_kw}kW @ {start_time} -> {r.status_code}")
        return r.status_code in [200, 201, 204]
    except Exception as e:
        logger.error(f"OCPP profile error: {e}")
        return False


def run_scheduling():
    """Main scheduling loop: OpenADR prices → FM schedules → OCPP profiles."""
    logger.info("Running scheduling cycle...")
    
    # 1. Get price forecast from OpenADR
    prices = get_price_forecast()
    if not prices:
        logger.warning("No price data available")
        return
    
    logger.info(f"Price forecast: {len(prices)} periods available")
    
    # 2. Get load control signal
    load_control = get_load_control_signal()
    logger.info(f"Load control signal: {load_control}")
    
    # 3. For each EVSE, calculate optimal schedule
    for sensor_id, (cp_id, connector_id, max_power) in EVSE_CONFIG.items():
        # Get current SOC
        current_soc = get_ev_soc(sensor_id)
        
        # Calculate schedule
        schedule = calculate_charging_schedule(prices, load_control, current_soc, max_power)
        
        if schedule:
            # Send to CitrineOS via OCPP
            for ts, power in schedule:
                send_ocpp_charging_profile(cp_id, connector_id, power, ts)
                logger.info(f"Schedule {cp_id}: {power} kW at {ts}")
        else:
            logger.info(f"No charging needed for {cp_id} (SOC: {current_soc}%)")


async def main():
    logger.info("Cariflex Scheduling Service starting")
    logger.info(f"FM: {FM_HOST}")
    logger.info(f"CitrineOS: {CITRINEOS_URL}")
    logger.info(f"EVSEs: {len(EVSE_CONFIG)}")
    
    fm_login()
    
    # Run scheduling every 5 minutes
    while True:
        try:
            run_scheduling()
        except Exception as e:
            logger.error(f"Scheduling error: {e}")
            fm_login()
        
        await asyncio.sleep(300)  # 5 minutes


if __name__ == "__main__":
    asyncio.run(main())
