#!/usr/bin/env python3
"""
Score HyperFrames catalog items against a user intent profile.

7-factor weighted scoring model from scoring.json — weights read dynamically,
never hardcoded. Hard constraints (code-morph exclusion, block-vs-component
preference) applied from both scoring.json and catalog source metadata.

Usage:
    python3 scripts/score-catalog.py --catalog PATH --scoring PATH --purpose PURPOSE \\
        --format FORMAT --style TAGS --motion TAGS --roles ROLES \\
        --raw-request TEXT [--top N] [--role-filter ROLE] [--output json|text]
"""
import argparse
import json
import sys


# ── Data Loading ──────────────────────────────────────────────────────────

def load_weights(scoring_path):
    """Read scoringFormula.scoringWeightsJson from scoring.json (no hardcoding)."""
    with open(scoring_path) as f:
        data = json.load(f)
    return data["scoringFormula"]["scoringWeightsJson"]


def load_catalog(catalog_path):
    """Read items list and source.failedItems from catalog map JSON.

    Returns (items, failed_items) tuple.
    """
    with open(catalog_path) as f:
        data = json.load(f)
    items = data.get("items", [])
    failed_items = data.get("source", {}).get("failedItems", [])
    return items, failed_items


# ── Scoring Engine ────────────────────────────────────────────────────────

def _compute_factor_scores(item, weights, profile):
    """Score a single item against the profile, returning list of (name, value).

    Each factor score is capped at its weight (max contribution).
    """
    raw_request = profile.get("rawRequest", "").lower()
    purpose = profile.get("purpose", "").lower()
    req_format = profile.get("format", "")
    req_styles = [s.strip().lower() for s in profile.get("styleTags", []) if s.strip()]
    req_motions = [m.strip().lower() for m in profile.get("motionTags", []) if m.strip()]
    req_roles = [r.strip().lower() for r in profile.get("roles", []) if r.strip()]

    item_triggers = item.get("naturalLanguageTriggers", [])
    item_domains = [d.lower() for d in item.get("intentDomains", [])]
    item_aspect = item.get("format", {}).get("aspect", "")
    item_roles = [r.lower() for r in item.get("assetRoles", [])]
    item_styles = [s.lower() for s in item.get("styleTags", [])]
    item_motions = [m.lower() for m in item.get("motionTags", [])]

    scores = []

    # 1. keywordMatch (0.30): +0.05 per trigger found as case-insensitive
    #    substring in rawRequest, capped at 6 matches.
    kw_matches = sum(1 for t in item_triggers if t.lower() in raw_request)
    kw_score = min(kw_matches * 0.05, weights.get("keywordMatch", 0.30))
    scores.append(("keywordMatch", kw_score))

    # 2. intentDomainMatch (0.25): +0.25 if purpose appears in item.intentDomains.
    id_score = weights.get("intentDomainMatch", 0.25) if purpose in item_domains else 0.0
    scores.append(("intentDomainMatch", id_score))

    # 3. formatMatch (0.15): +0.15 for exact aspect match; +0.075 for flexible.
    if item_aspect == req_format:
        fm_score = weights.get("formatMatch", 0.15)
    elif item_aspect == "flexible":
        fm_score = weights.get("formatMatch", 0.15) * 0.5
    else:
        fm_score = 0.0
    scores.append(("formatMatch", fm_score))

    # 4. assetRoleMatch (0.10): +0.05 per matching needed role, capped at 2.
    role_matches = sum(1 for r in req_roles if r in item_roles)
    ar_score = min(role_matches * 0.05, weights.get("assetRoleMatch", 0.10))
    scores.append(("assetRoleMatch", ar_score))

    # 5. styleMatch (0.10): +0.04 per matching style tag, capped at 3
    #    (effective max = weight = 0.10).
    style_matches = sum(1 for s in req_styles if s in item_styles)
    sm_score = min(style_matches * 0.04, weights.get("styleMatch", 0.10))
    scores.append(("styleMatch", sm_score))

    # 6. motionMatch (0.05): +0.025 per matching motion tag, capped at 2.
    motion_matches = sum(1 for m in req_motions if m in item_motions)
    mm_score = min(motion_matches * 0.025, weights.get("motionMatch", 0.05))
    scores.append(("motionMatch", mm_score))

    # 7. constraintsMatch (0.05): +0.025 for format constraint match,
    #    +0.025 for caption role constraint match.
    cm_score = 0.0
    if item_aspect == req_format:
        cm_score += 0.025
    if "caption" in req_roles and "caption" in item_roles:
        cm_score += 0.025
    cm_score = min(cm_score, weights.get("constraintsMatch", 0.05))
    scores.append(("constraintsMatch", cm_score))

    return scores


