"""Visualization utilities for record linkage analysis.

Provides Splink-style interactive charts via Altair (default) with
matplotlib fallback. All Altair functions return alt.Chart objects
that render natively in Jupyter notebooks.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, List, Optional

import numpy as np
import pandas as pd

if TYPE_CHECKING:
    from blazematch.linker import Linker


def _has_altair() -> bool:
    try:
        import altair  # noqa: F401
        return True
    except ImportError:
        return False


# ---------------------------------------------------------------------------
# 1. Score Distribution
# ---------------------------------------------------------------------------

def plot_score_distribution(
    results_df: pd.DataFrame,
    threshold: Optional[float] = None,
    bins: int = 50,
    backend: str = "altair",
    ax=None,
):
    """Interactive histogram of match scores.

    Args:
        results_df: DataFrame with "score" column.
        threshold: Optional vertical threshold line.
        bins: Number of histogram bins.
        backend: "altair" (default, interactive) or "matplotlib" (static).
        ax: Matplotlib axes (only used with matplotlib backend).

    Returns:
        alt.Chart or matplotlib axes.
    """
    if backend == "altair" and _has_altair():
        import altair as alt

        base = alt.Chart(results_df).mark_bar(
            color="#4682B4", opacity=0.8
        ).encode(
            alt.X("score:Q", bin=alt.Bin(maxbins=bins), title="Match Score"),
            alt.Y("count()", title="Count"),
            tooltip=[
                alt.Tooltip("score:Q", bin=alt.Bin(maxbins=bins), title="Score Range"),
                alt.Tooltip("count()", title="Count"),
            ],
        ).properties(
            width=600, height=400, title="Score Distribution"
        ).interactive()

        if threshold is not None:
            rule = alt.Chart(pd.DataFrame({"threshold": [threshold]})).mark_rule(
                color="red", strokeWidth=2, strokeDash=[5, 3]
            ).encode(x="threshold:Q")
            text = alt.Chart(pd.DataFrame({"threshold": [threshold]})).mark_text(
                align="left", dx=5, dy=-10, color="red", fontSize=12
            ).encode(
                x="threshold:Q",
                text=alt.value(f"Threshold = {threshold}"),
            )
            return base + rule + text
        return base

    # Matplotlib fallback
    import matplotlib.pyplot as plt

    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 6))

    scores = results_df["score"].values
    ax.hist(scores, bins=bins, edgecolor="black", alpha=0.7, color="steelblue")
    ax.set_xlabel("Match Score")
    ax.set_ylabel("Count")
    ax.set_title("Score Distribution")

    if threshold is not None:
        ax.axvline(x=threshold, color="red", linestyle="--", linewidth=2,
                    label=f"Threshold = {threshold}")
        ax.legend()

    return ax


# ---------------------------------------------------------------------------
# 2. Waterfall Chart (Splink-style)
# ---------------------------------------------------------------------------

def plot_waterfall(
    linker: "Linker",
    pair_idx: int,
    backend: str = "altair",
    ax=None,
):
    """Splink-style waterfall chart showing per-feature contribution for a pair.

    Green bars = positive evidence (feature >= 0.5), red = negative.
    Shows cumulative build-up of match evidence.

    Args:
        linker: Fitted Linker with features computed.
        pair_idx: Index into linker.pairs / linker.features.
        backend: "altair" or "matplotlib".
        ax: Matplotlib axes (matplotlib backend only).

    Returns:
        alt.Chart or matplotlib axes.
    """
    if linker.features is None:
        raise RuntimeError("Must call compute_features() before plot_waterfall()")

    feature_values = linker.features[pair_idx]
    names = linker.feature_names

    if backend == "altair" and _has_altair():
        import altair as alt

        # Build waterfall data: cumulative bars from left to right
        rows = []
        cumulative = 0.0
        for i, (name, val) in enumerate(zip(names, feature_values)):
            # Normalize to match weight scale: center at 0.5
            weight = val - 0.5
            rows.append({
                "feature": name,
                "value": float(val),
                "weight": float(weight),
                "start": cumulative,
                "end": cumulative + weight,
                "direction": "Positive" if weight >= 0 else "Negative",
                "order": i,
            })
            cumulative += weight

        # Add final score bar
        rows.append({
            "feature": "FINAL SCORE",
            "value": float(cumulative + 0.5 * len(names)),
            "weight": float(cumulative),
            "start": 0,
            "end": cumulative,
            "direction": "Final",
            "order": len(names),
        })

        df = pd.DataFrame(rows[:-1])  # Feature bars
        final_df = pd.DataFrame([rows[-1]])  # Final score summary bar

        bars = alt.Chart(df).mark_bar(opacity=0.85).encode(
            y=alt.Y("feature:N", sort=alt.EncodingSortField(field="order"),
                     title=None),
            x=alt.X("start:Q", title="Cumulative Match Weight"),
            x2="end:Q",
            color=alt.Color(
                "direction:N",
                scale=alt.Scale(
                    domain=["Positive", "Negative", "Final"],
                    range=["#2ecc71", "#e74c3c", "#4682B4"],
                ),
                legend=alt.Legend(title="Evidence"),
            ),
            tooltip=[
                alt.Tooltip("feature:N", title="Feature"),
                alt.Tooltip("value:Q", format=".3f", title="Similarity"),
                alt.Tooltip("weight:Q", format=".3f", title="Weight"),
            ],
        )

        # Final score bar
        final_bar = alt.Chart(final_df).mark_bar(opacity=0.85).encode(
            y=alt.Y("feature:N", sort=alt.EncodingSortField(field="order"),
                     title=None),
            x=alt.X("start:Q"),
            x2="end:Q",
            color=alt.Color("direction:N"),
            tooltip=[
                alt.Tooltip("feature:N", title="Feature"),
                alt.Tooltip("weight:Q", format=".3f", title="Total Weight"),
            ],
        )

        # Value labels
        labels = alt.Chart(df).mark_text(
            align="left", dx=4, fontSize=11
        ).encode(
            y=alt.Y("feature:N", sort=alt.EncodingSortField(field="order")),
            x="end:Q",
            text=alt.Text("value:Q", format=".3f"),
        )

        # Zero line
        zero_rule = alt.Chart(pd.DataFrame({"x": [0]})).mark_rule(
            color="black", strokeWidth=1
        ).encode(x="x:Q")

        chart = (bars + final_bar + labels + zero_rule).properties(
            width=500,
            height=max(200, (len(names) + 1) * 35),
            title=f"Feature Waterfall (Pair #{pair_idx})",
        )
        return chart

    # Matplotlib fallback
    import matplotlib.pyplot as plt

    if ax is None:
        fig, ax = plt.subplots(figsize=(10, max(4, len(names) * 0.6)))

    colors = ["#2ecc71" if v >= 0.5 else "#e74c3c" for v in feature_values]
    y_pos = np.arange(len(names))
    ax.barh(y_pos, feature_values, color=colors, edgecolor="black", alpha=0.8)
    ax.set_yticks(y_pos)
    ax.set_yticklabels(names)
    ax.set_xlabel("Feature Value")
    ax.set_title(f"Feature Waterfall (Pair #{pair_idx})")
    ax.set_xlim(0, 1.05)

    for i, v in enumerate(feature_values):
        ax.text(v + 0.01, i, f"{v:.3f}", va="center", fontsize=9)

    return ax


# ---------------------------------------------------------------------------
# 3. Comparison Heatmap
# ---------------------------------------------------------------------------

def plot_comparison_heatmap(
    linker: "Linker",
    results_df: pd.DataFrame,
    threshold: float = 0.5,
    backend: str = "altair",
    ax=None,
):
    """Heatmap of mean feature values for matches vs non-matches.

    Args:
        linker: Linker with features computed.
        results_df: DataFrame with "score" column.
        threshold: Score threshold to separate matches/non-matches.
        backend: "altair" or "matplotlib".
        ax: Matplotlib axes (matplotlib backend only).

    Returns:
        alt.Chart or matplotlib axes.
    """
    if linker.features is None:
        raise RuntimeError("Must call compute_features() before plot_comparison_heatmap()")

    scores = results_df["score"].values
    match_mask = scores >= threshold
    non_match_mask = ~match_mask
    names = linker.feature_names
    n_features = len(names)

    match_means = (linker.features[match_mask].mean(axis=0) if match_mask.any()
                   else np.zeros(n_features))
    non_match_means = (linker.features[non_match_mask].mean(axis=0) if non_match_mask.any()
                       else np.zeros(n_features))

    if backend == "altair" and _has_altair():
        import altair as alt

        rows = []
        for i, name in enumerate(names):
            rows.append({"Feature": name, "Category": "Match Mean",
                         "Value": float(match_means[i]), "order": i})
            rows.append({"Feature": name, "Category": "Non-Match Mean",
                         "Value": float(non_match_means[i]), "order": i})

        df = pd.DataFrame(rows)

        chart = alt.Chart(df).mark_rect().encode(
            x=alt.X("Category:N", title=None),
            y=alt.Y("Feature:N", sort=alt.EncodingSortField(field="order"), title=None),
            color=alt.Color("Value:Q",
                            scale=alt.Scale(scheme="redyellowgreen", domain=[0, 1]),
                            title="Mean Value"),
            tooltip=[
                alt.Tooltip("Feature:N"),
                alt.Tooltip("Category:N"),
                alt.Tooltip("Value:Q", format=".3f"),
            ],
        ).properties(
            width=250, height=max(200, n_features * 30),
            title="Feature Comparison Heatmap",
        )

        # Add text annotations
        text = alt.Chart(df).mark_text(fontSize=12).encode(
            x="Category:N",
            y=alt.Y("Feature:N", sort=alt.EncodingSortField(field="order")),
            text=alt.Text("Value:Q", format=".3f"),
            color=alt.condition(
                alt.datum.Value > 0.5,
                alt.value("white"),
                alt.value("black"),
            ),
        )

        return chart + text

    # Matplotlib fallback
    import matplotlib.pyplot as plt

    data = np.column_stack([match_means, non_match_means])

    if ax is None:
        fig, ax = plt.subplots(figsize=(8, max(4, n_features * 0.5)))

    im = ax.imshow(data, aspect="auto", cmap="RdYlGn", vmin=0, vmax=1)
    ax.set_xticks([0, 1])
    ax.set_xticklabels(["Match Mean", "Non-Match Mean"])
    ax.set_yticks(range(n_features))
    ax.set_yticklabels(names)
    ax.set_title("Feature Comparison Heatmap")

    for i in range(n_features):
        for j in range(2):
            ax.text(j, i, f"{data[i, j]:.3f}", ha="center", va="center", fontsize=9)

    plt.colorbar(im, ax=ax)
    return ax


# ---------------------------------------------------------------------------
# 4. Precision-Recall Curve
# ---------------------------------------------------------------------------

def plot_precision_recall(
    results_df: pd.DataFrame,
    labels_df: pd.DataFrame,
    backend: str = "altair",
    ax=None,
):
    """Precision-recall curve with interactive threshold tooltip.

    Args:
        results_df: DataFrame with [left_idx, right_idx, score].
        labels_df: DataFrame with [left_idx, right_idx, label].
        backend: "altair" or "matplotlib".
        ax: Matplotlib axes (matplotlib backend only).

    Returns:
        alt.Chart or matplotlib axes.
    """
    from sklearn.metrics import average_precision_score, precision_recall_curve

    merged = results_df.merge(labels_df, on=["left_idx", "right_idx"], how="inner")
    if len(merged) == 0:
        raise ValueError("No matching pairs found between results and labels.")

    y_true = merged["label"].values
    y_scores = merged["score"].values

    precision, recall, thresholds = precision_recall_curve(y_true, y_scores)
    ap = average_precision_score(y_true, y_scores)

    if backend == "altair" and _has_altair():
        import altair as alt

        # precision_recall_curve returns one more point than thresholds
        df = pd.DataFrame({
            "Recall": recall[:-1],
            "Precision": precision[:-1],
            "Threshold": thresholds,
        })

        line = alt.Chart(df).mark_area(
            line={"color": "#4682B4"},
            color=alt.Gradient(
                gradient="linear",
                stops=[alt.GradientStop(color="#4682B4", offset=0),
                       alt.GradientStop(color="rgba(70,130,180,0.1)", offset=1)],
                x1=1, x2=1, y1=1, y2=0,
            ),
        ).encode(
            x=alt.X("Recall:Q", scale=alt.Scale(domain=[0, 1]), title="Recall"),
            y=alt.Y("Precision:Q", scale=alt.Scale(domain=[0, 1.05]), title="Precision"),
            tooltip=[
                alt.Tooltip("Recall:Q", format=".3f"),
                alt.Tooltip("Precision:Q", format=".3f"),
                alt.Tooltip("Threshold:Q", format=".3f"),
            ],
        ).properties(
            width=500, height=400,
            title=f"Precision-Recall Curve (AP = {ap:.3f})",
        )

        # Nearest-point selector
        nearest = alt.selection_point(nearest=True, on="pointerover",
                                       fields=["Recall"], empty=False)
        points = alt.Chart(df).mark_point(
            color="#4682B4", size=60
        ).encode(
            x="Recall:Q", y="Precision:Q",
            opacity=alt.condition(nearest, alt.value(1), alt.value(0)),
        ).add_params(nearest)

        return line + points

    # Matplotlib fallback
    import matplotlib.pyplot as plt

    if ax is None:
        fig, ax = plt.subplots(figsize=(8, 6))

    ax.plot(recall, precision, color="steelblue", linewidth=2)
    ax.fill_between(recall, precision, alpha=0.2, color="steelblue")
    ax.set_xlabel("Recall")
    ax.set_ylabel("Precision")
    ax.set_title(f"Precision-Recall Curve (AP = {ap:.3f})")
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1.05)

    return ax


# ---------------------------------------------------------------------------
# 5. ROC Curve
# ---------------------------------------------------------------------------

def plot_roc(
    results_df: pd.DataFrame,
    labels_df: pd.DataFrame,
    backend: str = "altair",
    ax=None,
):
    """ROC curve with AUC score.

    Args:
        results_df: DataFrame with [left_idx, right_idx, score].
        labels_df: DataFrame with [left_idx, right_idx, label].
        backend: "altair" or "matplotlib".
        ax: Matplotlib axes (matplotlib backend only).

    Returns:
        alt.Chart or matplotlib axes.
    """
    from sklearn.metrics import roc_auc_score, roc_curve

    merged = results_df.merge(labels_df, on=["left_idx", "right_idx"], how="inner")
    if len(merged) == 0:
        raise ValueError("No matching pairs found between results and labels.")

    y_true = merged["label"].values
    y_scores = merged["score"].values

    fpr, tpr, thresholds = roc_curve(y_true, y_scores)
    auc = roc_auc_score(y_true, y_scores)

    if backend == "altair" and _has_altair():
        import altair as alt

        df = pd.DataFrame({"FPR": fpr, "TPR": tpr})
        diag = pd.DataFrame({"FPR": [0, 1], "TPR": [0, 1]})

        roc_line = alt.Chart(df).mark_line(
            color="#4682B4", strokeWidth=2
        ).encode(
            x=alt.X("FPR:Q", scale=alt.Scale(domain=[0, 1]),
                     title="False Positive Rate"),
            y=alt.Y("TPR:Q", scale=alt.Scale(domain=[0, 1.05]),
                     title="True Positive Rate"),
            tooltip=[
                alt.Tooltip("FPR:Q", format=".3f"),
                alt.Tooltip("TPR:Q", format=".3f"),
            ],
        )

        diag_line = alt.Chart(diag).mark_line(
            color="gray", strokeDash=[5, 3], strokeWidth=1
        ).encode(x="FPR:Q", y="TPR:Q")

        auc_text = alt.Chart(pd.DataFrame({"x": [0.6], "y": [0.1]})).mark_text(
            fontSize=14, color="#4682B4", fontWeight="bold"
        ).encode(
            x="x:Q", y="y:Q",
            text=alt.value(f"AUC = {auc:.3f}"),
        )

        return (roc_line + diag_line + auc_text).properties(
            width=500, height=400, title="ROC Curve",
        ).interactive()

    # Matplotlib fallback
    import matplotlib.pyplot as plt

    if ax is None:
        fig, ax = plt.subplots(figsize=(8, 6))

    ax.plot(fpr, tpr, color="steelblue", linewidth=2, label=f"AUC = {auc:.3f}")
    ax.plot([0, 1], [0, 1], color="gray", linestyle="--", linewidth=1)
    ax.set_xlabel("False Positive Rate")
    ax.set_ylabel("True Positive Rate")
    ax.set_title("ROC Curve")
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1.05)
    ax.legend(loc="lower right")

    return ax


# ---------------------------------------------------------------------------
# 6. Threshold Analysis
# ---------------------------------------------------------------------------

def plot_threshold_analysis(
    results_df: pd.DataFrame,
    n_thresholds: int = 50,
    backend: str = "altair",
    ax=None,
):
    """Shows count of matches at different threshold values.

    Args:
        results_df: DataFrame with "score" column.
        n_thresholds: Number of threshold values to evaluate.
        backend: "altair" or "matplotlib".
        ax: Matplotlib axes (matplotlib backend only).

    Returns:
        alt.Chart or matplotlib axes.
    """
    scores = results_df["score"].values
    thresholds = np.linspace(0, 1, n_thresholds)
    counts = [int(np.sum(scores >= t)) for t in thresholds]

    if backend == "altair" and _has_altair():
        import altair as alt

        df = pd.DataFrame({"Threshold": thresholds, "Matches": counts})

        nearest = alt.selection_point(nearest=True, on="pointerover",
                                       fields=["Threshold"], empty=False)

        line = alt.Chart(df).mark_area(
            line={"color": "#4682B4"},
            color=alt.Gradient(
                gradient="linear",
                stops=[alt.GradientStop(color="#4682B4", offset=0),
                       alt.GradientStop(color="rgba(70,130,180,0.1)", offset=1)],
                x1=1, x2=1, y1=1, y2=0,
            ),
        ).encode(
            x=alt.X("Threshold:Q", title="Threshold"),
            y=alt.Y("Matches:Q", title="Number of Matches"),
            tooltip=[
                alt.Tooltip("Threshold:Q", format=".3f"),
                alt.Tooltip("Matches:Q"),
            ],
        )

        # Crosshair point
        points = alt.Chart(df).mark_point(
            color="#4682B4", size=60
        ).encode(
            x="Threshold:Q", y="Matches:Q",
            opacity=alt.condition(nearest, alt.value(1), alt.value(0)),
        ).add_params(nearest)

        # Vertical rule at hover
        rules = alt.Chart(df).mark_rule(
            color="gray", strokeDash=[3, 3]
        ).encode(
            x="Threshold:Q",
            opacity=alt.condition(nearest, alt.value(0.5), alt.value(0)),
        ).add_params(nearest)

        return (line + points + rules).properties(
            width=600, height=400, title="Threshold Analysis",
        )

    # Matplotlib fallback
    import matplotlib.pyplot as plt

    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 6))

    ax.plot(thresholds, counts, color="steelblue", linewidth=2)
    ax.fill_between(thresholds, counts, alpha=0.2, color="steelblue")
    ax.set_xlabel("Threshold")
    ax.set_ylabel("Number of Matches")
    ax.set_title("Threshold Analysis")

    return ax


# ---------------------------------------------------------------------------
# 7. Match Weights (NEW — Splink-style)
# ---------------------------------------------------------------------------

def plot_match_weights(
    linker: "Linker",
    backend: str = "altair",
    ax=None,
):
    """Per-feature match weight chart.

    For Fellegi-Sunter: shows max log2(m/u) per comparison.
    For logistic regression: shows model weight coefficients.
    For other models: shows mean feature importance.

    Args:
        linker: Fitted Linker (after fit() or estimate()).
        backend: "altair" or "matplotlib".
        ax: Matplotlib axes (matplotlib backend only).

    Returns:
        alt.Chart or matplotlib axes.
    """
    names = linker.feature_names

    # Extract weights based on model type
    from blazematch.fellegi_sunter import FellegiSunterModel
    from blazematch.model import MatchModel

    model = linker._model or linker._fs_model
    if model is None:
        raise RuntimeError("Must call fit() or estimate() before plot_match_weights()")

    if isinstance(model, FellegiSunterModel) and model.is_fitted:
        weights = []
        for j in range(len(model.match_weights_)):
            max_weight = float(np.max(model.match_weights_[j]))
            weights.append(max_weight)
    elif isinstance(model, MatchModel) and model.is_fitted:
        weights = model.weights_.tolist()
    elif hasattr(model, "_model") and hasattr(model._model, "feature_importances_"):
        weights = model._model.feature_importances_.tolist()
    else:
        raise RuntimeError("Cannot extract weights from the current model type.")

    if backend == "altair" and _has_altair():
        import altair as alt

        df = pd.DataFrame({
            "Feature": names,
            "Weight": weights,
            "Abs Weight": [abs(w) for w in weights],
            "Direction": ["Positive" if w >= 0 else "Negative" for w in weights],
        }).sort_values("Abs Weight", ascending=True)

        chart = alt.Chart(df).mark_bar(opacity=0.85).encode(
            y=alt.Y("Feature:N", sort=alt.EncodingSortField(field="Abs Weight"),
                     title=None),
            x=alt.X("Weight:Q", title="Match Weight"),
            color=alt.Color(
                "Direction:N",
                scale=alt.Scale(
                    domain=["Positive", "Negative"],
                    range=["#2ecc71", "#e74c3c"],
                ),
                legend=alt.Legend(title="Direction"),
            ),
            tooltip=[
                alt.Tooltip("Feature:N"),
                alt.Tooltip("Weight:Q", format=".3f"),
            ],
        ).properties(
            width=500, height=max(200, len(names) * 30),
            title="Match Weights by Feature",
        )

        # Zero reference line
        zero = alt.Chart(pd.DataFrame({"x": [0]})).mark_rule(
            color="black", strokeWidth=1
        ).encode(x="x:Q")

        return chart + zero

    # Matplotlib fallback
    import matplotlib.pyplot as plt

    if ax is None:
        fig, ax = plt.subplots(figsize=(10, max(4, len(names) * 0.6)))

    sorted_idx = np.argsort(np.abs(weights))
    sorted_names = [names[i] for i in sorted_idx]
    sorted_weights = [weights[i] for i in sorted_idx]
    colors = ["#2ecc71" if w >= 0 else "#e74c3c" for w in sorted_weights]

    ax.barh(range(len(sorted_names)), sorted_weights, color=colors, edgecolor="black", alpha=0.8)
    ax.set_yticks(range(len(sorted_names)))
    ax.set_yticklabels(sorted_names)
    ax.set_xlabel("Match Weight")
    ax.set_title("Match Weights by Feature")
    ax.axvline(x=0, color="black", linewidth=1)

    return ax


# ---------------------------------------------------------------------------
# 8. Comparison Viewer (NEW — Splink-style)
# ---------------------------------------------------------------------------

def plot_comparison_viewer(
    linker: "Linker",
    results_df: pd.DataFrame,
    sample_size: int = 10,
    backend: str = "altair",
):
    """Interactive comparison viewer showing sampled pairs with field values.

    Displays pairs sorted by score, showing left/right field values and
    per-feature similarity scores. Click a row to highlight.

    Args:
        linker: Fitted Linker with features computed.
        results_df: DataFrame with [left_idx, right_idx, score].
        sample_size: Number of pairs to display (sampled across score range).
        backend: "altair" or "matplotlib".

    Returns:
        alt.Chart (altair) or pandas DataFrame (matplotlib fallback).
    """
    if linker.features is None:
        raise RuntimeError("Must call compute_features() before plot_comparison_viewer()")

    names = linker.feature_names

    # Sample pairs across score distribution
    sorted_results = results_df.sort_values("score", ascending=False)
    n = min(sample_size, len(sorted_results))
    step = max(1, len(sorted_results) // n)
    sampled = sorted_results.iloc[::step].head(n).reset_index(drop=True)

    # Build comparison data
    rows = []
    for _, pair in sampled.iterrows():
        left_idx = int(pair["left_idx"])
        right_idx = int(pair["right_idx"])
        score = float(pair["score"])

        # Find this pair's position in linker.pairs to get features
        mask = ((linker.pairs["left_idx"] == left_idx) &
                (linker.pairs["right_idx"] == right_idx))
        if mask.any():
            pair_pos = mask.idxmax()
            feature_vals = linker.features[pair_pos]
        else:
            feature_vals = [np.nan] * len(names)

        for i, fname in enumerate(names):
            # Get actual field values from dataframes
            comp = linker.config.comparisons[i]
            left_val = str(linker.df_left.iloc[left_idx].get(comp.left_field, ""))
            right_val = str(linker.df_right.iloc[right_idx].get(comp.right_field, ""))

            rows.append({
                "Pair": f"L{left_idx}-R{right_idx}",
                "Score": score,
                "Feature": fname,
                "Left Value": left_val,
                "Right Value": right_val,
                "Similarity": float(feature_vals[i]) if not np.isnan(feature_vals[i]) else 0.0,
            })

    df = pd.DataFrame(rows)

    if backend == "altair" and _has_altair():
        import altair as alt

        selection = alt.selection_point(fields=["Pair"])

        chart = alt.Chart(df).mark_rect().encode(
            x=alt.X("Feature:N", title=None),
            y=alt.Y("Pair:N",
                     sort=alt.EncodingSortField(field="Score", order="descending"),
                     title="Pair (sorted by score)"),
            color=alt.Color("Similarity:Q",
                            scale=alt.Scale(scheme="redyellowgreen", domain=[0, 1]),
                            title="Similarity"),
            opacity=alt.condition(selection, alt.value(1), alt.value(0.5)),
            tooltip=[
                alt.Tooltip("Pair:N"),
                alt.Tooltip("Score:Q", format=".3f"),
                alt.Tooltip("Feature:N"),
                alt.Tooltip("Left Value:N"),
                alt.Tooltip("Right Value:N"),
                alt.Tooltip("Similarity:Q", format=".3f"),
            ],
        ).add_params(selection).properties(
            width=max(300, len(names) * 80),
            height=max(200, n * 30),
            title="Comparison Viewer",
        )

        # Similarity text
        text = alt.Chart(df).mark_text(fontSize=10).encode(
            x="Feature:N",
            y=alt.Y("Pair:N",
                     sort=alt.EncodingSortField(field="Score", order="descending")),
            text=alt.Text("Similarity:Q", format=".2f"),
            color=alt.condition(
                alt.datum.Similarity > 0.5,
                alt.value("white"),
                alt.value("black"),
            ),
        )

        return chart + text

    # Matplotlib/text fallback: return a pivot table
    pivot = df.pivot_table(
        index="Pair", columns="Feature", values="Similarity", aggfunc="first"
    )
    return pivot
