# ----------------------------------
# DM--------------------------------
# ----------------------------------
# ==================== START OF dm.py ====================

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd


# LOAD DATA
def load_data(path):
    df = pd.read_csv(path)
    return df


# PREPROCESSING
def preprocess(df):
    # Remove CustomerID
    df = df.drop(columns=["CustomerID"])

    # Check missing values
    print("Missing Values:\n", df.isnull().sum())

    # Convert Gender to numeric
    df["Gender"] = df["Gender"].map({"Male": 1, "Female": 0})

    return df


# VISUALIZATION
def plot_gender_ratio(df):
    counts = df["Gender"].value_counts()
    labels = ["Female", "Male"]
    plt.figure()
    plt.pie(counts, labels=labels, autopct="%1.1f%%")
    plt.title("Gender Ratio")
    plt.show()


def plot_bar_graphs(df):
    plt.figure()
    df["Age"].value_counts().sort_index().plot(kind="bar")
    plt.title("Age Distribution")
    plt.xlabel("Age")
    plt.ylabel("Count")
    plt.show()

    plt.figure()
    df["Annual Income (k$)"].value_counts().sort_index().plot(kind="bar")
    plt.title("Annual Income Distribution")
    plt.xlabel("Income (k$)")
    plt.ylabel("Count")
    plt.show()


# DISTANCE FUNCTION
def euclidean_distance(a, b):
    return np.sqrt(np.sum((a - b) ** 2))


# WARD LINKAGE (MANUAL)
def ward_distance(cluster1, cluster2, data):
    # Compute centroids
    c1 = np.mean(data[cluster1], axis=0)
    c2 = np.mean(data[cluster2], axis=0)

    n1 = len(cluster1)
    n2 = len(cluster2)

    # Ward formula
    return (n1 * n2) / (n1 + n2) * np.sum((c1 - c2) ** 2)


# AGGLOMERATIVE CLUSTERING
def agglomerative_clustering(data):
    clusters = [[i] for i in range(len(data))]
    history = []

    while len(clusters) > 1:
        min_dist = float("inf")
        pair = None

        for i in range(len(clusters)):
            for j in range(i + 1, len(clusters)):
                dist = ward_distance(clusters[i], clusters[j], data)
                if dist < min_dist:
                    min_dist = dist
                    pair = (i, j)

        i, j = pair
        new_cluster = clusters[i] + clusters[j]

        history.append((clusters[i], clusters[j], min_dist))

        # Merge clusters
        clusters.pop(j)
        clusters.pop(i)
        clusters.append(new_cluster)

    return history


# DENDROGRAM
def plot_dendrogram(history):
    plt.figure(figsize=(10, 6))

    n = len(history) + 1
    positions = {i: i for i in range(n)}
    heights = {i: 0 for i in range(n)}
    current_cluster_id = n

    for cluster1, cluster2, dist in history:
        c1 = tuple(cluster1)
        c2 = tuple(cluster2)

        # Get positions
        x1 = np.mean([positions[i] for i in cluster1])
        x2 = np.mean([positions[i] for i in cluster2])

        # Get heights
        h1 = max([heights.get(i, 0) for i in cluster1])
        h2 = max([heights.get(i, 0) for i in cluster2])

        # Draw vertical lines
        plt.plot([x1, x1], [h1, dist])
        plt.plot([x2, x2], [h2, dist])

        # Draw horizontal line
        plt.plot([x1, x2], [dist, dist])

        # Update new cluster
        new_cluster = cluster1 + cluster2
        for i in new_cluster:
            positions[i] = (x1 + x2) / 2
            heights[i] = dist

        current_cluster_id += 1

    plt.title("Dendrogram (Manual - Tree Structure)")
    plt.xlabel("Data Points")
    plt.ylabel("Distance")
    plt.show()


if __name__ == "__main__":
    path = "Mall_Customers.csv"  # update path

    df = load_data(path)

    print("Original Dataset:")
    print(df.head())

    df = preprocess(df)

    print("Dataset After Preprocessing:")
    print(df.head())

    # Gender Count
    print("Gender Count (0=Female, 1=Male):")
    print(df["Gender"].value_counts())

    # Visualizations
    plot_gender_ratio(df)
    plot_bar_graphs(df)

    # Prepare data for clustering
    data = df.values.astype(float)

    # Perform clustering
    history = agglomerative_clustering(data)

    print("Clustering Merge Steps (Cluster1, Cluster2, Distance):")
    for step in history[:10]:  # print first 10 steps
        print(step)

    # Plot dendrogram
    plot_dendrogram(history)


# ==================== START OF dm1.py ====================

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.datasets import make_moons


# LOAD DATA
def load_data(path):
    df = pd.read_csv(path)
    return df


# TASK 1
def preprocess_wholesale(df):
    # Drop columns
    df = df.drop(columns=["Channel", "Region"])

    print("First few records:\n")
    print(df.head())

    return df


