import swisseph as swe
from datetime import datetime, timedelta

swe.set_ephe_path("/usr/share/ephe")


TRANSIT_PLANETS = {
    "Sun": swe.SUN,
    "Moon": swe.MOON,
    "Mercury": swe.MERCURY,
    "Venus": swe.VENUS,
    "Mars": swe.MARS,
    "Jupiter": swe.JUPITER,
    "Saturn": swe.SATURN,
}

MAJOR_PLANET_KEYS = [
    "Sun",
    "Moon",
    "Mercury",
    "Venus",
    "Mars",
    "Jupiter",
    "Saturn",
]

MAJOR_ASPECTS = {
    "conjunction": 0,
    "sextile": 60,
    "square": 90,
    "trine": 120,
    "opposition": 180,
}

SYMBOLS = {
    "Sun": "☉",
    "Moon": "☽",
    "Mercury": "☿",
    "Venus": "♀",
    "Mars": "♂",
    "Jupiter": "♃",
    "Saturn": "♄",

    "conjunction": "☌",
    "sextile": "✶",
    "square": "□",
    "trine": "△",
    "opposition": "☍",
}

ASPECT_NAMES = {
    "conjunction": "Conjunction",
    "sextile": "Sextile",
    "square": "Square",
    "trine": "Trine",
    "opposition": "Opposition",
}


HARD_ASPECTS = {
    "conjunction",
    "square",
    "opposition",
}

SOFT_ASPECTS = {
    "sextile",
    "trine",
}

PERSONAL_NATALS = {
    "Sun",
    "Moon",
    "Mercury",
    "Venus",
    "Mars",
}

IMPORTANT_SAME_PLANETS = {
    "Sun",
    "Moon",
    "Mercury",
    "Venus",
    "Mars",
    "Jupiter",
    "Saturn",
}

ALLOWED_TRANSITS = {
    "Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn",
    "Uranus", "Neptune", "Pluto", "Chiron", "Lilith", "Pholus", "Vesta", "Juno"
}

# Normal orb limits
HARD_ORB_LIMIT = 1.00
SOFT_ORB_LIMIT = 1.00

# Same planet special orb limits
SAME_IMPORTANT_HARD_ORB = 1.00
SAME_IMPORTANT_SOFT_ORB = 1.00

SAME_OTHER_HARD_ORB = 1.00
SAME_OTHER_SOFT_ORB = 1.00

# Jupiter rules
JUPITER_SOFT_ORB = 1.00
JUPITER_HARD_ORB = 1.00

# Saturn rules
SATURN_SOFT_ORB = 1.00
SATURN_HARD_ORB = 1.00

# Per date max result
# None = show all HIGHEST rows per date
MAX_EVENTS_PER_DATE = None


def normalize_degree(value):
    return value % 360


def angle_diff(a, b):
    d = abs(a - b) % 360
    return d if d <= 180 else 360 - d


def format_orb(orb):
    deg = int(orb)
    minutes = round((orb - deg) * 60)

    if minutes == 60:
        deg += 1
        minutes = 0

    return f"{deg}° {minutes}'"


def format_date(date_obj):
    return date_obj.strftime("%B %d, %Y")


def normalize_planet_name(name):
    clean = str(name or "").replace("_", " ").strip().lower()

    aliases = {
        "sun": "Sun",
        "moon": "Moon",
        "mercury": "Mercury",
        "venus": "Venus",
        "mars": "Mars",
        "jupiter": "Jupiter",
        "saturn": "Saturn",
    }

    return aliases.get(clean, clean.title())


def normalize_natal_planets(natal_planets):
    if isinstance(natal_planets, list):
        return natal_planets

    result = []

    for key, value in natal_planets.items():
        planet_name = value.get("planet") or key

        result.append({
            "planet": normalize_planet_name(planet_name),
            "longitude": value.get("longitude"),
        })

    return result


def get_transit_longitudes(date_obj):
    jd = swe.julday(
        date_obj.year,
        date_obj.month,
        date_obj.day,
        12.0,
    )

    data = {}

    for name, planet_id in TRANSIT_PLANETS.items():
        try:
            result = swe.calc_ut(jd, planet_id)
            data[name] = normalize_degree(result[0][0])
        except Exception as e:
            print(f"Transit calculation error for {name}: {e}")

    return data


