import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
from sklearn.decomposition import PCA as SklearnPCA
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import mean_absolute_error, mean_squared_error
from sklearn.metrics.pairwise import cosine_similarity

plt.rcParams["figure.figsize"] = (8, 5)
pd.set_option("display.max_columns", None)
pd.set_option("display.width", 120)


items_cb = pd.DataFrame(
    {
        "item_id": [1, 2, 3, 4, 5, 6, 7, 8, 9],
        "title": [
            "Red Mars",
            "Jurassic Park",
            "Lost World",
            "2001",
            "Foundation",
            "Difference Engine",
            "Machine Learning",
            "Neuromancer",
            "2010",
        ],
        "description": [
            "science fiction mars colony politics space future",
            "science thriller dinosaurs genetics adventure park",
            "adventure dinosaurs island science thriller sequel",
            "science fiction space artificial intelligence mission",
            "classic science fiction empire foundation future space",
            "alternate history computing steam engine science fiction",
            "artificial intelligence data algorithms prediction learning",
            "cyberpunk hackers network dystopian future artificial intelligence",
            "science fiction space mission sequel future",
        ],
    }
)

# Example user ratings inspired by the books/movies personalization example in the slides
ratings_cb = pd.DataFrame(
    {
        "user_id": [1, 1, 1, 1, 2, 2, 2],
        "item_id": [1, 4, 5, 8, 2, 3, 7],
        "rating": [5, 4, 5, 4, 5, 4, 3],
    }
)

print("Items used for content-based recommendation:")
display(items_cb)

print("Sample user ratings:")
display(ratings_cb)

"""### Step 1: Build item profiles using TF-IDF"""

vectorizer = TfidfVectorizer(stop_words="english")
tfidf_matrix = vectorizer.fit_transform(items_cb["description"])

print("TF-IDF matrix shape:", tfidf_matrix.shape)

# Cosine similarity between items
item_similarity_cb = cosine_similarity(tfidf_matrix)
item_similarity_cb_df = pd.DataFrame(
    item_similarity_cb, index=items_cb["title"], columns=items_cb["title"]
)

item_similarity_cb_df.round(2)

"""### Step 2: Build a user profile from items rated highly"""


def build_user_profile(user_id, ratings_df, items_df, tfidf_matrix, min_rating=4):
    liked_item_ids = ratings_df[
        (ratings_df["user_id"] == user_id) & (ratings_df["rating"] >= min_rating)
    ]["item_id"]
    liked_idx = items_df[items_df["item_id"].isin(liked_item_ids)].index.tolist()

    if not liked_idx:
        return None

    # weighted average of item profiles, using rating as weight
    liked_ratings = ratings_df[
        (ratings_df["user_id"] == user_id) & (ratings_df["rating"] >= min_rating)
    ]
    weights = (
        liked_ratings.set_index("item_id")
        .loc[items_df.loc[liked_idx, "item_id"], "rating"]
        .values
    )
    profile = np.average(tfidf_matrix[liked_idx].toarray(), axis=0, weights=weights)
    return profile.reshape(1, -1)


user1_profile = build_user_profile(1, ratings_cb, items_cb, tfidf_matrix, min_rating=4)
print("User 1 profile shape:", user1_profile.shape)

"""### Step 3: Match user profile to item profiles"""


def recommend_content_based(user_id, ratings_df, items_df, tfidf_matrix, top_n=5):
    user_profile = build_user_profile(
        user_id, ratings_df, items_df, tfidf_matrix, min_rating=4
    )
    if user_profile is None:
        return pd.DataFrame(columns=["item_id", "title", "score"])

    scores = cosine_similarity(user_profile, tfidf_matrix).flatten()
    result = items_df.copy()
    result["score"] = scores

    rated_items = ratings_df[ratings_df["user_id"] == user_id]["item_id"].tolist()
    result = result[~result["item_id"].isin(rated_items)]

    return result.sort_values("score", ascending=False)[
        ["item_id", "title", "score"]
    ].head(top_n)