# NORMALIZATION (Z-SCORE)
def normalize(data):
    mean = np.mean(data, axis=0)
    std = np.std(data, axis=0)
    return (data - mean) / std


# VISUALIZATION
def plot_data(data, title):
    plt.figure()
    plt.scatter(data[:, 0], data[:, 1])
    plt.title(title)
    plt.xlabel("Groceries")
    plt.ylabel("Milk")
    plt.show()


# DBSCAN
def euclidean(p, q):
    return np.sqrt(np.sum((p - q) ** 2))


def region_query(data, point_idx, eps):
    neighbors = []
    for i in range(len(data)):
        if euclidean(data[point_idx], data[i]) <= eps:
            neighbors.append(i)
    return neighbors


def expand_cluster(data, labels, point_idx, cluster_id, eps, min_pts):
    seeds = region_query(data, point_idx, eps)

    if len(seeds) < min_pts:
        labels[point_idx] = -1  # noise
        return False

    for seed in seeds:
        labels[seed] = cluster_id

    i = 0
    while i < len(seeds):
        current = seeds[i]
        result = region_query(data, current, eps)

        if len(result) >= min_pts:
            for r in result:
                if labels[r] == 0:
                    seeds.append(r)
                    labels[r] = cluster_id
                elif labels[r] == -1:
                    labels[r] = cluster_id
        i += 1

    return True


def dbscan(data, eps, min_pts):
    labels = [0] * len(data)
    cluster_id = 0

    for i in range(len(data)):
        if labels[i] != 0:
            continue

        if expand_cluster(data, labels, i, cluster_id + 1, eps, min_pts):
            cluster_id += 1

    return np.array(labels)


# PLOT CLUSTERS
def plot_clusters(data, labels, title):
    plt.figure()
    unique_labels = set(labels)

    for label in unique_labels:
        if label == -1:
            color = "k"
        else:
            color = None

        points = data[labels == label]
        plt.scatter(points[:, 0], points[:, 1])

    plt.title(title)
    plt.xlabel("Feature 1")
    plt.ylabel("Feature 2")
    plt.show()


# TASK 2 - MOONS
def moons_dbscan():
    # Generate moons dataset
    X, _ = make_moons(n_samples=2000, noise=0.05)

    # Apply manual DBSCAN
    labels = dbscan(X, eps=0.2, min_pts=5)
    plot_clusters(X, labels, "DBSCAN on Moons")

    # Add noise manually
    noise = np.random.uniform(low=-1.5, high=2.5, size=(200, 2))
    X_noisy = np.vstack((X, noise))

    # Apply manual DBSCAN again
    labels_noisy = dbscan(X_noisy, eps=0.2, min_pts=5)
    plot_clusters(X_noisy, labels_noisy, "DBSCAN with Noise")


if __name__ == "__main__":
    path = "Wholesale customers data.csv"  # update path

    # Load and preprocess
    df = load_data(path)
    print("Original Dataset:")
    print(df.head())

    df = preprocess_wholesale(df)

    # Select features
    subset = df[["Grocery", "Milk"]].values

    print("Selected Features (Groceries, Milk):")
    print(subset[:5])

    # Normalize
    normalized = normalize(subset)

    print("Normalized Data (first 5 rows):")
    print(normalized[:5])

    # Plot normalized data
    plot_data(normalized, "Normalized Data")

    # Run DBSCAN (manual)
    labels = dbscan(normalized, eps=0.5, min_pts=15)

    print("DBSCAN Cluster Labels (first 20):")
    print(labels[:20])

    print(
        "Number of clusters (excluding noise): ",
        len(set(labels)) - (1 if -1 in labels else 0),
    )
    print("Number of noise points: ", list(labels).count(-1))

    plot_clusters(normalized, labels, "Manual DBSCAN Clusters (Wholesale)")

    # Task 2: Moons
    print("--- MOONS DATASET ---")
    X, _ = make_moons(n_samples=2000, noise=0.05)

    print("Moons Dataset Sample:")
    print(X[:5])

    labels_moons = dbscan(X, eps=0.2, min_pts=5)

    print("Moons Cluster Labels (first 20):")
    print(labels_moons[:20])

    plot_clusters(X, labels_moons, "DBSCAN on Moons")

    # Add noise
    noise = np.random.uniform(low=-1.5, high=2.5, size=(200, 2))
    X_noisy = np.vstack((X, noise))

    print("Noisy Dataset Sample:")
    print(X_noisy[:5])

    labels_noisy = dbscan(X_noisy, eps=0.2, min_pts=5)

    print("Noisy Data Cluster Labels (first 20):")
    print(labels_noisy[:20])

    print(
        "Number of clusters (noisy): ",
        len(set(labels_noisy)) - (1 if -1 in labels_noisy else 0),
    )
    print("Noise points (noisy data): ", list(labels_noisy).count(-1))

    plot_clusters(X_noisy, labels_noisy, "Manual DBSCAN with Noise")


# ==================== START OF dm2.py ====================

import itertools
from collections import defaultdict


