from itertools import combinations


# =====================================================
# SETTINGS
# =====================================================

# Keep fewer bodies to avoid too many fake chart patterns.
# Add more later only if result is too low.
ALLOWED_PATTERN_BODIES = {
    "sun",
    "moon",
    "mercury",
    "venus",
    "mars",
    "jupiter",
    "saturn",
    "uranus",
    "neptune",
    "pluto",
    "chiron",
    "juno",
}

# Tight orbs for chart patterns.
# Wide orbs create too many patterns.
PATTERN_ORBS = {
    "opposition": 6,
    "square": 5,
    "trine": 5,
    "sextile": 4,
    "quincunx": 3,
}

ASPECT_ANGLES = {
    "opposition": 180,
    "square": 90,
    "trine": 120,
    "sextile": 60,
    "quincunx": 150,
}

# Final display limit.
# If you want same like reference website, keep low.
DISPLAY_LIMITS = {
    "t_square": 6,
    "yod": 6,
    "grand_trine": 6,
}


# =====================================================
# BASIC HELPERS
# =====================================================

def normalize_angle(angle):
    return angle % 360


def angle_diff(lon1, lon2):
    diff = abs(normalize_angle(lon1) - normalize_angle(lon2))
    return min(diff, 360 - diff)


def is_aspect(lon1, lon2, aspect_name):
    exact = ASPECT_ANGLES[aspect_name]
    orb_limit = PATTERN_ORBS[aspect_name]

    diff = angle_diff(lon1, lon2)
    actual_orb = abs(diff - exact)

    return actual_orb <= orb_limit, round(actual_orb, 4), round(diff, 4)


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

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

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


def clean_planet_name(name):
    return name.replace("_", " ").title()


def has_both_people(planets):
    owners = {p["owner"] for p in planets}
    return "user" in owners and "partner" in owners


def unique_body_key(p):
    return f'{p["owner"]}:{p["id"]}:{round(float(p["longitude"]), 4)}'


def score_pattern(aspects):
    """
    Lower score = stronger pattern.
    """
    return round(sum(float(a["orb"]) for a in aspects), 4)


def keep_best_unique(results, signature_fn, limit=None):
    """
    Remove duplicate patterns and keep strongest by lowest score.
    """
    best = {}

    for item in results:
        sig = signature_fn(item)

        if sig not in best:
            best[sig] = item
        elif item["score"] < best[sig]["score"]:
            best[sig] = item

    final = sorted(best.values(), key=lambda x: x["score"])

    if limit is not None:
        final = final[:limit]

    return final


# =====================================================
# PREPARE SYNASTRY BODIES
# =====================================================

def prepare_synastry_bodies(user_planets, partner_planets):
    """
    Input from /chart-data:

    [
      {
        "id": "moon",
        "planet": "Moon",
        "sign": "Virgo",
        "degree": "8° 46'",
        "house": 5,
        "longitude": 158.7667
      }
    ]

    Output:
    owner = user / partner
    color = blue / pink
    """

    bodies = []

    for p in user_planets:
        pid = p.get("id") or p.get("planet", "").lower().replace(" ", "_")

        if pid not in ALLOWED_PATTERN_BODIES:
            continue

        if p.get("longitude") is None:
            continue

        bodies.append({
            "id": pid,
            "name": p.get("planet") or clean_planet_name(pid),
            "owner": "user",
            "color": "blue",
            "sign": p.get("sign"),
            "degree": p.get("degree"),
            "house": p.get("house"),
            "longitude": float(p.get("longitude")),
        })

    for p in partner_planets:
        pid = p.get("id") or p.get("planet", "").lower().replace(" ", "_")

        if pid not in ALLOWED_PATTERN_BODIES:
            continue

        if p.get("longitude") is None:
            continue

        bodies.append({
            "id": pid,
            "name": p.get("planet") or clean_planet_name(pid),
            "owner": "partner",
            "color": "pink",
            "sign": p.get("sign"),
            "degree": p.get("degree"),
            "house": p.get("house"),
            "longitude": float(p.get("longitude")),
        })

    return bodies


# =====================================================
# T-SQUARE
# =====================================================

def detect_t_squares(bodies):
    """
    T-Square condition:

    base1 opposite base2 = 180°
    apex square base1 = 90°
    apex square base2 = 90°

    Extra filters:
    - Must include both user and partner planets
    - Tight orb only
    - Weak patterns removed by score
    - Duplicate geometry removed
    """

    raw_results = []

    for a, b, c in combinations(bodies, 3):
        trio = [a, b, c]

        if not has_both_people(trio):
            continue

        # Each planet can be apex once.
        candidates = [
            (a, b, c),
            (a, c, b),
            (b, c, a),
        ]

        for base1, base2, apex in candidates:
            opp_ok, opp_orb, _ = is_aspect(
                base1["longitude"],
                base2["longitude"],
                "opposition"
            )

            sq1_ok, sq1_orb, _ = is_aspect(
                apex["longitude"],
                base1["longitude"],
                "square"
            )

            sq2_ok, sq2_orb, _ = is_aspect(
                apex["longitude"],
                base2["longitude"],
                "square"
            )

            if not (opp_ok and sq1_ok and sq2_ok):
                continue

            # Stronger filter to avoid too many results.
            if opp_orb > 5.0:
                continue

            if sq1_orb > 4.5 or sq2_orb > 4.5:
                continue

            aspects = [
                {
                    "type": "opposition",
                    "planet1": base1,
                    "planet2": base2,
                    "orb": round(opp_orb, 2),
                    "orb_text": degree_to_text(opp_orb),
                },
                {
                    "type": "square",
                    "planet1": apex,
                    "planet2": base1,
                    "orb": round(sq1_orb, 2),
                    "orb_text": degree_to_text(sq1_orb),
                },
                {
                    "type": "square",
                    "planet1": apex,
                    "planet2": base2,
                    "orb": round(sq2_orb, 2),
                    "orb_text": degree_to_text(sq2_orb),
                },
            ]

            total_score = score_pattern(aspects)

            # Total orb should be tight.
            if total_score > 11:
                continue

            raw_results.append({
                "pattern": "T Square",
                "apex": apex,
                "opposition": [base1, base2],
                "planets": [base1, base2, apex],
                "aspects": aspects,
                "score": total_score,
            })

    return keep_best_unique(
        raw_results,
        lambda item: (
            unique_body_key(item["apex"]),
            tuple(sorted([
                unique_body_key(item["opposition"][0]),
                unique_body_key(item["opposition"][1]),
            ])),
        ),
        limit=DISPLAY_LIMITS["t_square"]
    )