cb_recommendations = recommend_content_based(
    1, ratings_cb, items_cb, tfidf_matrix, top_n=5
)
cb_recommendations

"""
### Evaluation metrics
The recommender slides mention metrics such as:
- RMSE
- Precision at top-k
- Rank correlation

For a compact lab exam notebook, for this content-based section we demonstrate:
- **Precision@K**
- **Recall@K**

Here we simulate a tiny held-out relevant set for User 1.
"""


def precision_at_k(recommended, relevant, k):
    recommended_k = recommended[:k]
    hits = len(set(recommended_k) & set(relevant))
    return hits / k if k else 0


def recall_at_k(recommended, relevant, k):
    recommended_k = recommended[:k]
    hits = len(set(recommended_k) & set(relevant))
    return hits / len(relevant) if relevant else 0


recommended_items = cb_recommendations["item_id"].tolist()
relevant_items = [9, 6]

print("Recommended item IDs:", recommended_items)
print("Relevant item IDs (held-out assumption):", relevant_items)
print("Precision@3 =", round(precision_at_k(recommended_items, relevant_items, 3), 3))
print("Recall@3    =", round(recall_at_k(recommended_items, relevant_items, 3), 3))

"""### Visualizations"""

plt.imshow(item_similarity_cb_df.values, aspect="auto")
plt.colorbar(label="Cosine similarity")
plt.xticks(
    range(len(item_similarity_cb_df.columns)),
    item_similarity_cb_df.columns,
    rotation=90,
)
plt.yticks(range(len(item_similarity_cb_df.index)), item_similarity_cb_df.index)
plt.title("Content-Based Item Similarity Matrix")
plt.tight_layout()
plt.show()

plt.bar(cb_recommendations["title"], cb_recommendations["score"])
plt.xticks(rotation=45, ha="right")
plt.ylabel("Similarity score")
plt.title("Top Content-Based Recommendations for User 1")
plt.tight_layout()
plt.show()


cf_matrix = pd.DataFrame(
    [
        [2, 3, np.nan, 1, 3, 8],
        [0, 3, 1, 4, 6, 7],
        [3, 0, 0, 3, 4, 6],
        [9, 5, 1, 5, 0, 7],
        [3, 4, 6, 7, 9, 9],
        [4, 0, 1, 4, 8, 0],
        [2, 4, 0, 0, 0, 8],
    ],
    index=["U1", "U2", "U3", "U4", "U5", "U6", "U7"],
    columns=["I1", "I2", "I3", "I4", "I5", "I6"],
)

cf_matrix

"""### Step 1: Define Pearson similarity on co-rated items only"""


def pearson_similarity(user_a, user_b):
    # consider only co-rated items (non-missing and non-zero)
    mask = user_a.notna() & user_b.notna() & (user_a != 0) & (user_b != 0)
    a = user_a[mask]
    b = user_b[mask]

    if len(a) < 2:
        return 0.0

    a_centered = a - a.mean()
    b_centered = b - b.mean()

    denom = np.sqrt((a_centered**2).sum()) * np.sqrt((b_centered**2).sum())
    if denom == 0:
        return 0.0

    return ((a_centered * b_centered).sum()) / denom


target_user = "U1"
sims = {}

for u in cf_matrix.index:
    if u != target_user:
        sims[u] = pearson_similarity(cf_matrix.loc[target_user], cf_matrix.loc[u])

user_similarity_df = pd.DataFrame.from_dict(
    sims, orient="index", columns=["pearson_similarity"]
).sort_values("pearson_similarity", ascending=False)
user_similarity_df

"""### Step 2: Select top neighbors who rated the target item"""

target_item = "I3"

neighbors = []
for u, sim in sims.items():
    rating = cf_matrix.loc[u, target_item]
    if pd.notna(rating) and rating != 0:
        neighbors.append((u, sim, rating))

neighbors_df = pd.DataFrame(
    neighbors, columns=["neighbor", "similarity", "rating_on_I3"]
)
neighbors_df = neighbors_df.sort_values("similarity", ascending=False)
neighbors_df