# READ INPUT FILES
def read_data(file):
    sequences = []
    with open(file, "r") as f:
        for line in f:
            seq = []
            parts = line.strip().split("}")
            for p in parts:
                if "{" in p:
                    items = p.split("{")[1]
                    if items:
                        seq.append(list(map(int, items.split(","))))
            sequences.append(seq)
    return sequences


def read_parameters(file):
    MIS = {}
    SDC = 0

    with open(file, "r") as f:
        for line in f:
            if "MIS" in line:
                item = int(line.split("(")[1].split(")")[0])
                value = float(line.split("=")[1])
                MIS[item] = value
            elif "SDC" in line:
                SDC = float(line.split("=")[1])

    return MIS, SDC


# SUPPORT COUNT
def support_count(sequences):
    count = defaultdict(int)
    total = len(sequences)

    for seq in sequences:
        items = set()
        for itemset in seq:
            items.update(itemset)
        for item in items:
            count[item] += 1

    support = {item: count[item] / total for item in count}
    return support, count


# INIT PASS (L)
def init_pass(MIS, support):
    sorted_items = sorted(MIS.items(), key=lambda x: x[1])

    L = []
    for item, mis in sorted_items:
        if support.get(item, 0) >= mis:
            L.append(item)

    return L


# LEVEL 1 FREQUENT
def level1(L, support, MIS):
    F1 = []
    for item in L:
        if support[item] >= MIS[item]:
            F1.append([item])
    return F1


# CANDIDATE GENERATION (LEVEL 2)
def candidate_gen_L2(L, support, MIS, SDC):
    C2 = []

    for i in range(len(L)):
        for j in range(i + 1, len(L)):
            if support[L[j]] >= MIS[L[i]] and abs(support[L[i]] - support[L[j]]) <= SDC:
                C2.append([[L[i]], [L[j]]])
                C2.append([[L[i], L[j]]])

    return C2


# SUBSEQUENCE CHECK
def is_subsequence(candidate, sequence):
    i = 0
    for itemset in sequence:
        if set(candidate[i]).issubset(set(itemset)):
            i += 1
            if i == len(candidate):
                return True
    return False


# COUNT SUPPORT FOR SEQUENCE
def count_support_seq(candidates, sequences):
    counts = [0] * len(candidates)

    for i, cand in enumerate(candidates):
        for seq in sequences:
            if is_subsequence(cand, seq):
                counts[i] += 1

    return counts


# FILTER FREQUENT
def filter_candidates(candidates, counts, MIS, sequences):
    F = []
    total = len(sequences)

    for i, cand in enumerate(candidates):
        first_item = cand[0][0]
        if counts[i] / total >= MIS[first_item]:
            F.append((cand, counts[i]))

    return F


# MAIN MS-GSP
def format_pattern(pattern):
    result = "<"
    for itemset in pattern:
        result += "{" + ",".join(map(str, itemset)) + "}"
    result += ">"
    return result


def MSGSP(data_file, para_file):
    sequences = read_data(data_file)
    MIS, SDC = read_parameters(para_file)

    print("\nInput Sequences:\n")
    for s in sequences[:5]:
        print(s)

    print("\nMIS Values:")
    print(MIS)
    print("SDC:", SDC)

    support, item_counts = support_count(sequences)

    print("\nItem Supports:")
    for k, v in support.items():
        print(f"Item {k}: {v:.3f}")

    L = init_pass(MIS, support)
    print("\nL (after init pass):", L)

    F1 = level1(L, support, MIS)
    print("\nF1 (Frequent 1-sequences):", F1)

    C2 = candidate_gen_L2(L, support, MIS, SDC)
    print("\nC2 (Candidate sequences):")
    for c in C2[:10]:
        print(c)

    counts = count_support_seq(C2, sequences)

    F2 = filter_candidates(C2, counts, MIS, sequences)

    print("\nFrequent Patterns:")
    for pattern, count in F2:
        print(f"Pattern :{format_pattern(pattern)} count: {count}")

    # Visualization
    if F2:
        labels = [format_pattern(p) for p, _ in F2[:10]]
        values = [c for _, c in F2[:10]]

        import matplotlib.pyplot as plt

        plt.figure()
        plt.bar(range(len(values)), values)
        plt.xticks(range(len(values)), labels, rotation=45)
        plt.title("Top Frequent Sequential Patterns")
        plt.xlabel("Patterns")
        plt.ylabel("Count")
        plt.tight_layout()
        plt.show()


if __name__ == "__main__":
    MSGSP("data.txt", "para.txt")

# ------------------------------------------------
# IR----------------------------------------------
# ------------------------------------------------
# ==========================================
# SECTION 1: CONTENT_BASED.PY
# ==========================================

import re

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.preprocessing import MinMaxScaler


