from pathlib import Path

import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
from sklearn.metrics.pairwise import cosine_similarity

# 1. LOAD  (CSV is items x users — rows=items, cols=users)
DATA_PATH = Path(__file__).resolve().parent / "user_items.csv"
raw = pd.read_csv(DATA_PATH, index_col=0)
print("Original Matrix (items x users):")
print(raw.to_string())

# 2. FIND MISSING VALUE (?) LOCATION
mask = raw == "?"
first_missing = mask.stack()[mask.stack()].index[0]
target_item = str(first_missing[0])  # row  -> item
target_user = str(first_missing[1])  # col  -> user
print(f"\nTarget: item='{target_item}', user='{target_user}'")

# 3. CLEAN & CONVERT TO NUMERIC (items x users)
raw.replace("?", np.nan, inplace=True)
raw = raw.apply(pd.to_numeric)
raw.index = raw.index.astype(str)
raw.columns = raw.columns.astype(str)
print("\nCleaned Matrix (items x users):")
print(raw.to_string())

df_iu = raw.copy()  # items x users (natural CSV layout)
df_ui = raw.T.copy()  # users x items (transpose for user-user CF)

# 4. MEANS
#    item mean = mean across users  (axis=1 on items x users)
#    user mean = mean across items  (axis=1 on users x items)
item_means = df_iu.mean(axis=1)
user_means = df_ui.mean(axis=1)
print("\nItem means:")
print(item_means.round(4).to_string())
print("\nUser means:")
print(user_means.round(4).to_string())


# 5. METHOD 1 — ITEM-ITEM  (Pearson Correlation on mean-centered item vectors)

print("\n" + "=" * 50)
print("METHOD 1: ITEM-ITEM (Pearson Correlation)")
print("=" * 50)

# 5.1 Mean-center each item row by subtracting its mean across users
mc_iu = df_iu.sub(item_means, axis=0)
print("\nItem mean-centered matrix:")
print(mc_iu.round(4).to_string())

# 5.2 Pearson corr between items: transpose so items are rows, then corr()
item_sim = mc_iu.T.corr()
item_sim.index = item_sim.index.astype(str)
item_sim.columns = item_sim.columns.astype(str)
print("\nItem-Item Similarity (Pearson):")
print(item_sim.round(4).to_string())

# Alternative: Cosine similarity on mean-centered item vectors
# mc_iu_filled = mc_iu.fillna(0)
# cos_arr = cosine_similarity(mc_iu_filled)
# item_sim = pd.DataFrame(cos_arr, index=df_iu.index, columns=df_iu.index)
# item_sim.index = item_sim.index.astype(str)
# item_sim.columns = item_sim.columns.astype(str)
# print("\nItem-Item Similarity (Cosine on mean-centered):")
# print(item_sim.round(4).to_string())

# 5.3 Similarities to target item, drop self and NaN
sims_to_item = item_sim[target_item].drop(target_item).dropna()
print(f"\nSimilarities to item '{target_item}' (sorted):")
print(sims_to_item.sort_values(ascending=False).round(4).to_string())

# 5.4 Items rated by the target user (exclude target item)
rated_by_user = df_iu[target_user].dropna().drop(target_item, errors="ignore")
print(f"\nItems rated by user '{target_user}':")
print(rated_by_user.to_string())

# 5.5 Neighbours = items similar to target_item AND rated by target_user
nb_items = sims_to_item.index.intersection(rated_by_user.index)
sim_ii = sims_to_item[nb_items]
rat_ii = rated_by_user[nb_items]
print(f"\nNeighbour items : {list(nb_items)}")
print("Similarities    :", sim_ii.round(4).values)
print("Ratings         :", rat_ii.values)

threshold = 0.2
top_k = 3

if len(nb_items) == 0:
    # Edge case: no common items at all — fall back to user mean
    pred_ii = user_means[target_user]
    print(f"\n--- Item-Item Result ---")
    print(f"  No common items. Fallback to user mean: {pred_ii:.4f}")