def get_effect(aspect_name, transit_name=None, natal_name=None):
    aspect_name = str(aspect_name or "").lower()
    transit_name = str(transit_name or "")
    natal_name = str(natal_name or "")

    if aspect_name in ("trine", "sextile"):
        return "POSITIVE"

    if aspect_name in ("square", "opposition"):
        return "NEGATIVE"

    if aspect_name == "conjunction":
        # Conjunction with malefics is negative in this system
        if transit_name in {"Saturn", "Mars"} or natal_name in {"Saturn", "Mars"}:
            return "NEGATIVE"
        return "NEUTRAL"

    return "NEUTRAL"


def get_color(effect, impact=None):
    effect = str(effect or "").upper()

    if effect == "POSITIVE":
        return "green"

    if effect == "NEGATIVE":
        return "red"

    return "gray"


def get_impact_by_orb(orb, transit_name=None, natal_name=None, aspect_name=None):
    # Precise per-planet thresholds derived from target screenshots.
    # We use title-case for keys to be safe.
    transit_name = str(transit_name or "").title()
    
    thresholds = {
        "Sun": 1.00,     # Matches 0° 55' Square on May 16th
        "Moon": 0.50,    # Matches 0° 29' Sextile on May 7th
        "Mercury": 0.40, # Matches 0° 20' Opposition on May 11th
        "Venus": 0.55,
        "Mars": 0.55,
        "Jupiter": 0.55,
        "Saturn": 0.55,
        "Uranus": 0.40,
        "Neptune": 0.40,
        "Pluto": 0.40,
        "Chiron": 0.40,
        "Lilith": 0.40,
        "Pholus": 0.30,
        "Vesta": 0.30,
        "Juno": 0.30,
    }
    
    limit = thresholds.get(transit_name, 0.40)

    if orb <= limit:
        return "HIGHEST"

    return None


def is_showable_aspect(transit_name, natal_name, aspect_name, orb):
    # Support all major and minor bodies
    # Return True for any aspect that meets the global orbs defined in get_impact_by_orb
    # The actual filtering happens in get_impact_by_orb
    return True


def get_sort_score(transit_name, natal_name, aspect_name, orb):
    score = 0

    if transit_name == natal_name:
        score += 8

    if orb <= 0.10:
        score += 6
    elif orb <= 0.20:
        score += 5
    elif orb <= 0.30:
        score += 4
    elif orb <= 0.40:
        score += 3
    elif orb <= 0.55:
        score += 2
    else:
        score += 1

    if aspect_name in ("conjunction", "opposition"):
        score += 5
    elif aspect_name == "square":
        score += 4
    elif aspect_name == "trine":
        score += 3
    elif aspect_name == "sextile":
        score += 2

    if natal_name in {"Sun", "Moon"}:
        score += 4
    elif natal_name in {"Mercury", "Venus", "Mars"}:
        score += 3
    elif natal_name in {"Jupiter", "Saturn"}:
        score += 1

    if transit_name in {"Sun", "Mars"}:
        score += 3
    elif transit_name in {"Mercury", "Venus"}:
        score += 2
    elif transit_name in {"Moon", "Saturn"}:
        score += 1

    return score