# LOAD DATA
def load_data():
    movies = pd.DataFrame(
        {
            "movie_id": [1, 2, 3, 4, 5, 6, 7, 8],
            "title": [
                "Inception",
                "Interstellar",
                "Dark Knight",
                "Memento",
                "Tenet",
                "Avatar",
                "Titanic",
                "The Matrix",
            ],
            "description": [
                "dream subconscious thriller mind",
                "space time black hole science",
                "batman joker crime action",
                "memory loss psychological thriller",
                "time inversion action thriller",
                "alien planet sci fi adventure",
                "romance ship tragedy love",
                "virtual reality ai action",
            ],
        }
    )

    user_ratings = pd.Series(
        [5, 4, 0, 0, 3, 0, 0, 5], index=movies["title"], name="User1"
    )

    return movies, user_ratings


# TEXT PREPROCESSING
def clean_text(text):
    text = text.lower()
    text = re.sub(r"[^a-z\s]", "", text)
    return text


def preprocess_text(movies):
    movies["clean_description"] = movies["description"].apply(clean_text)
    return movies


# NORMALIZE RATINGS
def normalize_ratings(user_ratings):
    user_ratings = user_ratings.fillna(0)
    scaler = MinMaxScaler()

    normalized = scaler.fit_transform(user_ratings.values.reshape(-1, 1)).flatten()

    return pd.Series(normalized, index=user_ratings.index)


# FEATURE EXTRACTION
def build_item_profiles(movies):
    tfidf = TfidfVectorizer(stop_words="english")
    tfidf_matrix = tfidf.fit_transform(movies["clean_description"])

    item_profiles = pd.DataFrame(tfidf_matrix.toarray(), index=movies["title"])

    return item_profiles, tfidf


# USER PROFILE
def build_user_profile(user_ratings_norm, item_profiles):
    user_vector = np.dot(user_ratings_norm.values, item_profiles.values)
    return pd.Series(user_vector, index=item_profiles.columns)


# RECOMMENDATION
def recommend(user_profile, item_profiles, user_ratings, top_n=5, threshold=0.0):

    user_vec = user_profile.values.reshape(1, -1)

    scores = cosine_similarity(user_vec, item_profiles.values)[0]

    scores_df = pd.DataFrame({"movie": item_profiles.index, "score": scores})

    # Remove already rated items
    rated_items = user_ratings[user_ratings > 0].index
    scores_df = scores_df[~scores_df["movie"].isin(rated_items)]

    # Apply threshold
    scores_df = scores_df[scores_df["score"] > threshold]

    # Sort
    scores_df = scores_df.sort_values(by="score", ascending=False)

    return scores_df.head(top_n)


# VISUALIZATION
def plot_item_similarity(item_profiles, movies):
    similarity_matrix = cosine_similarity(item_profiles)

    plt.figure()
    plt.imshow(similarity_matrix)
    plt.colorbar()
    plt.title("Item-Item Similarity Matrix")
    plt.xticks(range(len(movies)), movies["title"], rotation=90)
    plt.yticks(range(len(movies)), movies["title"])
    plt.tight_layout()
    plt.show()


def plot_user_ratings(user_ratings):
    plt.figure()
    plt.bar(user_ratings.index, user_ratings.values)
    plt.xticks(rotation=90)
    plt.title("User Ratings")
    plt.ylabel("Rating")
    plt.show()


def plot_recommendations(rec_df):
    if rec_df.empty:
        print("No recommendations to display")
        return

    plt.figure()
    plt.bar(rec_df["movie"], rec_df["score"])
    plt.xticks(rotation=90)
    plt.title("Top Recommended Movies")
    plt.ylabel("Similarity Score")
    plt.show()


def plot_similarity_distribution(item_profiles):
    sim_matrix = cosine_similarity(item_profiles)

    plt.figure()
    plt.hist(sim_matrix.flatten(), bins=20)
    plt.title("Similarity Score Distribution")
    plt.xlabel("Similarity")
    plt.ylabel("Frequency")
    plt.show()


# EVALUATION
def precision_at_k(actual, predicted, k):
    predicted = predicted[:k]
    if len(predicted) == 0:
        return 0
    return len(set(predicted) & set(actual)) / len(predicted)


def recall_at_k(actual, predicted, k):
    if len(actual) == 0:
        return 0
    predicted = predicted[:k]
    return len(set(predicted) & set(actual)) / len(actual)


def f1_score(precision, recall):
    if precision + recall == 0:
        return 0
    return 2 * (precision * recall) / (precision + recall)


def evaluate_system(recommendations, ground_truth, k):
    predicted = recommendations["movie"].tolist()

    precision = precision_at_k(ground_truth, predicted, k)
    recall = recall_at_k(ground_truth, predicted, k)
    f1 = f1_score(precision, recall)

    return precision, recall, f1


