"""Static catalog of ML lab programs."""

from __future__ import annotations

from dataclasses import dataclass
from textwrap import dedent
from typing import List


@dataclass(frozen=True)
class LabProgram:
    """Simple container for a lab script."""

    title: str
    summary: str
    content: str


def _clean(text: str) -> str:
    return dedent(text).strip()


_LAB_PROGRAMS: List[LabProgram] = [
    LabProgram(
        title="Lab 1 – Basics",
        summary="Intro to pandas DataFrames and simple matplotlib circles plot.",
        content=_clean(
            '''
            # -*- coding: utf-8 -*-

            LAB-1 (Basics)

            """Sample Basics .ipynb

            Automatically generated by Colab.

            Original file is located at

                https://colab.research.google.com/drive/1BYi4dzglrMXqfmSWR3WWpIH-wxAICcUC

            """

            import pandas as pd

            data = {

              "calories": [420, 380, 390],

              "duration": [50, 40, 45]

            }

            #load data into a DataFrame object:

            df = pd.DataFrame(data)

            print(df)

            #refer to the row index:

            print(df.loc[0])

            #use a list of indexes:

            print(df.loc[[0, 1]])

            #Add a list of names to give each row a name:

            import pandas as pd

            data = {

              "calories": [420, 380, 390],

              "duration": [50, 40, 45]

            }

            df = pd.DataFrame(data, index = ["day1", "day2", "day3"])

            print(df)

            # Load a comma separated file (CSV file) into a DataFrame:

            # download csv file from google .      https://www.datablist.com/learn/csv/download-sample-csv-files

            import pandas as pd

            df = pd.read_csv('/content/sample_data/customers-100.csv')

            print(df)

            # use to_string() to print the entire DataFrame.

            import pandas as pd

            df = pd.read_csv('/content/sample_data/customers-100.csv')

            print(df)

            # print(df.to_string())

            # Return a new Data Frame with no empty cells:

            import pandas as pd

            df = pd.read_csv('data.csv')

            new_df = df.dropna()

            print(new_df.to_string())

            # Import necessary libraries

            from sklearn.datasets import make_circles

            import matplotlib.pyplot as plt

            # Generate 2d classification dataset

            X, y = make_circles(n_samples=200, shuffle=True,

                                noise=0.1, random_state=42)

            # Plot the generated datasets

            plt.scatter(X[:, 0], X[:, 1], c=y)

            plt.show()
            '''
        ),
    ),
    LabProgram(
        title="Lab 2 – Decision Tree (Play Tennis)",
        summary="Implements a decision tree classifier on the Play Tennis dataset using scikit-learn.",
        content=_clean(
            '''
            # Load libraries
            import numpy as np
            import pandas as pd
            from sklearn import metrics

            # Importing the Dataset
            df = pd.read_csv("/content/Play Tennis.csv")
            value = ['Outlook', 'Temperature', 'Humidity', 'Wind']
            df

            # Data Analysis
            len(df)
            df.shape
            df.head()
            df.tail()
            df.describe()

            # Preparing the Data (Data Slicing)
            from sklearn import preprocessing
            df = df.apply(preprocessing.LabelEncoder().fit_transform)
            df

            feature_cols = ['Outlook', 'Temprature', 'Humidity', 'Wind']
            X = df[feature_cols]
            y = df.Play_Tennis
            df

            # To divide our data into training and test sets:
            from sklearn.model_selection import train_test_split
            X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.20)

            # Training and Making Predictions
            # perform training
            from sklearn.tree import DecisionTreeClassifier
            classifier = DecisionTreeClassifier(criterion="entropy", random_state=100)
            classifier.fit(X_train, y_train)

            # Predict the response for test dataset
            y_pred = classifier.predict(X_test)
            print(y_pred)
            print(X_test)

            # Model Accuracy, how often is the classifier correct?
            from sklearn.metrics import accuracy_score
            print("Accuracy:", metrics.accuracy_score(y_test, y_pred))

            # Now let's compare some of our predicted values with the actual values and see how accurate we were:
            data_p = pd.DataFrame({'Actual': y_test, 'Predicted': y_pred})
            data_p

            # Evaluating the Algorithm
            from sklearn.metrics import classification_report, confusion_matrix
            print(confusion_matrix(y_test, y_pred))
            print(classification_report(y_test, y_pred))

            # Visualizing Decision Trees
            from sklearn.tree import export_graphviz
            from io import StringIO
            from IPython.display import Image
            import pydotplus

            dot_data = StringIO()
            export_graphviz(classifier, out_file=dot_data, feature_names=feature_cols, class_names=['0', '1'])
            graph = pydotplus.graph_from_dot_data(dot_data.getvalue())
            graph.write_png('Play Tennis.png')
            Image(graph.create_png())
            '''
        ),
    ),
    LabProgram(
        title="PCA Extra Lab",
        summary="Dimensionality reduction on Iris dataset with PCA and matplotlib visualization.",
        content=_clean(
            '''
            # Principal Component Analysis (PCA) for reducing the dimensionality of the Iris dataset from 4 features to 2.
            import numpy as np
            import pandas as pd
            from sklearn.datasets import load_iris
            from sklearn.decomposition import PCA
            import matplotlib.pyplot as plt

            # Load the Iris dataset
            iris = load_iris()
            data = iris.data
            labels = iris.target
            label_names = iris.target_names

            # Convert to a DataFrame for better visualization
            iris_df = pd.DataFrame(data, columns=iris.feature_names)

            # Perform PCA to reduce dimensionality to 2
            pca = PCA(n_components=2)
            data_reduced = pca.fit_transform(data)

            # Create a DataFrame for the reduced data
            reduced_df = pd.DataFrame(data_reduced, columns=['Principal Component 1', 'Principal Component 2'])
            reduced_df['Label'] = labels

            # Plot the reduced data
            plt.figure(figsize=(8, 6))
            colors = ['r', 'g', 'b']
            for i, label in enumerate(np.unique(labels)):
                plt.scatter(
                    reduced_df[reduced_df['Label'] == label]['Principal Component 1'],
                    reduced_df[reduced_df['Label'] == label]['Principal Component 2'],
                    label=label_names[label],
                    color=colors[i]
                )
            plt.title('PCA on Iris Dataset')
            plt.xlabel('Principal Component 1')
            plt.ylabel('Principal Component 2')
            plt.legend()
            plt.grid()
            plt.show()
            '''
        ),
    ),
    LabProgram(
        title="Expectation Maximization Lab",
        summary="Compares KMeans and Gaussian Mixture clustering on Iris features.",
        content=_clean(
            '''
            # EM Algorithm
            import numpy as np
            import pandas as pd
            import matplotlib.pyplot as plt
            from sklearn.cluster import KMeans
            from sklearn.mixture import GaussianMixture
            from sklearn.preprocessing import StandardScaler
            from sklearn import datasets

            # Load the Diabetes dataset from sklearn
            iris = datasets.load_iris()
            df = pd.DataFrame(data=iris.data, columns=iris.feature_names)

            # Select the first two features for clustering
            X = df.iloc[:, [0, 1]].values

            # Normalize the dataset
            scaler = StandardScaler()
            X_scaled = scaler.fit_transform(X)

            # Apply k-Means clustering
            kmeans = KMeans(n_clusters=3, random_state=42, n_init=10)
            kmeans_labels = kmeans.fit_predict(X_scaled)

            # Apply Expectation-Maximization (EM) using Gaussian Mixture Model
            gmm = GaussianMixture(n_components=3, random_state=42)
            gmm_labels = gmm.fit_predict(X_scaled)

            # Visualizing k-Means clusters
            plt.figure(figsize=(12, 5))
            plt.subplot(1, 2, 1)
            plt.scatter(X_scaled[:, 0], X_scaled[:, 1], c=kmeans_labels, cmap='viridis', edgecolors='k')
            plt.scatter(kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1], s=200, marker='X', c='red')
            plt.title("k-Means Clustering")

            # Visualizing EM (GMM) clusters
            plt.subplot(1, 2, 2)
            plt.scatter(X_scaled[:, 0], X_scaled[:, 1], c=gmm_labels, cmap='plasma', edgecolors='k')
            plt.title("EM Algorithm (GMM) Clustering")
            plt.show()
            '''
        ),
    ),
    LabProgram(
        title="Bayesian Network Lab",
        summary="Constructs and queries a Burglary Alarm Bayesian network using pgmpy.",
        content=_clean(
            '''
            # Install pgmpy if not installed
            # !pip install pgmpy

            from pgmpy.models import DiscreteBayesianNetwork
            from pgmpy.factors.discrete import TabularCPD
            from pgmpy.inference import VariableElimination

            # Define the structure of the Bayesian Network
            model = DiscreteBayesianNetwork([
                ('Burglary', 'Alarm'),
                ('Earthquake', 'Alarm'),
                ('Alarm', 'JohnCalls'),
                ('Alarm', 'MaryCalls')
            ])

            # Define Conditional Probability Distributions (CPDs)
            cpd_burglary = TabularCPD(variable='Burglary', variable_card=2, values=[[0.001], [0.999]])
            cpd_earthquake = TabularCPD(variable='Earthquake', variable_card=2, values=[[0.002], [0.998]])
            cpd_alarm = TabularCPD(variable='Alarm', variable_card=2,
                                   values=[
                                       [0.95, 0.94, 0.29, 0.001],
                                       [0.05, 0.06, 0.71, 0.999]
                                   ],
                                   evidence=['Burglary', 'Earthquake'],
                                   evidence_card=[2, 2])
            cpd_john_calls = TabularCPD(variable='JohnCalls', variable_card=2,
                                        values=[
                                            [0.90, 0.05],
                                            [0.10, 0.95]
                                        ],
                                        evidence=['Alarm'],
                                        evidence_card=[2])
            cpd_mary_calls = TabularCPD(variable='MaryCalls', variable_card=2,
                                        values=[
                                            [0.70, 0.01],
                                            [0.30, 0.99]
                                        ],
                                        evidence=['Alarm'],
                                        evidence_card=[2])

            # Add CPDs to the model
            model.add_cpds(cpd_burglary, cpd_earthquake, cpd_alarm, cpd_john_calls, cpd_mary_calls)

            # Validate the model
            assert model.check_model()

            # Perform Inference
            inference = VariableElimination(model)

            # Example Query: Probability that a Burglary happened given JohnCalls and MaryCalls
            query_result = inference.query(variables=['Burglary'], evidence={'JohnCalls': 1, 'MaryCalls': 1})
            print(query_result)

            import networkx as nx
            import matplotlib.pyplot as plt
            from pgmpy.models import DiscreteBayesianNetwork

            # Define the Bayesian Network structure
            model = DiscreteBayesianNetwork([
                ('Burglary', 'Alarm'),
                ('Earthquake', 'Alarm'),
                ('Alarm', 'JohnCalls'),
                ('Alarm', 'MaryCalls')
            ])

            # Create a directed graph
            G = nx.DiGraph()
            G.add_edges_from(model.edges())

            # Draw the graph
            plt.figure(figsize=(8, 6))
            pos = nx.spring_layout(G, seed=42)
            nx.draw(G, pos, with_labels=True, node_size=3000, node_color="lightblue", edge_color="black", font_size=12, font_weight="bold")
            plt.title("Bayesian Network Structure")
            plt.show()
            '''
        ),
    ),
    LabProgram(
        title="KNN on Iris",
        summary="K-nearest neighbors classifier with scaling, evaluation, and CV on Iris data.",
        content=_clean(
            '''
            """
            K-Nearest Neighbors (KNN) on the Iris dataset.
            Save as knn_iris.py and run: python knn_iris.py
            """
            import warnings
            warnings.filterwarnings("ignore")

            import pandas as pd
            import numpy as np
            import matplotlib.pyplot as plt
            import seaborn as sns
            from sklearn.preprocessing import LabelEncoder, StandardScaler
            from sklearn.model_selection import train_test_split, cross_val_score
            from sklearn.neighbors import KNeighborsClassifier
            from sklearn.metrics import classification_report, confusion_matrix, accuracy_score

            # ---------- CONFIG ----------
            DATA_PATH = "/kaggle/input/iris-uci/iris/iris.data"
            RANDOM_STATE = 42
            TEST_SIZE = 0.3
            K_NEIGHBORS = 5
            CV_FOLDS = 5

            # Load dataset
            column_names = ['sepal_length', 'sepal_width', 'petal_length', 'petal_width', 'class']
            df = pd.read_csv(DATA_PATH, header=None, names=column_names)

            print("Dataset shape:", df.shape)
            print(df['class'].value_counts())

            # Clean class labels
            df['class'] = df['class'].str.replace("Iris-", "", regex=False)

            # Encode target
            le = LabelEncoder()
            df['class'] = le.fit_transform(df['class'])

            # Split features/target
            X = df.drop('class', axis=1)
            y = df['class']

            # Train/test split
            X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=TEST_SIZE,
                                                                random_state=RANDOM_STATE, stratify=y)

            # Scale features
            scaler = StandardScaler()
            X_train_scaled = scaler.fit_transform(X_train)
            X_test_scaled = scaler.transform(X_test)

            # Initialize and train KNN
            knn = KNeighborsClassifier(n_neighbors=K_NEIGHBORS)
            knn.fit(X_train_scaled, y_train)

            # Predict and evaluate
            y_pred = knn.predict(X_test_scaled)
            print("\nKNN Test Accuracy:", accuracy_score(y_test, y_pred))
            print("\nClassification Report:\n", classification_report(y_test, y_pred, target_names=le.classes_))

            # Confusion matrix plot
            cm = confusion_matrix(y_test, y_pred)
            sns.heatmap(cm, annot=True, fmt="d", xticklabels=le.classes_, yticklabels=le.classes_)
            plt.title(f"KNN (k={K_NEIGHBORS}) - Confusion Matrix")
            plt.xlabel("Predicted")
            plt.ylabel("Actual")
            plt.show()

            # Cross-validation
            X_scaled_all = scaler.fit_transform(X)
            cv_scores = cross_val_score(knn, X_scaled_all, y, cv=CV_FOLDS)
            print(f"\n{CV_FOLDS}-fold cross-validation scores: {cv_scores}")
            print("Average CV score: {:.4f}".format(np.mean(cv_scores)))
            '''
        ),
    ),
    LabProgram(
        title="SVM on Iris",
        summary="Support Vector Machine workflow with scaling, evaluation and CV.",
        content=_clean(
            '''
            """
            Support Vector Machine (SVM) on the Iris dataset.
            Save as svm_iris.py and run: python svm_iris.py
            """
            import warnings
            warnings.filterwarnings("ignore")

            import pandas as pd
            import numpy as np
            import matplotlib.pyplot as plt
            import seaborn as sns
            from sklearn.preprocessing import LabelEncoder, StandardScaler
            from sklearn.model_selection import train_test_split, cross_val_score
            from sklearn.svm import SVC
            from sklearn.metrics import classification_report, confusion_matrix, accuracy_score

            # ---------- CONFIG ----------
            DATA_PATH = "/kaggle/input/iris-uci/iris/iris.data"
            RANDOM_STATE = 42
            TEST_SIZE = 0.3
            SVM_KERNEL = 'rbf'
            C_PARAM = 1.0
            GAMMA = 'scale'
            CV_FOLDS = 5

            # Load dataset
            column_names = ['sepal_length', 'sepal_width', 'petal_length', 'petal_width', 'class']
            df = pd.read_csv(DATA_PATH, header=None, names=column_names)

            print("Dataset shape:", df.shape)
            print(df['class'].value_counts())

            # Clean class labels
            df['class'] = df['class'].str.replace("Iris-", "", regex=False)

            # Encode target
            le = LabelEncoder()
            df['class'] = le.fit_transform(df['class'])

            # Split features/target
            X = df.drop('class', axis=1)
            y = df['class']

            # Train/test split
            X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=TEST_SIZE,
                                                                random_state=RANDOM_STATE, stratify=y)

            # Scale features
            scaler = StandardScaler()
            X_train_scaled = scaler.fit_transform(X_train)
            X_test_scaled = scaler.transform(X_test)

            # Initialize and train SVM
            svm = SVC(kernel=SVM_KERNEL, C=C_PARAM, gamma=GAMMA, random_state=RANDOM_STATE)
            svm.fit(X_train_scaled, y_train)

            # Predict and evaluate
            y_pred = svm.predict(X_test_scaled)
            print("\nSVM Test Accuracy:", accuracy_score(y_test, y_pred))
            print("\nClassification Report:\n", classification_report(y_test, y_pred, target_names=le.classes_))

            # Confusion matrix plot
            cm = confusion_matrix(y_test, y_pred)
            sns.heatmap(cm, annot=True, fmt="d", xticklabels=le.classes_, yticklabels=le.classes_)
            plt.title(f"SVM (kernel={SVM_KERNEL}) - Confusion Matrix")
            plt.xlabel("Predicted")
            plt.ylabel("Actual")
            plt.show()

            # Cross-validation
            X_scaled_all = scaler.fit_transform(X)
            cv_scores = cross_val_score(svm, X_scaled_all, y, cv=CV_FOLDS)
            print(f"\n{CV_FOLDS}-fold cross-validation scores: {cv_scores}")
            print("Average CV score: {:.4f}".format(np.mean(cv_scores)))
            '''
        ),
    ),
    LabProgram(
        title="Random Forest Lab",
        summary="RandomForest training, evaluation, and heatmap visualization on Iris CSV.",
        content=_clean(
            '''
            import pandas as pd
            from sklearn.model_selection import train_test_split
            from sklearn.ensemble import RandomForestClassifier
            from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
            import seaborn as sns
            import matplotlib.pyplot as plt

            # Load dataset
            data = pd.read_csv("/content/iris.csv")
            print(data.head())

            # Features and target
            X = data.drop("variety", axis=1)
            y = data["variety"]

            # Encode labels
            y = y.astype('category').cat.codes

            # Train-test split
            X_train, X_test, y_train, y_test = train_test_split(
                X, y, test_size=0.3, random_state=42
            )

            # Random Forest Model
            model = RandomForestClassifier(
                n_estimators=100,
                random_state=42
            )
            model.fit(X_train, y_train)

            # Predictions
            pred = model.predict(X_test)

            # Accuracy
            print("\nAccuracy:", accuracy_score(y_test, pred))

            # Classification report
            print("\nClassification Report:\n")
            print(classification_report(y_test, pred))

            # Confusion Matrix
            cm = confusion_matrix(y_test, pred)
            plt.figure(figsize=(6,4))
            sns.heatmap(cm, annot=True, cmap="Purples", fmt='d')
            plt.title("Confusion Matrix - Random Forest")
            plt.xlabel("Predicted")
            plt.ylabel("Actual")
            plt.show()
            '''
        ),
    ),
    LabProgram(
        title="Bagging and Pasting Lab",
        summary="Demonstrates individual decision tree vs BaggingClassifier ensemble on Iris.",
        content=_clean(
            '''
            from sklearn.datasets import load_iris
            from sklearn.tree import DecisionTreeClassifier
            from sklearn.ensemble import BaggingClassifier, VotingClassifier
            from sklearn.model_selection import train_test_split
            from sklearn.metrics import accuracy_score
            from sklearn.svm import SVC
            from sklearn.neighbors import KNeighborsClassifier
            import numpy as np

            # Load dataset
            X, y = load_iris(return_X_y=True)
            X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.5, random_state=42)

            # Base estimator: single decision tree with some randomness
            base_clf = DecisionTreeClassifier(max_features=0.8, random_state=42)

            # 1. Individual classifier
            base_clf.fit(X_train, y_train)
            y_pred_base = base_clf.predict(X_test)
            acc_base = accuracy_score(y_test, y_pred_base)

            # 2. Bagging Classifier (with replacement)
            bagging_clf = BaggingClassifier(
                estimator=base_clf,
                n_estimators=10,
                max_samples=0.8,
                bootstrap=True,
                oob_score=True,
                random_state=42
            )
            bagging_clf.fit(X_train, y_train)
            y_pred_bag = bagging_clf.predict(X_test)
            acc_bag = accuracy_score(y_test, y_pred_bag)
            print("Bagging Accuracy:", accuracy_score(y_test, y_pred_bag))
            print("Out-of-Bag Score:", bagging_clf.oob_score_)
            '''
        ),
    ),
    LabProgram(
        title="AdaBoost Lab (ROC Included)",
        summary="AdaBoost on synthetic data with ROC/AUC plotting.",
        content=_clean(
            '''
            # Importing required libraries
            from sklearn.model_selection import train_test_split
            from sklearn.ensemble import AdaBoostClassifier
            from sklearn.tree import DecisionTreeClassifier
            from sklearn.datasets import make_classification
            from sklearn.metrics import accuracy_score

            # Generating a synthetic dataset
            X, y = make_classification(n_samples=1000, n_features=20, n_classes=2, random_state=42)

            # Splitting into train/test
            X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

            # Base estimator (weak learner)
            base_estimator = DecisionTreeClassifier(max_depth=1)

            # Updated AdaBoostClassifier (use estimator= instead of base_estimator=)
            ada_boost = AdaBoostClassifier(
                estimator=base_estimator,
                n_estimators=50,
                random_state=42
            )

            # Train
            ada_boost.fit(X_train, y_train)

            # Predict
            y_pred = ada_boost.predict(X_test)

            # Accuracy
            print("AdaBoost accuracy:", accuracy_score(y_test, y_pred))

            import matplotlib.pyplot as plt
            from sklearn.metrics import roc_curve, roc_auc_score

            # Get predicted probabilities for the positive class
            y_proba = ada_boost.predict_proba(X_test)[:, 1]

            # Calculate ROC curve
            fpr, tpr, thresholds = roc_curve(y_test, y_proba)

            # Calculate AUC score
            auc_score = roc_auc_score(y_test, y_proba)

            # Plot the ROC curve
            plt.figure(figsize=(8, 6))
            plt.plot(fpr, tpr, color='blue', label=f'ROC Curve (AUC = {auc_score:.2f})')
            plt.plot([0, 1], [0, 1], color='red', linestyle='--', label='Random Classifier')
            plt.xlabel('False Positive Rate')
            plt.ylabel('True Positive Rate')
            plt.title('Receiver Operating Characteristic (ROC) Curve for AdaBoost')
            plt.legend()
            plt.grid(True)
            plt.show()
            '''
        ),
    ),
    LabProgram(
        title="Tic Tac Toe Q-Learning Lab",
        summary="Q-learning agent that learns to play Tic Tac Toe versus a random opponent.",
        content=_clean(
            '''
            import random



            from collections import defaultdict

            # ---------- Environment ---------- #

            class TicTacToe:

                def __init__(self):

                    # Board: 1 = X, -1 = O, 0 = empty

                    self.board = [0] * 9

                    self.current_player = 1  # X starts

                def reset(self):

                    self.board = [0] * 9

                    self.current_player = 1

                    return self.get_state()

                def get_state(self):

                    # Represent state as a tuple (immutable, hashable)

                    return tuple(self.board)

                def available_actions(self):

                    return [i for i, v in enumerate(self.board) if v == 0]

                def step(self, action, player):

                    """

                    Take an action for 'player' (1 or -1).

                    Returns: next_state, reward, done, info

                    reward is from *that player's* perspective: +1 win, 0 otherwise.

                    """

                    if self.board[action] != 0:

                        # Illegal move: big negative reward, end episode

                        return self.get_state(), -10, True, {"illegal": True}

                    self.board[action] = player

                    winner = self.check_winner()

                    if winner == player:

                        return self.get_state(), 1, True, {"winner": player}

                    if self.is_draw():

                        return self.get_state(), 0, True, {"draw": True}

                    # Game continues

                    return self.get_state(), 0, False, {}

                def check_winner(self):

                    b = self.board

                    lines = [

                        (0, 1, 2), (3, 4, 5), (6, 7, 8),  # rows

                        (0, 3, 6), (1, 4, 7), (2, 5, 8),  # cols

                        (0, 4, 8), (2, 4, 6)              # diagonals

                    ]

                    for i, j, k in lines:

                        if b[i] != 0 and b[i] == b[j] == b[k]:

                            return b[i]

                    return None

                def is_draw(self):

                    return all(v != 0 for v in self.board) and self.check_winner() is None

                def render(self):

                    symbols = {1: 'X', -1: 'O', 0: ' '}

                    for i in range(3):

                        row = [symbols[self.board[3 * i + j]] for j in range(3)]

                        print(" | ".join(row))

                        if i < 2:

                            print("--+---+--")

                    print()

            # ---------- Q-learning Agent ---------- #

            class QLearningAgent:

                def __init__(self, alpha=0.1, gamma=0.99, epsilon=1.0, epsilon_min=0.05, epsilon_decay=0.999):

                    self.q = defaultdict(float)  # Q[(state, action)] = value

                    self.alpha = alpha

                    self.gamma = gamma

                    self.epsilon = epsilon

                    self.epsilon_min = epsilon_min

                    self.epsilon_decay = epsilon_decay

                def get_q(self, state, action):

                    return self.q[(state, action)]

                def choose_action(self, state, available_actions):

                    # epsilon-greedy

                    if random.random() < self.epsilon:

                        return random.choice(available_actions)

                    # Greedy

                    q_values = [self.get_q(state, a) for a in available_actions]

                    max_q = max(q_values)

                    # tie-break randomly among best

                    best_actions = [a for a, q in zip(available_actions, q_values) if q == max_q]

                    return random.choice(best_actions)

                def update(self, state, action, reward, next_state, next_actions, done):

                    old_q = self.get_q(state, action)

                    if done:

                        target = reward

                    else:

                        # max_a' Q(s', a')

                        next_qs = [self.get_q(next_state, a) for a in next_actions]

                        target = reward + self.gamma * max(next_qs, default=0.0)

                    self.q[(state, action)] = old_q + self.alpha * (target - old_q)

                def decay_epsilon(self):

                    self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)

            # ---------- Training Loop ---------- #

            def train(num_episodes=50000):

                env = TicTacToe()

                agent = QLearningAgent()

                for episode in range(num_episodes):

                    state = env.reset()

                    done = False

                    while not done:

                        # --- Agent (X) move ---

                        actions = env.available_actions()

                        action = agent.choose_action(state, actions)

                        next_state, reward, done, info = env.step(action, player=1)

                        if done:

                            # If X wins -> reward=+1, draw->0, illegal->-10

                            agent.update(state, action, reward, next_state, [], True)

                            break

                        # --- Opponent (O) random move ---

                        opp_actions = env.available_actions()

                        opp_action = random.choice(opp_actions)

                        next_state_opp, opp_reward, done, info_opp = env.step(opp_action, player=-1)

                        # From X's perspective: if O wins, that's -1 for X

                        if done:

                            if info_opp.get("winner") == -1:

                                final_reward = -1

                            else:

                                final_reward = 0  # draw

                            agent.update(state, action, final_reward, next_state_opp, [], True)

                            break

                        # Neither won; small step with 0 reward

                        agent.update(state, action, 0, next_state_opp, env.available_actions(), False)

                        state = next_state_opp

                    agent.decay_epsilon()

                    # Optional: simple progress print

                    if (episode + 1) % 10000 == 0:

                        print(f"Episode {episode + 1}/{num_episodes}, epsilon={agent.epsilon:.3f}")

                return env, agent

            # ---------- Play vs Trained Agent ---------- #

            def play_against_agent(env, agent):

                print("You are O, agent is X.")

                print("Board positions are:")

                print("0 | 1 | 2")

                print("--+---+--")

                print("3 | 4 | 5")

                print("--+---+--")

                print("6 | 7 | 8")

                print()

                state = env.reset()

                while True:

                    env.render()

                    # Agent (X) move - now we use greedy policy (epsilon=0)

                    available = env.available_actions()

                    agent_move = agent.choose_action(state, available)

                    print(f"Agent plays at position {agent_move}")

                    next_state, reward, done, info = env.step(agent_move, player=1)

                    if done:

                        env.render()

                        if info.get("winner") == 1:

                            print("Agent (X) wins!")

                        elif info.get("draw"):

                            print("It's a draw!")

                        else:

                            print("Game over.")

                        break

                    # Human (O) move

                    state = next_state

                    env.render()

                    available = env.available_actions()

                    while True:

                        try:

                            move = int(input(f"Your move (choose from {available}): "))

                            if move in available:

                                break

                            else:

                                print("Invalid move, try again.")

                        except ValueError:

                            print("Please enter a number.")

                    next_state, reward_opp, done, info_opp = env.step(move, player=-1)

                    if done:

                        env.render()

                        if info_opp.get("winner") == -1:

                            print("You (O) win! 🎉")

                        elif info_opp.get("draw"):

                            print("It's a draw!")

                        else:

                            print("Game over.")

                        break

                    state = next_state

            if __name__ == "__main__":

                # 1. Train the agent

                env, agent = train(num_episodes=30000)

                # Set epsilon to 0 for deterministic (greedy) play

                agent.epsilon = 0.0

                # 2. Play against it

                play_against_agent(env, agent)



            tic tac toe lab
            '''
        ),
    ),
    LabProgram(
        title="Naive Bayes Lab",
        summary="Gaussian Naive Bayes classifier on Iris dataset with confusion matrix visualization.",
        content=_clean(
            '''
            import pandas as pd
            import numpy as np
            import matplotlib.pyplot as plt
            from sklearn import datasets
            from sklearn.model_selection import train_test_split
            from sklearn.naive_bayes import GaussianNB
            from sklearn.metrics import accuracy_score, confusion_matrix, ConfusionMatrixDisplay

            # Load iris dataset
            iris = datasets.load_iris()
            X = iris.data
            y = iris.target
            feature_names = iris.feature_names
            class_names = iris.target_names

            # Convert to DataFrame
            df = pd.DataFrame(X, columns=feature_names)
            df['target'] = y
            print(df.head())

            # Train-test split
            X_train, X_test, y_train, y_test = train_test_split(
                X, y, test_size=0.3, random_state=42, stratify=y
            )

            # Train Gaussian Naive Bayes
            model = GaussianNB()
            model.fit(X_train, y_train)

            # Predict
            y_pred = model.predict(X_test)

            # Accuracy
            print("Accuracy:", accuracy_score(y_test, y_pred))

            # Confusion matrix
            cm = confusion_matrix(y_test, y_pred)
            disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=class_names)
            disp.plot(cmap="Blues")
            plt.title("Confusion Matrix — Gaussian Naive Bayes (Iris)")
            plt.show()
            '''
        ),
    ),
]


def get_lab_programs() -> List[LabProgram]:
    """Return all lab programs."""

    return list(_LAB_PROGRAMS)