else:
    # filter negative correlations
    sim_ii = sim_ii[sim_ii > 0]
    # filter below threshold
    sim_ii = sim_ii[sim_ii > threshold]
    # keep top-k
    sim_ii = sim_ii.sort_values(ascending=False).head(top_k)
    rat_ii = rat_ii[sim_ii.index]
    print(
        f"\nFiltered neighbour items (pos, >{threshold}, top-{top_k}): {list(sim_ii.index)}"
    )
    print("Similarities:", sim_ii.round(4).values)
    print("Ratings     :", rat_ii.values)

    # 5.6 Weighted deviation formula
    # r̂(u,i) = mean(u) + Σ[sim(i,j)*(r(u,j)-mean(u))] / Σ|sim(i,j)|
    u_mean_ii = user_means[target_user]
    dev_ii = rat_ii - u_mean_ii
    num_ii = (sim_ii * dev_ii).sum()
    den_ii = sim_ii.abs().sum()
    pred_ii = u_mean_ii + (num_ii / den_ii) if den_ii != 0 else u_mean_ii

    print(f"\n--- Item-Item Result ---")
    print(f"  user mean ('{target_user}')     : {u_mean_ii:.4f}")
    print(f"  numerator  Σ sim*(r-mean)       : {num_ii:.4f}")
    print(f"  denominator Σ|sim|              : {den_ii:.4f}")
    print(f"  predicted r('{target_user}', item '{target_item}') = {pred_ii:.4f}")

# Alternative: Weighted average formula (NO mean-centering)
# num_ii = (sim_ii * rat_ii).sum()
# den_ii = sim_ii.abs().sum()
# pred_ii_wa = num_ii / den_ii if den_ii != 0 else user_means[target_user]
# print(f"\n--- Item-Item Result (Weighted Average, no centering) ---")
# print(f"  numerator  Σ sim * rating       : {num_ii:.4f}")
# print(f"  denominator Σ|sim|              : {den_ii:.4f}")
# print(f"  predicted r('{target_user}', item '{target_item}') = {pred_ii_wa:.4f}")


# 6. METHOD 2 — USER-USER  (Cosine Similarity on mean-centered user vectors)

print("\n" + "=" * 50)
print("METHOD 2: USER-USER (Cosine Similarity)")
print("=" * 50)

# 6.1 Mean-center each user row by subtracting its mean across items
mc_ui = df_ui.sub(user_means, axis=0)
print("\nUser mean-centered matrix:")
print(mc_ui.round(4).to_string())

# 6.2 Cosine similarity on mean-centered user vectors (NaN -> 0 before cosine)
mc_ui_filled = mc_ui.fillna(0)
cos_arr = cosine_similarity(mc_ui_filled)
user_sim = pd.DataFrame(cos_arr, index=df_ui.index, columns=df_ui.index)
user_sim.index = user_sim.index.astype(str)
user_sim.columns = user_sim.columns.astype(str)
print("\nUser-User Similarity (Cosine):")
print(user_sim.round(4).to_string())

# 6.3 Similarities to target user, drop self
sims_to_user = user_sim[target_user].drop(target_user)
print(f"\nSimilarities to user '{target_user}' (sorted):")
print(sims_to_user.sort_values(ascending=False).round(4).to_string())

# 6.4 Users who rated the target item
rated_item = df_iu.loc[target_item].dropna()
print(f"\nUsers who rated item '{target_item}':")
print(rated_item.to_string())

# 6.5 Neighbours = users similar to target_user AND who rated target_item
nb_users = sims_to_user.index.intersection(rated_item.index)
sim_uu = sims_to_user[nb_users]
rat_uu = rated_item[nb_users]
print(f"\nNeighbour users : {list(nb_users)}")
print("Similarities    :", sim_uu.round(4).values)
print("Ratings         :", rat_uu.values)

if len(nb_users) == 0:
    # Edge case: no users rated target item — fall back to user mean
    pred_uu = user_means[target_user]
    print(f"\n--- User-User Result ---")
    print(f"  No common users. Fallback to user mean: {pred_uu:.4f}")