def main_content_based():
    # Load data
    movies, user_ratings = load_data()

    # Preprocess
    movies = preprocess_text(movies)

    # Normalize ratings
    user_ratings_norm = normalize_ratings(user_ratings)

    # Build item profiles
    item_profiles, _ = build_item_profiles(movies)

    # Build user profile
    user_profile = build_user_profile(user_ratings_norm, item_profiles)

    # Generate recommendations
    rec_df = recommend(user_profile, item_profiles, user_ratings, top_n=5)

    # Visualization
    plot_item_similarity(item_profiles, movies)
    plot_user_ratings(user_ratings)
    plot_recommendations(rec_df)
    plot_similarity_distribution(item_profiles)

    # Evaluation
    ground_truth = ["Memento", "Avatar"]
    precision, recall, f1 = evaluate_system(rec_df, ground_truth, k=3)

    print("Precision@K:", precision)
    print("Recall@K:", recall)
    print("F1 Score:", f1)

    # Output
    print("\nRecommended Movies:")
    print(rec_df)


# ==========================================
# SECTION 2: DR.PY
# ==========================================


# DATA
def get_data():
    return np.array(
        [
            [5, 3, np.nan, 1],
            [4, np.nan, np.nan, 1],
            [1, 1, np.nan, 5],
            [np.nan, np.nan, 5, 4],
        ],
        dtype=float,
    )


# MISSING VALUE HANDLING
def fill_item_mean(R):
    R_filled = R.copy()
    for j in range(R.shape[1]):
        mean = np.nanmean(R[:, j])
        for i in range(R.shape[0]):
            if np.isnan(R_filled[i, j]):
                R_filled[i, j] = mean
    return R_filled


def fill_user_mean(R):
    R_filled = R.copy()
    for i in range(R.shape[0]):
        mean = np.nanmean(R[i])
        for j in range(R.shape[1]):
            if np.isnan(R_filled[i, j]):
                R_filled[i, j] = mean
    return R_filled


# PCA
def pca_process(R_filled, k=2):

    # STEP 1: Mean
    mean = np.mean(R_filled, axis=0)
    print("\nSTEP 1: MEAN\n", mean)

    # STEP 2: Centering
    X_centered = R_filled - mean
    print("\nSTEP 2: CENTERED DATA\n", X_centered)

    # STEP 3: Covariance
    n = R_filled.shape[0]
    cov = np.dot(X_centered.T, X_centered) / (n - 1)
    print("\nSTEP 3: COVARIANCE MATRIX\n", cov)

    # STEP 4 & 5: Eigenvalues & Eigenvectors
    eigenvalues, eigenvectors = np.linalg.eig(cov)
    print("\nSTEP 4: EIGENVALUES\n", eigenvalues)
    print("\nSTEP 5: EIGENVECTORS\n", eigenvectors)

    # STEP 6: Normalize
    for i in range(eigenvectors.shape[1]):
        eigenvectors[:, i] /= np.linalg.norm(eigenvectors[:, i])
    print("\nSTEP 6: UNIT EIGENVECTORS\n", eigenvectors)

    # STEP 7: Top-k
    idx = np.argsort(eigenvalues)[::-1]
    eigenvalues = eigenvalues[idx]
    eigenvectors = eigenvectors[:, idx]
    W = eigenvectors[:, :k]
    print("\nSTEP 7: TOP-K EIGENVECTORS\n", W)

    # STEP 8: Projection
    Z = np.dot(X_centered, W)
    print("\nSTEP 8: PCA COMPONENTS\n", Z)

    # Reconstruction
    R_recon = np.dot(Z, W.T) + mean
    print("\nPCA RECONSTRUCTION\n", R_recon)

    return Z, R_recon


# SVD
def svd_process(R_filled, k=2):

    RtR = np.dot(R_filled.T, R_filled)
    eigenvalues, V = np.linalg.eig(RtR)

    idx = np.argsort(eigenvalues)[::-1]
    eigenvalues = eigenvalues[idx]
    V = V[:, idx]

    singular_values = np.sqrt(np.abs(eigenvalues))

    U = []
    for i in range(len(singular_values)):
        if singular_values[i] > 1e-10:
            u = np.dot(R_filled, V[:, i]) / singular_values[i]
            U.append(u)

    U = np.array(U).T

    U_k = U[:, :k]
    S_k = np.diag(singular_values[:k])
    V_k = V[:, :k]

    R_recon = np.dot(np.dot(U_k, S_k), V_k.T)

    print("\nSingular Values\n", singular_values)
    print("\nSVD RECONSTRUCTION\n", R_recon)

    return R_recon


# RECOMMENDATION + ERROR
def recommend_dr(R_original, R_pred, user):
    missing = np.where(np.isnan(R_original[user]))[0]
    scores = R_pred[user][missing]
    return missing[np.argmax(scores)]


def compute_error(R_original, R_pred):
    mask = ~np.isnan(R_original)
    return np.sum((R_original[mask] - R_pred[mask]) ** 2)


# VISUALIZATION
def plot_matrix(matrix, title):
    plt.figure()
    plt.imshow(matrix)
    plt.title(title)
    plt.colorbar()
    plt.show()


def plot_latent(Z):
    plt.figure()
    for i in range(Z.shape[0]):
        plt.scatter(Z[i, 0], Z[i, 1])
        plt.text(Z[i, 0], Z[i, 1], f"U{i}")
    plt.title("Latent Space (PCA)")
    plt.xlabel("Component 1")
    plt.ylabel("Component 2")
    plt.show()