"""### Step 3: Predict the missing rating"""

# Use top-k positively similar neighbors for a simple exam-friendly prediction
top_k = neighbors_df[neighbors_df["similarity"] > 0].head(3).copy()

if len(top_k) == 0 or top_k["similarity"].sum() == 0:
    predicted_rating_user_based = np.nan
else:
    predicted_rating_user_based = np.sum(
        top_k["similarity"] * top_k["rating_on_I3"]
    ) / np.sum(top_k["similarity"])

print("Top neighbors used:")
display(top_k)

print("Predicted rating for U1 on I3 =", round(predicted_rating_user_based, 3))


item_cf_small = pd.DataFrame(
    [[4, 5, np.nan, 2], [5, 3, 4, 3], [2, 4, 5, 1]],
    index=["U1", "U2", "U3"],
    columns=["Item1", "Item2", "Item3", "Item4"],
)

item_cf_small


def cosine_sim(x, y):
    x = np.array(x, dtype=float)
    y = np.array(y, dtype=float)
    return np.dot(x, y) / (np.linalg.norm(x) * np.linalg.norm(y))


sim_13 = cosine_sim([5, 2], [4, 5])  # expected approx 0.87
sim_23 = cosine_sim([3, 4], [4, 5])  # expected approx 1.00
sim_43 = cosine_sim([3, 1], [4, 5])  # expected approx 0.84

print("sim(Item1, Item3) =", round(sim_13, 2))
print("sim(Item2, Item3) =", round(sim_23, 2))
print("sim(Item4, Item3) =", round(sim_43, 2))

"""### Step 2: Use U1's existing ratings to predict U1 on Item3"""

u1_ratings = {"Item1": 4, "Item2": 5, "Item4": 2}

numerator = (
    sim_13 * u1_ratings["Item1"]
    + sim_23 * u1_ratings["Item2"]
    + sim_43 * u1_ratings["Item4"]
)
denominator = sim_13 + sim_23 + sim_43

predicted_u1_item3 = numerator / denominator

print("Numerator   =", round(numerator, 2))
print("Denominator =", round(denominator, 2))
print("Predicted rating for U1 on Item3 =", round(predicted_u1_item3, 2))


# Tiny leave-one-out evaluation for item-based CF on the 3x4 matrix
known_entries = []
for u in item_cf_small.index:
    for i in item_cf_small.columns:
        if pd.notna(item_cf_small.loc[u, i]):
            known_entries.append((u, i))


def predict_item_based_leave_one_out(df, user, target_item):
    temp = df.copy()
    true_rating = temp.loc[user, target_item]
    temp.loc[user, target_item] = np.nan

    # items rated by target user
    rated_items = [
        col
        for col in temp.columns
        if col != target_item and pd.notna(temp.loc[user, col])
    ]

    sims = []
    for other_item in rated_items:
        both = temp[[other_item, target_item]].dropna()
        if len(both) < 2:
            continue
        sim = cosine_sim(both[other_item].values, both[target_item].values)
        sims.append((other_item, sim, temp.loc[user, other_item]))

    sims = [(it, s, r) for it, s, r in sims if s > 0]
    if not sims:
        return np.nan

    num = sum(s * r for _, s, r in sims)
    den = sum(s for _, s, _ in sims)
    return num / den if den != 0 else np.nan


y_true, y_pred = [], []
for user, item in known_entries:
    pred = predict_item_based_leave_one_out(item_cf_small, user, item)
    if not np.isnan(pred):
        y_true.append(item_cf_small.loc[user, item])
        y_pred.append(pred)

print("Predictions used in evaluation:", len(y_true))
print("MAE  =", round(mean_absolute_error(y_true, y_pred), 4))
print("RMSE =", round(np.sqrt(mean_squared_error(y_true, y_pred)), 4))

"""### Visualizations"""

# user-based similarity bar chart
plt.bar(user_similarity_df.index, user_similarity_df["pearson_similarity"])
plt.title("User Similarity with U1 (Pearson)")
plt.xlabel("Neighbor user")
plt.ylabel("Similarity")
plt.tight_layout()
plt.show()