else:
    # 6.6 Weighted deviation formula
    # r̂(u,i) = mean(u) + Σ[sim(u,v)*(r(v,i)-mean(v))] / Σ|sim(u,v)|
    u_mean_uu = user_means[target_user]
    neigh_means = user_means[nb_users]
    dev_uu = rat_uu - neigh_means
    num_uu = (sim_uu * dev_uu).sum()
    den_uu = sim_uu.abs().sum()
    pred_uu = u_mean_uu + (num_uu / den_uu) if den_uu != 0 else u_mean_uu

    print(f"\n--- User-User Result ---")
    print(f"  user mean ('{target_user}')     : {u_mean_uu:.4f}")
    print(f"  numerator  Σ sim*(r-mean)       : {num_uu:.4f}")
    print(f"  denominator Σ|sim|              : {den_uu:.4f}")
    print(f"  predicted r('{target_user}', item '{target_item}') = {pred_uu:.4f}")

# Alternative: Simple weighted average (NO mean-centering)
# num_uu = (sim_uu * rat_uu).sum()
# den_uu = sim_uu.abs().sum()
# pred_uu_wa = num_uu / den_uu if den_uu != 0 else user_means[target_user]
# print(f"\n--- User-User Result (Weighted Average, no centering) ---")
# print(f"  numerator  Σ sim * rating       : {num_uu:.4f}")
# print(f"  denominator Σ|sim|              : {den_uu:.4f}")
# print(f"  predicted r('{target_user}', item '{target_item}') = {pred_uu_wa:.4f}")


# FINAL SUMMARY

print("\n" + "=" * 60)
print("FINAL SUMMARY")
print("=" * 60)
print(f"  Target              : user='{target_user}', item='{target_item}'")
print(f"  Item-Item (Pearson) : {pred_ii:.4f}")
print(f"  User-User (Cosine)  : {pred_uu:.4f}")
print("=" * 60)

fig, axes = plt.subplots(1, 3, figsize=(18, 5))
fig.suptitle("Collaborative Filtering — Visualization", fontsize=13, fontweight="bold")

# 1. Rating matrix
ax = axes[0]
ti = list(df_iu.index).index(target_item)
tu = list(df_iu.columns).index(target_user)
im = ax.imshow(df_iu.values.astype(float), cmap="YlGn", aspect="auto", vmin=1, vmax=5)
for i in range(df_iu.shape[0]):
    for j in range(df_iu.shape[1]):
        val = df_iu.iloc[i, j]
        if i == ti and j == tu:
            ax.add_patch(
                plt.Rectangle(
                    (j - 0.5, i - 0.5), 1, 1, fill=True, color="#4fc3f7", zorder=2
                )
            )
            ax.text(
                j,
                i,
                "?",
                ha="center",
                va="center",
                fontsize=9,
                fontweight="bold",
                color="navy",
                zorder=3,
            )
        elif pd.notna(val):
            ax.text(j, i, int(val), ha="center", va="center", fontsize=8, color="black")
ax.set_xticks(range(len(df_iu.columns)))
ax.set_xticklabels(df_iu.columns, rotation=45, ha="right", fontsize=8)
ax.set_yticks(range(len(df_iu.index)))
ax.set_yticklabels(df_iu.index, fontsize=8)
ax.set_title("Rating matrix\n(blue = target ?)", fontsize=10)
plt.colorbar(im, ax=ax, shrink=0.8, label="rating")

# 2. Item-item similarity (full matrix)
ax = axes[1]
im2 = ax.imshow(
    item_sim.values.astype(float), cmap="coolwarm", vmin=-1, vmax=1, aspect="auto"
)
for i in range(item_sim.shape[0]):
    for j in range(item_sim.shape[1]):
        val = item_sim.iloc[i, j]
        if not np.isnan(val):
            ax.text(j, i, f"{val:.2f}", ha="center", va="center", fontsize=7)
ax.set_xticks(range(len(item_sim.columns)))
ax.set_xticklabels(item_sim.columns, rotation=45, ha="right", fontsize=8)
ax.set_yticks(range(len(item_sim.index)))
ax.set_yticklabels(item_sim.index, fontsize=8)
ax.set_title("Item-item similarity\n(Pearson)", fontsize=10)
plt.colorbar(im2, ax=ax, shrink=0.8, label="correlation")

# 3. User-user similarity (full matrix)
ax = axes[2]
im3 = ax.imshow(
    user_sim.values.astype(float), cmap="coolwarm", vmin=-1, vmax=1, aspect="auto"
)
for i in range(user_sim.shape[0]):
    for j in range(user_sim.shape[1]):
        ax.text(j, i, f"{user_sim.iloc[i,j]:.2f}", ha="center", va="center", fontsize=6)