def plot_error(errors, labels):
    plt.figure()
    plt.bar(labels, errors)
    plt.title("Error Comparison")
    plt.show()


def main_dr():
    R = get_data()
    print("ORIGINAL MATRIX: ", R)

    R_filled = fill_item_mean(R)
    print("\nITEM MEAN FILLED MATRIX: ", R_filled)

    # PCA
    Z_pca, R_pca = pca_process(R_filled)

    # SVD
    R_svd = svd_process(R_filled)

    # Recommendation
    user = 0
    print("\nRECOMMENDATIONS")
    print("PCA:", recommend_dr(R, R_pca, user))
    print("SVD:", recommend_dr(R, R_svd, user))

    # Error
    print("\nERRORS")
    error_pca = compute_error(R, R_pca)
    error_svd = compute_error(R, R_svd)

    print("PCA Error:", error_pca)
    print("SVD Error:", error_svd)

    # Visualization
    plot_matrix(R_filled, "Filled Matrix")
    plot_matrix(R_pca, "PCA Reconstruction")
    plot_matrix(R_svd, "SVD Reconstruction")

    plot_latent(Z_pca)

    plot_error([error_pca, error_svd], ["PCA", "SVD"])


# ==========================================
# SECTION 3: RECOMMENDERS.PY
# ==========================================


# =========================
# DATA PREPARATION
def load_data_cf():
    data = [[5, 3, 4, "?"], [3, 1, 2, 3], [4, 3, 4, 5], [3, 3, 1, 5]]

    df = pd.DataFrame(
        data,
        index=["User1", "User2", "User3", "User4"],
        columns=["Item1", "Item2", "Item3", "Item4"],
    )

    df = df.mask(df == "?", np.nan).astype(float)
    return df


def mean_center(df):
    user_mean = df.mean(axis=1)
    df_centered = df.sub(user_mean, axis=0)
    return user_mean, df_centered


# SIMILARITY FUNCTION
def cosine_similarity_cf(a, b):
    mask = ~np.isnan(a) & ~np.isnan(b)

    if np.sum(mask) == 0:
        return 0

    a, b = a[mask], b[mask]
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))


# USER-BASED CF
def predict_user_based(df, user, item):
    target_vector = df.loc[user].values
    similarities = {}

    for other_user in df.index:
        if other_user == user:
            continue

        if not np.isnan(df.loc[other_user, item]):
            sim = cosine_similarity_cf(target_vector, df.loc[other_user].values)
            similarities[other_user] = sim

    num, den = 0, 0
    for u, sim in similarities.items():
        num += sim * df.loc[u, item]
        den += abs(sim)

    return np.nan if den == 0 else num / den


def predict_user_based_mean_centered(df, user, item):
    user_mean = df.mean(axis=1)
    target_vector = df.loc[user].values
    similarities = {}

    for other_user in df.index:
        if other_user == user:
            continue

        if not np.isnan(df.loc[other_user, item]):
            sim = cosine_similarity_cf(target_vector, df.loc[other_user].values)
            similarities[other_user] = sim

    num, den = 0, 0

    for u, sim in similarities.items():
        ru_i = df.loc[u, item]
        ru_mean = user_mean[u]

        num += sim * (ru_i - ru_mean)
        den += abs(sim)

    if den == 0:
        return user_mean[user]

    return user_mean[user] + (num / den)


def predict_user_based_topk(df, user, item, k=2):
    target_vector = df.loc[user].values
    similarities = []

    for other_user in df.index:
        if other_user == user:
            continue

        if not np.isnan(df.loc[other_user, item]):
            sim = cosine_similarity_cf(target_vector, df.loc[other_user].values)
            similarities.append((other_user, sim))

    # Sort by similarity (descending)
    similarities.sort(key=lambda x: x[1], reverse=True)

    # Select Top-K
    top_k = similarities[:k]

    num, den = 0, 0
    for u, sim in top_k:
        num += sim * df.loc[u, item]
        den += abs(sim)

    return np.nan if den == 0 else num / den


# ITEM-BASED CF
def predict_item_based(df, user, item):
    target_vector = df[item].values
    similarities = {}

    for other_item in df.columns:
        if other_item == item:
            continue

        if not np.isnan(df.loc[user, other_item]):
            sim = cosine_similarity_cf(target_vector, df[other_item].values)
            similarities[other_item] = sim

    num, den = 0, 0

    for i, sim in similarities.items():
        num += sim * df.loc[user, i]
        den += abs(sim)

    return np.nan if den == 0 else num / den


def predict_item_based_topk(df, user, item, k=2):
    target_vector = df[item].values
    similarities = []

    for other_item in df.columns:
        if other_item == item:
            continue

        if not np.isnan(df.loc[user, other_item]):
            sim = cosine_similarity_cf(target_vector, df[other_item].values)
            similarities.append((other_item, sim))

    # Sort & select Top-K
    similarities.sort(key=lambda x: x[1], reverse=True)
    top_k = similarities[:k]

    num, den = 0, 0
    for i, sim in top_k:
        num += sim * df.loc[user, i]
        den += abs(sim)

    return np.nan if den == 0 else num / den