def calculate_upcoming_aspects(
    natal_planets,
    start_date=None,
    days=14,
    tz_offset=5.5,
):
    if start_date is None:
        now_utc = datetime.utcnow()
        local_now = now_utc + timedelta(hours=tz_offset)
        start_date = local_now.date()

    if isinstance(start_date, datetime):
        start_date = start_date.date()

    if isinstance(start_date, str):
        start_date = datetime.strptime(start_date, "%Y-%m-%d").date()

    natal_planets = normalize_natal_planets(natal_planets)

    natal_map = {}

    for item in natal_planets:
        planet_name = normalize_planet_name(item.get("planet", ""))
        longitude = item.get("longitude")

        if planet_name in MAJOR_PLANET_KEYS and longitude is not None:
            natal_map[planet_name] = float(longitude)

    if not natal_map:
        return []

    final_results = []
    shown_events = set()

    for i in range(days):
        current_date = start_date + timedelta(days=i)
        transit_map = get_transit_longitudes(current_date)

        day_candidates = []

        for transit_name, transit_lon in transit_map.items():
            if transit_name not in MAJOR_PLANET_KEYS:
                continue

            for natal_name, natal_lon in natal_map.items():
                if natal_name not in MAJOR_PLANET_KEYS:
                    continue

                diff = angle_diff(transit_lon, natal_lon)

                for aspect_name, aspect_angle in MAJOR_ASPECTS.items():
                    orb = abs(diff - aspect_angle)

                    if not is_showable_aspect(
                        transit_name=transit_name,
                        natal_name=natal_name,
                        aspect_name=aspect_name,
                        orb=orb,
                    ):
                        continue

                    impact = get_impact_by_orb(
                        orb,
                        transit_name=transit_name,
                        natal_name=natal_name,
                        aspect_name=aspect_name,
                    )

                    # Only HIGHEST impact show
                    if impact != "HIGHEST":
                        continue

                    event_key = (
                        transit_name,
                        natal_name,
                        aspect_name,
                    )

                    if event_key in shown_events:
                        continue

                    effect = get_effect(
                        aspect_name=aspect_name,
                        transit_name=transit_name,
                        natal_name=natal_name,
                    )

                    color = get_color(effect, impact)

                    score = get_sort_score(
                        transit_name=transit_name,
                        natal_name=natal_name,
                        aspect_name=aspect_name,
                        orb=orb,
                    )

                    # For internal sorting (Date -> Natal Index -> Transit Index -> Orb)
                    try:
                        natal_idx = MAJOR_PLANET_KEYS.index(natal_name)
                    except:
                        natal_idx = 99
                    
                    try:
                        transit_idx = MAJOR_PLANET_KEYS.index(transit_name)
                    except:
                        transit_idx = 99

                    item = {
                        "date": format_date(current_date),

                        "transit_planet": transit_name,
                        "transit_symbol": SYMBOLS.get(transit_name, transit_name[0]),

                        "aspect": ASPECT_NAMES.get(aspect_name, aspect_name.title()),
                        "aspect_symbol": SYMBOLS.get(aspect_name, aspect_name[0]),

                        "natal_planet": natal_name,
                        "natal_symbol": SYMBOLS.get(natal_name, natal_name[0]),

                        "orb": format_orb(orb),
                        "effect": effect,
                        "color": color,
                        "impact": impact,

                        "_sort_date": current_date,
                        "_orb_raw": orb,
                        "_natal_idx": natal_idx,
                        "_transit_idx": transit_idx,
                        "_score": score,
                        "_event_key": event_key,
                    }

                    day_candidates.append(item)

        if not day_candidates:
            continue

        unique_day = {}

        for item in day_candidates:
            key = item["_event_key"]

            if key not in unique_day:
                unique_day[key] = item
            else:
                old = unique_day[key]

                if item["_score"] > old["_score"]:
                    unique_day[key] = item
                elif item["_score"] == old["_score"] and item["_orb_raw"] < old["_orb_raw"]:
                    unique_day[key] = item

        day_candidates = list(unique_day.values())

        day_candidates.sort(
            key=lambda x: (
                x["_natal_idx"],
                x["_transit_idx"],
                x["_orb_raw"],
            )
        )
    
        if MAX_EVENTS_PER_DATE is None:
            selected_day = day_candidates
        else:
            selected_day = day_candidates[:MAX_EVENTS_PER_DATE]

        for item in selected_day:
            event_key = item["_event_key"]

            if event_key in shown_events:
                continue

            shown_events.add(event_key)
            final_results.append(item)

    final_results.sort(
        key=lambda x: (
            x["_sort_date"],
            x["_natal_idx"],
            x["_transit_idx"],
            x["_orb_raw"],
        )
    )

    clean_results = []

    for item in final_results:
        clean_item = {
            key: value
            for key, value in item.items()
            if not key.startswith("_")
        }

        clean_results.append(clean_item)

    return clean_results