ax.set_xticks(range(len(user_sim.columns)))
ax.set_xticklabels(user_sim.columns, rotation=45, ha="right", fontsize=8)
ax.set_yticks(range(len(user_sim.index)))
ax.set_yticklabels(user_sim.index, fontsize=8)
ax.set_title("User-user similarity\n(Cosine)", fontsize=10)
plt.colorbar(im3, ax=ax, shrink=0.8, label="similarity")

plt.tight_layout()
plt.show()


# 7. EVALUATION METRICS (Leave-One-Out on observed ratings)

print("\n" + "=" * 60)
print("STEP 7: EVALUATION METRICS REPORT")
print("=" * 60)


def predict_item_item_loo(train_iu: pd.DataFrame, user_id: str, item_id: str) -> float:
    """Predict r(user_id, item_id) using item-item Pearson on a train matrix."""
    item_means_local = train_iu.mean(axis=1)
    user_means_local = train_iu.T.mean(axis=1)

    if user_id not in train_iu.columns or item_id not in train_iu.index:
        return np.nan

    mc_iu_local = train_iu.sub(item_means_local, axis=0)
    item_sim_local = mc_iu_local.T.corr()
    item_sim_local.index = item_sim_local.index.astype(str)
    item_sim_local.columns = item_sim_local.columns.astype(str)

    sims_to_item_local = item_sim_local[item_id].drop(item_id, errors="ignore").dropna()
    rated_by_user_local = train_iu[user_id].dropna().drop(item_id, errors="ignore")

    nb_items_local = sims_to_item_local.index.intersection(rated_by_user_local.index)
    if len(nb_items_local) == 0:
        return float(user_means_local.get(user_id, np.nan))

    sim_ii_local = sims_to_item_local[nb_items_local]
    rat_ii_local = rated_by_user_local[nb_items_local]

    # Match method-1 filtering for fair evaluation.
    sim_ii_local = sim_ii_local[sim_ii_local > 0]
    sim_ii_local = sim_ii_local[sim_ii_local > threshold]
    sim_ii_local = sim_ii_local.sort_values(ascending=False).head(top_k)

    if sim_ii_local.empty:
        return float(user_means_local.get(user_id, np.nan))

    rat_ii_local = rat_ii_local[sim_ii_local.index]
    u_mean_local = user_means_local.get(user_id, np.nan)
    dev_ii_local = rat_ii_local - u_mean_local
    num_ii_local = (sim_ii_local * dev_ii_local).sum()
    den_ii_local = sim_ii_local.abs().sum()

    return float(u_mean_local + (num_ii_local / den_ii_local)) if den_ii_local != 0 else float(u_mean_local)


def predict_user_user_loo(train_iu: pd.DataFrame, user_id: str, item_id: str) -> float:
    """Predict r(user_id, item_id) using user-user cosine on a train matrix."""
    if user_id not in train_iu.columns or item_id not in train_iu.index:
        return np.nan

    train_ui = train_iu.T.copy()
    user_means_local = train_ui.mean(axis=1)
    mc_ui_local = train_ui.sub(user_means_local, axis=0)

    mc_ui_filled_local = mc_ui_local.fillna(0)
    user_sim_local = pd.DataFrame(
        cosine_similarity(mc_ui_filled_local),
        index=train_ui.index.astype(str),
        columns=train_ui.index.astype(str),
    )

    sims_to_user_local = user_sim_local[user_id].drop(user_id, errors="ignore")
    rated_item_local = train_iu.loc[item_id].dropna()
    nb_users_local = sims_to_user_local.index.intersection(rated_item_local.index)

    if len(nb_users_local) == 0:
        return float(user_means_local.get(user_id, np.nan))

    sim_uu_local = sims_to_user_local[nb_users_local]
    rat_uu_local = rated_item_local[nb_users_local]
    neigh_means_local = user_means_local[nb_users_local]

    u_mean_local = user_means_local.get(user_id, np.nan)
    dev_uu_local = rat_uu_local - neigh_means_local
    num_uu_local = (sim_uu_local * dev_uu_local).sum()
    den_uu_local = sim_uu_local.abs().sum()

    return float(u_mean_local + (num_uu_local / den_uu_local)) if den_uu_local != 0 else float(u_mean_local)