# EVALUATION
def evaluate(df):
    actuals, preds = [], []

    for u in df.index:
        for i in df.columns:
            if not np.isnan(df.loc[u, i]):

                temp = df.copy()
                actual = temp.loc[u, i]
                temp.loc[u, i] = np.nan

                p = predict_user_based(temp, u, i)

                if not np.isnan(p):
                    actuals.append(actual)
                    preds.append(p)

    actuals = np.array(actuals)
    preds = np.array(preds)

    rmse = np.sqrt(np.mean((actuals - preds) ** 2))
    mae = np.mean(np.abs(actuals - preds))

    return rmse, mae


# VISUALIZATION
def plot_matrix_cf(matrix, title):
    plt.figure()
    plt.imshow(matrix, aspect="auto")
    plt.title(title)
    plt.colorbar()
    plt.show()


# MAIN EXECUTION
def main_recommenders():
    df = load_data_cf()

    # Mean Centering
    user_mean, df_centered = mean_center(df)
    print("\nMean Centered Matrix:\n", df_centered)

    """
    for u in df.index:
        for i in df.columns:
            if np.isnan(df.loc[u, i]):
                user_pred_matrix.loc[u, i] = predict_user_based(df, u, i)
                item_pred_matrix.loc[u, i] = predict_item_based(df, u, i)

    print("\nUser-Based Prediction Matrix:\n", user_pred_matrix)
    print("\nItem-Based Prediction Matrix:\n", item_pred_matrix)
    """

    # Prediction
    user = "User1"
    item = "Item4"

    user_pred = predict_user_based(df, user, item)
    item_pred = predict_item_based(df, user, item)

    print("\nUser-Based Prediction:\n", user_pred)
    print("\nItem-Based Prediction:\n", item_pred)

    # Evaluation
    rmse, mae = evaluate(df)
    print("\nEvaluation Metrics:")
    print("RMSE =", rmse)
    print("MAE =", mae)

    # Fill NaNs for visualization
    user_pred_matrix = df.copy().fillna(df.mean().mean())
    item_pred_matrix = df.copy().fillna(df.mean().mean())

    # Plots
    plot_matrix_cf(df.fillna(0), "Original Matrix")
    plot_matrix_cf(df_centered.fillna(0), "Mean Centered Matrix")
    plot_matrix_cf(user_pred_matrix, "User-Based Predicted Matrix")
    plot_matrix_cf(item_pred_matrix, "Item-Based Predicted Matrix")


# ==========================================
# SECTION 4: PAGERANK.PY
# ==========================================

import math


# GRAPH CREATION
def create_graph(matrix, nodes):
    graph = {}
    for i in range(len(matrix)):
        graph[nodes[i]] = []
        for j in range(len(matrix[i])):
            if matrix[i][j] == 1:
                graph[nodes[i]].append(nodes[j])
    return graph


def edges_to_graph(edges):
    graph = {}
    for src, dst in edges:
        if src not in graph:
            graph[src] = []
        if dst not in graph:
            graph[dst] = []
        graph[src].append(dst)
    return graph


# GRAPH ANALYSIS
def count_outgoing_links(graph):
    return {page: len(graph[page]) for page in graph}


def store_incoming_links(graph):
    incoming_links = {page: [] for page in graph}
    incoming_count = {page: 0 for page in graph}

    for page in graph:
        for link in graph[page]:
            incoming_links[link].append(page)
            incoming_count[link] += 1

    return incoming_links, incoming_count


# PAGERANK ALGORITHM
def calculate_pagerank(graph, incoming_links, outgoing_count):
    d = 0.85
    iterations = 10
    N = len(graph)

    pr = {page: 1 / N for page in graph}

    print("\nPageRank Algorithm:")

    for i in range(iterations):
        new_pr = {}
        for page in graph:
            rank_sum = 0
            for incoming in incoming_links[page]:
                rank_sum += pr[incoming] / outgoing_count[incoming]

            new_pr[page] = (1 - d) / N + d * rank_sum

        pr = new_pr
        print(f"\nIteration {i+1}: {pr}")

    return pr


# HITS ALGORITHM
def initialize_scores(graph):
    authority = {node: 1.0 for node in graph}
    hub = {node: 1.0 for node in graph}
    return authority, hub


def normalize_sum(scores):
    total = sum(scores.values())
    return {k: round(v / total, 4) for k, v in scores.items()}


def hits_algorithm(graph, iterations=5):
    incoming_links, _ = store_incoming_links(graph)
    authority, hub = initialize_scores(graph)

    print("\nHITS Algorithm:")

    for i in range(iterations):

        # Authority update
        new_authority = {}
        for node in graph:
            new_authority[node] = sum(
                hub.get(in_node, 0) for in_node in incoming_links.get(node, [])
            )

        # Hub update
        new_hub = {}
        for node in graph:
            new_hub[node] = sum(
                new_authority.get(out_node, 0) for out_node in graph[node]
            )

        authority = normalize_sum(new_authority)
        hub = normalize_sum(new_hub)

        print(f"\nIteration {i+1}")
        print("Authority:", authority)
        print("Hub:", hub)

    return authority, hub