def _build_reasons(score_map, item, profile):
    """Generate human-readable reason strings for non-zero factor contributions."""
    reasons = []
    raw_request = profile.get("rawRequest", "")
    req_format = profile.get("format", "")
    req_roles = [r.strip().lower() for r in profile.get("roles", []) if r.strip()]
    req_styles = [s.strip().lower() for s in profile.get("styleTags", []) if s.strip()]
    req_motions = [m.strip().lower() for m in profile.get("motionTags", []) if m.strip()]

    # keywordMatch
    if score_map.get("keywordMatch", 0) > 0:
        for trigger in item.get("naturalLanguageTriggers", []):
            if trigger.lower() in raw_request.lower():
                reasons.append("keyword: '{}' found in request".format(trigger))

    # intentDomainMatch
    if score_map.get("intentDomainMatch", 0) > 0:
        reasons.append("intent: purpose '{}' matches domain".format(
            profile.get("purpose", "")))

    # formatMatch
    if score_map.get("formatMatch", 0) > 0:
        item_aspect = item.get("format", {}).get("aspect", "")
        if item_aspect == req_format:
            reasons.append("format: exact match {}".format(item_aspect))
        else:
            reasons.append("format: flexible component ({})".format(item_aspect))

    # assetRoleMatch
    if score_map.get("assetRoleMatch", 0) > 0:
        item_roles_lower = [r.lower() for r in item.get("assetRoles", [])]
        for role in req_roles:
            if role in item_roles_lower:
                reasons.append("role: '{}' matches".format(role))

    # styleMatch
    if score_map.get("styleMatch", 0) > 0:
        item_styles_lower = [s.lower() for s in item.get("styleTags", [])]
        for style in req_styles:
            if style in item_styles_lower:
                reasons.append("style: '{}' matches".format(style))

    # motionMatch
    if score_map.get("motionMatch", 0) > 0:
        item_motions_lower = [m.lower() for m in item.get("motionTags", [])]
        for motion in req_motions:
            if motion in item_motions_lower:
                reasons.append("motion: '{}' matches".format(motion))

    # constraintsMatch
    if score_map.get("constraintsMatch", 0) > 0:
        item_aspect = item.get("format", {}).get("aspect", "")
        if item_aspect == req_format:
            reasons.append("constraint: format match bonus")
        item_roles_lower = [r.lower() for r in item.get("assetRoles", [])]
        if "caption" in req_roles and "caption" in item_roles_lower:
            reasons.append("constraint: caption role match bonus")

    if not reasons:
        reasons.append("no factors matched")

    return reasons


def score_items(items, weights, profile):
    """Score every catalog item against the profile (7-factor model).

    Returns list of dicts with keys: id, title, type, score, reasons,
    assetRoles, intentDomains, styleTags, format.
    """
    results = []
    for item in items:
        factor_scores = _compute_factor_scores(item, weights, profile)
        score_map = dict(factor_scores)
        total_score = sum(score_map.values())
        reasons = _build_reasons(score_map, item, profile)

        results.append({
            "id": item["id"],
            "title": item.get("title", item["id"]),
            "type": item.get("type", ""),
            "score": round(total_score, 4),
            "reasons": reasons,
            "assetRoles": item.get("assetRoles", []),
            "intentDomains": item.get("intentDomains", []),
            "styleTags": item.get("styleTags", []),
            "format": item.get("format", {}),
        })
    return results


# ── Hard Constraints ──────────────────────────────────────────────────────

def apply_hard_constraints(results, failed_items):
    """Exclude items listed in source.failedItems (e.g. code-morph).

    Read from catalog metadata dynamically — no hardcoded item IDs.
    """
    return [r for r in results if r["id"] not in set(failed_items)]


# ── Filtering & Sorting ───────────────────────────────────────────────────