def topk_precision_recall(
    pred_df: pd.DataFrame,
    pred_col: str,
    k: int,
    relevance_threshold: float,
) -> tuple[float, float]:
    """Macro-averaged Precision@K and Recall@K across users."""
    p_scores = []
    r_scores = []

    for user_id, grp in pred_df.groupby("user"):
        grp = grp.sort_values(pred_col, ascending=False)
        if grp.empty:
            continue

        denom_k = min(k, len(grp))
        topk_items = set(grp.head(denom_k)["item"])
        relevant_items = set(grp.loc[grp["true"] >= relevance_threshold, "item"])

        if denom_k == 0:
            continue

        hits = len(topk_items.intersection(relevant_items))
        p_scores.append(hits / denom_k)

        if len(relevant_items) > 0:
            r_scores.append(hits / len(relevant_items))

    precision_k = float(np.mean(p_scores)) if p_scores else np.nan
    recall_k = float(np.mean(r_scores)) if r_scores else np.nan
    return precision_k, recall_k


records = []

# Evaluate only on known ratings; each point is predicted from a matrix where that point is hidden.
for item_id in df_iu.index:
    for user_id in df_iu.columns:
        true_rating = df_iu.loc[item_id, user_id]
        if pd.isna(true_rating):
            continue

        train_iu = df_iu.copy()
        train_iu.loc[item_id, user_id] = np.nan

        pred_item_item = predict_item_item_loo(train_iu, str(user_id), str(item_id))
        pred_user_user = predict_user_user_loo(train_iu, str(user_id), str(item_id))

        records.append(
            {
                "user": str(user_id),
                "item": str(item_id),
                "true": float(true_rating),
                "pred_item_item": float(pred_item_item) if pd.notna(pred_item_item) else np.nan,
                "pred_user_user": float(pred_user_user) if pd.notna(pred_user_user) else np.nan,
            }
        )

eval_df = pd.DataFrame(records)

valid_ii = eval_df.dropna(subset=["pred_item_item"]).copy()
valid_uu = eval_df.dropna(subset=["pred_user_user"]).copy()


def compute_rmse(frame: pd.DataFrame, pred_col: str) -> float:
    if frame.empty:
        return np.nan
    return float(np.sqrt(np.mean((frame["true"] - frame[pred_col]) ** 2)))


def compute_rank_corr(frame: pd.DataFrame, pred_col: str) -> float:
    if frame.empty:
        return np.nan
    if frame["true"].nunique() < 2 or frame[pred_col].nunique() < 2:
        return np.nan
    return float(frame["true"].corr(frame[pred_col], method="spearman"))


eval_k = 3
relevance_threshold = 4.0

rmse_ii = compute_rmse(valid_ii, "pred_item_item")
rmse_uu = compute_rmse(valid_uu, "pred_user_user")

rank_corr_ii = compute_rank_corr(valid_ii, "pred_item_item")
rank_corr_uu = compute_rank_corr(valid_uu, "pred_user_user")

precision_k_ii, recall_k_ii = topk_precision_recall(
    valid_ii, "pred_item_item", eval_k, relevance_threshold
)
precision_k_uu, recall_k_uu = topk_precision_recall(
    valid_uu, "pred_user_user", eval_k, relevance_threshold
)

report = pd.DataFrame(
    {
        "Metric": [
            "RMSE",
            f"Precision at top-{eval_k}",
            "Rank correlation (Spearman)",
            f"Precision@{eval_k}",
            f"Recall@{eval_k}",
        ],
        "Item-Item": [
            rmse_ii,
            precision_k_ii,
            rank_corr_ii,
            precision_k_ii,
            recall_k_ii,
        ],
        "User-User": [
            rmse_uu,
            precision_k_uu,
            rank_corr_uu,
            precision_k_uu,
            recall_k_uu,
        ],
    }
)

print(f"Evaluated predictions: item-item={len(valid_ii)}, user-user={len(valid_uu)}")
print(f"Top-K for ranking metrics: K={eval_k}, relevance threshold >= {relevance_threshold}")
print("\nComparison report:")
print(report.round(4).to_string(index=False))
print("=" * 60)