# EQUATION METHOD
def build_matrix(graph):
    nodes = list(graph.keys())
    n = len(nodes)
    index = {node: i for i, node in enumerate(nodes)}

    incoming, _ = store_incoming_links(graph)
    outdeg = {node: len(graph[node]) for node in graph}

    A = np.zeros((n, n))

    for i, node in enumerate(nodes):
        for inc in incoming[node]:
            j = index[inc]
            A[i][j] = 1 / outdeg[inc]

    return A, nodes


def solve_ranks(A, iterations=20):
    n = len(A)
    r = np.ones(n) / n

    history = []

    for _ in range(iterations):
        r = A @ r
        r = r / np.sum(r)
        history.append(r.copy())

    return r, history


def print_equations(graph):
    incoming, _ = store_incoming_links(graph)
    outdeg = {node: len(graph[node]) for node in graph}

    print("\nEquations:\n")
    for node in graph:
        eq = f"r_{node} = "
        terms = [f"(r_{inc}/{outdeg[inc]})" for inc in incoming[node]]
        eq += " + ".join(terms) if terms else "0"
        print(eq)


# VISUALIZATION
def visualize_graph(graph):
    nodes = list(graph.keys())
    n = len(nodes)

    positions = {}
    for i, node in enumerate(nodes):
        angle = 2 * math.pi * i / n
        positions[node] = (math.cos(angle), math.sin(angle))

    plt.figure()

    for node, (x, y) in positions.items():
        plt.scatter(x, y)
        plt.text(x, y, node, ha="center", va="center")

    for src in graph:
        for dst in graph[src]:
            x1, y1 = positions[src]
            x2, y2 = positions[dst]
            plt.arrow(x1, y1, x2 - x1, y2 - y1, head_width=0.05)

    plt.title("Web Graph")
    plt.axis("off")
    plt.show()


def visualize_pagerank(pr):
    plt.figure()
    plt.bar(list(pr.keys()), list(pr.values()))
    plt.title("PageRank Values")
    plt.show()


def plot_convergence(history, nodes):
    for i, node in enumerate(nodes):
        plt.plot([h[i] for h in history], label=node)

    plt.title("Rank Convergence")
    plt.legend()
    plt.show()


def plot_scores(authority, hub):
    nodes = list(authority.keys())

    plt.figure()
    plt.bar(nodes, authority.values())
    plt.title("Authority Scores")
    plt.show()

    plt.figure()
    plt.bar(nodes, hub.values())
    plt.title("Hub Scores")
    plt.show()


# INFERENCE
def infer_pagerank(pr):
    print("\nFinal PageRank:")
    sorted_pr = sorted(pr.items(), key=lambda x: x[1], reverse=True)
    for node, val in sorted_pr:
        print(f"{node}: {val:.4f}")

    print(f"\nMost important: {sorted_pr[0][0]}")
    print(f"Least important: {sorted_pr[-1][0]}")


def infer_hits(authority, hub):
    sorted_auth = sorted(authority.items(), key=lambda x: x[1], reverse=True)
    sorted_hub = sorted(hub.items(), key=lambda x: x[1], reverse=True)

    print("\nBest Authority:", sorted_auth[0][0])
    print("Best Hub:", sorted_hub[0][0])


def infer_ranks(ranks, nodes):
    rank_dict = dict(zip(nodes, ranks))
    sorted_nodes = sorted(rank_dict.items(), key=lambda x: x[1], reverse=True)

    print("\nEquation Method Ranking:")
    for node, val in sorted_nodes:
        print(f"{node}: {val:.4f}")


def main_pagerank():
    edges = [("A", "B"), ("A", "C"), ("B", "C"), ("C", "A"), ("D", "C")]

    graph = edges_to_graph(edges)

    print("Graph:", graph)

    # Analysis
    outgoing_count = count_outgoing_links(graph)
    incoming_links, incoming_count = store_incoming_links(graph)

    print("\nCount of Outgoing Links:", outgoing_count)
    print("Count of Incoming Links:", incoming_count)
    print("Incoming Links:", incoming_links)

    # PageRank
    pr = calculate_pagerank(graph, incoming_links, outgoing_count)

    # HITS
    authority, hub = hits_algorithm(graph)

    # Equation Method
    print_equations(graph)
    A, nodes = build_matrix(graph)
    ranks, history = solve_ranks(A)

    # Inference
    infer_pagerank(pr)
    infer_hits(authority, hub)
    infer_ranks(ranks, nodes)

    # Visualization
    visualize_graph(graph)
    visualize_pagerank(pr)
    plot_scores(authority, hub)
    plot_convergence(history, nodes)