# =====================================================
# YOD
# =====================================================

def detect_yods(bodies):
    """
    Yod condition:

    base1 sextile base2 = 60°
    apex quincunx base1 = 150°
    apex quincunx base2 = 150°
    """

    raw_results = []

    for a, b, c in combinations(bodies, 3):
        trio = [a, b, c]

        if not has_both_people(trio):
            continue

        candidates = [
            (a, b, c),
            (a, c, b),
            (b, c, a),
        ]

        for base1, base2, apex in candidates:
            sextile_ok, sextile_orb, _ = is_aspect(
                base1["longitude"],
                base2["longitude"],
                "sextile"
            )

            q1_ok, q1_orb, _ = is_aspect(
                apex["longitude"],
                base1["longitude"],
                "quincunx"
            )

            q2_ok, q2_orb, _ = is_aspect(
                apex["longitude"],
                base2["longitude"],
                "quincunx"
            )

            if not (sextile_ok and q1_ok and q2_ok):
                continue

            if sextile_orb > 3.5:
                continue

            if q1_orb > 3.0 or q2_orb > 3.0:
                continue

            aspects = [
                {
                    "type": "sextile",
                    "planet1": base1,
                    "planet2": base2,
                    "orb": round(sextile_orb, 2),
                    "orb_text": degree_to_text(sextile_orb),
                },
                {
                    "type": "quincunx",
                    "planet1": apex,
                    "planet2": base1,
                    "orb": round(q1_orb, 2),
                    "orb_text": degree_to_text(q1_orb),
                },
                {
                    "type": "quincunx",
                    "planet1": apex,
                    "planet2": base2,
                    "orb": round(q2_orb, 2),
                    "orb_text": degree_to_text(q2_orb),
                },
            ]

            total_score = score_pattern(aspects)

            if total_score > 8.5:
                continue

            raw_results.append({
                "pattern": "Yod",
                "apex": apex,
                "base": [base1, base2],
                "planets": [base1, base2, apex],
                "aspects": aspects,
                "score": total_score,
            })

    return keep_best_unique(
        raw_results,
        lambda item: (
            unique_body_key(item["apex"]),
            tuple(sorted([
                unique_body_key(item["base"][0]),
                unique_body_key(item["base"][1]),
            ])),
        ),
        limit=DISPLAY_LIMITS["yod"]
    )


# =====================================================
# GRAND TRINE
# =====================================================

def detect_grand_trines(bodies):
    """
    Grand Trine condition:

    a trine b = 120°
    b trine c = 120°
    c trine a = 120°
    """

    raw_results = []

    for a, b, c in combinations(bodies, 3):
        trio = [a, b, c]

        if not has_both_people(trio):
            continue

        ab_ok, ab_orb, _ = is_aspect(
            a["longitude"],
            b["longitude"],
            "trine"
        )

        bc_ok, bc_orb, _ = is_aspect(
            b["longitude"],
            c["longitude"],
            "trine"
        )

        ca_ok, ca_orb, _ = is_aspect(
            c["longitude"],
            a["longitude"],
            "trine"
        )

        if not (ab_ok and bc_ok and ca_ok):
            continue

        if ab_orb > 4.5 or bc_orb > 4.5 or ca_orb > 4.5:
            continue

        aspects = [
            {
                "type": "trine",
                "planet1": a,
                "planet2": b,
                "orb": round(ab_orb, 2),
                "orb_text": degree_to_text(ab_orb),
            },
            {
                "type": "trine",
                "planet1": b,
                "planet2": c,
                "orb": round(bc_orb, 2),
                "orb_text": degree_to_text(bc_orb),
            },
            {
                "type": "trine",
                "planet1": c,
                "planet2": a,
                "orb": round(ca_orb, 2),
                "orb_text": degree_to_text(ca_orb),
            },
        ]

        total_score = score_pattern(aspects)

        if total_score > 10:
            continue

        raw_results.append({
            "pattern": "Grand Trine",
            "planets": [a, b, c],
            "aspects": aspects,
            "score": total_score,
        })

    return keep_best_unique(
        raw_results,
        lambda item: tuple(
            sorted(unique_body_key(p) for p in item["planets"])
        ),
        limit=DISPLAY_LIMITS["grand_trine"]
    )


# =====================================================
# MAIN ENTRY
# =====================================================

def detect_synastry_chart_patterns(user_planets, partner_planets):
    bodies = prepare_synastry_bodies(user_planets, partner_planets)

    return {
        "t_square": detect_t_squares(bodies),
        "yod": detect_yods(bodies),
        "grand_trine": detect_grand_trines(bodies),
    }