def filter_and_sort(results, top=None, role_filter=None):
    """Sort by score descending (ties broken by id for determinism),
    optionally filter by asset role, then truncate to top N.
    """
    # Filter first, then sort, then truncate
    if role_filter:
        results = [r for r in results if role_filter in r.get("assetRoles", [])]

    # Deterministic sort: primary = descending score, secondary = ascending id
    results.sort(key=lambda x: (-x["score"], x["id"]))

    if top is not None and top > 0:
        results = results[:top]

    return results


# ── CLI ───────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="Score HyperFrames catalog items against intent profile."
    )
    parser.add_argument("--catalog", required=True,
                        help="Path to catalog map JSON")
    parser.add_argument("--scoring", required=True,
                        help="Path to scoring.json")
    parser.add_argument("--purpose", required=True,
                        help="Intent purpose (e.g. product_launch)")
    parser.add_argument("--format", required=True,
                        help="Target format aspect (e.g. landscape_16_9)")
    parser.add_argument("--style", required=True,
                        help="Comma-separated style tags")
    parser.add_argument("--motion", required=True,
                        help="Comma-separated motion tags")
    parser.add_argument("--roles", required=True,
                        help="Comma-separated asset roles")
    parser.add_argument("--raw-request", required=True,
                        help="Raw user request text for keyword matching")
    parser.add_argument("--top", type=int, default=None,
                        help="Return only top N items")
    parser.add_argument("--role-filter", default=None,
                        help="Filter items to those with this asset role")
    parser.add_argument("--output", choices=["json", "text"], default="json",
                        help="Output format (default: json)")
    parser.add_argument("--smoke-test", action="store_true",
                        help="Quick self-check with default profile")

    args = parser.parse_args()

    # ── Smoke test ───────────────────────────────────────────────────────
    if args.smoke_test:
        from pathlib import Path
        home = Path.home()
        default_catalog = str(
            home / ".claude/skills/hyper-animator/references/hyperframes-catalog-map.json")
        default_scoring = str(
            home / ".claude/skills/hyper-animator/references/scoring.json")
        weights = load_weights(default_scoring)
        items, failed_items = load_catalog(default_catalog)
        profile = {
            "purpose": "product_launch", "format": "landscape_16_9",
            "styleTags": ["cinematic"], "motionTags": ["reveal"],
            "roles": ["main_scene"], "rawRequest": "smoke test",
        }
        results = score_items(items, weights, profile)
        results = apply_hard_constraints(results, failed_items)
        results = filter_and_sort(results, top=5)
        assert len(results) == 5, f"Expected 5 results, got {len(results)}"
        assert all("id" in r and "score" in r and "reasons" in r for r in results)
        assert "code-morph" not in [r["id"] for r in results]
        print("SMOKE TEST PASSED")
        return

    # ── Load ──────────────────────────────────────────────────────────
    weights = load_weights(args.scoring)
    items, failed_items = load_catalog(args.catalog)

    # ── Build profile ─────────────────────────────────────────────────
    profile = {
        "purpose": args.purpose,
        "format": args.format,
        "styleTags": [s.strip() for s in args.style.split(",") if s.strip()],
        "motionTags": [m.strip() for m in args.motion.split(",") if m.strip()],
        "roles": [r.strip() for r in args.roles.split(",") if r.strip()],
        "rawRequest": args.raw_request,
    }

    # ── Score ─────────────────────────────────────────────────────────
    results = score_items(items, weights, profile)

    # ── Hard constraints ──────────────────────────────────────────────
    results = apply_hard_constraints(results, failed_items)

    # ── Filter & sort ─────────────────────────────────────────────────
    results = filter_and_sort(results, top=args.top, role_filter=args.role_filter)

    # ── Output ────────────────────────────────────────────────────────
    output = {"items": results}
    if args.output == "json":
        print(json.dumps(output, indent=2, ensure_ascii=False))
    else:
        # Text table format
        header = "{:<5} {:<30} {:<25} {:<12} {:<8}".format(
            "Rank", "ID", "Title", "Type", "Score")
        sep = "-" * 85
        lines = [header, sep]
        for i, item in enumerate(results, 1):
            lines.append("{:<5} {:<30} {:<25} {:<12} {:<8.4f}".format(
                i, item["id"], item["title"], item["type"], item["score"]))
            for reason in item["reasons"][:3]:
                lines.append("      + {}".format(reason))
            extra = len(item["reasons"]) - 3
            if extra > 0:
                lines.append("      ... and {} more".format(extra))
        print("\n".join(lines))


if __name__ == "__main__":
    main()