sim_values = pd.Series(
    {"Item1 vs Item3": sim_13, "Item2 vs Item3": sim_23, "Item4 vs Item3": sim_43}
)

plt.bar(sim_values.index, sim_values.values)
plt.ylabel("Cosine similarity")
plt.title("Solved PPT: Similarity with Item3")
plt.xticks(rotation=30, ha="right")
plt.tight_layout()
plt.show()


X = np.array(
    [
        [2.5, 2.4],
        [0.5, 0.7],
        [2.2, 2.9],
        [1.9, 2.2],
        [3.1, 3.0],
        [2.3, 2.7],
        [2.0, 1.6],
        [1.0, 1.1],
        [1.5, 1.6],
        [1.1, 0.9],
    ],
    dtype=float,
)

pca_df = pd.DataFrame(X, columns=["X1", "X2"])
pca_df

"""### Step 1: Center the data"""

mean_vector = X.mean(axis=0)
X_centered = X - mean_vector

print("Mean vector:", mean_vector)
pd.DataFrame(X_centered, columns=["X1_centered", "X2_centered"]).head()

"""### Step 2: Covariance matrix computation"""

cov_matrix = np.cov(X_centered, rowvar=False)
cov_df = pd.DataFrame(cov_matrix, index=["X1", "X2"], columns=["X1", "X2"])
cov_df

"""### Step 3: Eigenvalue and eigenvector computation"""

eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)

print("Eigenvalues:")
print(eigenvalues)

print("\nEigenvectors:")
print(eigenvectors)

"""### Step 4: Sort eigenvalues in descending order"""

sorted_idx = np.argsort(eigenvalues)[::-1]
eigenvalues_sorted = eigenvalues[sorted_idx]
eigenvectors_sorted = eigenvectors[:, sorted_idx]

print("Sorted eigenvalues:", eigenvalues_sorted)
print("\nSorted eigenvectors:\n", eigenvectors_sorted)

"""### Step 5: Select principal components"""

# Keep only the first principal component for maximum variance direction
W = eigenvectors_sorted[:, :1]
print("Selected principal component vector:\n", W)

"""### Step 6: Project data onto principal components"""

X_pca_manual = X_centered @ W
pd.DataFrame(X_pca_manual, columns=["PC1"]).head()

sk_pca = SklearnPCA(n_components=2)
X_pca_sklearn = sk_pca.fit_transform(X_centered)

print("Explained variance ratio:", sk_pca.explained_variance_ratio_)
print("Cumulative explained variance:", np.cumsum(sk_pca.explained_variance_ratio_))


explained_variance_ratio = eigenvalues_sorted / eigenvalues_sorted.sum()
cumulative_explained_variance = np.cumsum(explained_variance_ratio)

# reconstruct from first PC
X_reconstructed = X_pca_manual @ W.T + mean_vector
reconstruction_error = np.mean((X - X_reconstructed) ** 2)

print("Explained variance ratio:", explained_variance_ratio)
print("Cumulative explained variance:", cumulative_explained_variance)
print("Reconstruction error using 1 PC:", reconstruction_error)

"""### Visualizations"""

plt.scatter(X[:, 0], X[:, 1])
plt.xlabel("X1")
plt.ylabel("X2")
plt.title("Original Data")
plt.grid(True)
plt.show()

plt.scatter(X_pca_manual[:, 0], np.zeros(len(X_pca_manual)))
plt.xlabel("PC1")
plt.title("Projection of Data onto First Principal Component")
plt.yticks([])
plt.grid(True)
plt.show()

plt.bar(["PC1", "PC2"], explained_variance_ratio)
plt.ylabel("Explained Variance Ratio")
plt.title("Scree Plot")
plt.show()

plt.plot(["PC1", "PC2"], cumulative_explained_variance, marker="o")
plt.ylabel("Cumulative Explained Variance")
plt.title("Cumulative Explained Variance Plot")
plt.ylim(0, 1.05)
plt.grid(True)
plt.show()
