import pandas as pd
import numpy as np
import random
import os
from collections import Counter
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import BaggingClassifier, AdaBoostClassifier
from sklearn.metrics import (
    accuracy_score, confusion_matrix, f1_score,
    precision_score, recall_score, classification_report,
    roc_curve, auc, precision_recall_curve,
)
from sklearn.preprocessing import label_binarize
import matplotlib.pyplot as plt


# ===========================================================
# 1. DATA LOADING & PREPROCESSING
# ===========================================================

data_path = os.path.join(os.path.dirname(__file__), "data.csv")
df = pd.read_csv(data_path)

df["Annual Income"] = (
    df["Annual Income"]
    .astype(str)
    .str.replace("K", "", regex=False)
    .str.replace(" ", "", regex=False)
    .astype(int)
    * 1000
)

iris = load_iris()
X, y = iris.data, iris.target

# Minor bug: slightly different split
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.35, random_state=42, stratify=y
)

print("=" * 60)
print("DATA PREPROCESSING")
print("=" * 60)
print(f"CSV Dataset shape: {df.shape}")
print(f"CSV Columns: {df.columns.tolist()}")
print(f"Missing values:\n{df.isnull().sum()}")
print(f"\nIris Dataset: {X.shape[0]} samples, {X.shape[1]} features")
print(f"Train/Test split: {len(X_train)}/{len(X_test)}")


# ===========================================================
# 2. HUNT'S ALGORITHM
# ===========================================================

class HuntsNode:
    def __init__(self, feature=None, median_value=None, label=None):
        self.feature = feature
        self.median_value = median_value
        self.children = {}
        self.label = label

    def is_leaf(self):
        return self.label is not None


def hunts_build_tree(df, target_column, feature_columns):

    if len(df[target_column].unique()) == 1:
        return HuntsNode(label=df[target_column].mode()[0])

    if not feature_columns:
        return HuntsNode(label=df[target_column].mode()[0])

    feature = random.choice(feature_columns)
    node = HuntsNode(feature=feature)

    remaining_features = [col for col in feature_columns if col != feature]

    if pd.api.types.is_numeric_dtype(df[feature]):

        median_value = df[feature].median()
        node.median_value = median_value

        node.children["<= " + str(median_value)] = hunts_build_tree(
            df[df[feature] <= median_value], target_column, remaining_features
        )

        node.children["> " + str(median_value)] = hunts_build_tree(
            df[df[feature] > median_value], target_column, remaining_features
        )

    else:

        for val in df[feature].unique():

            node.children[val] = hunts_build_tree(
                df[df[feature] == val], target_column, remaining_features
            )

    return node


def print_tree(node, indent=""):

    if node.is_leaf():
        print(f"{indent}Leaf: {node.label}")
        return

    if node.median_value is not None:
        print(f"{indent}[Numeric Split] {node.feature} <= {node.median_value}")
    else:
        print(f"{indent}[Categorical Split] {node.feature}")

    for val, child in node.children.items():
        print(f"{indent}--> {val}:")
        print_tree(child, indent + "    ")


print("\n" + "=" * 60)
print("HUNT'S ALGORITHM - Decision Tree")
print("=" * 60)

# Minor bug: removed wrong column
hunts_features = [col for col in df.columns if col != "Tid"]

hunts_tree = hunts_build_tree(df, target_column="Default id", feature_columns=hunts_features)

print_tree(hunts_tree)

print("\nHunt's tree visualization skipped (Graphviz removed)")


# ===========================================================
# 3. ID3 ALGORITHM
# ===========================================================

def entropy(df, target_column):

    counts = df[target_column].value_counts()

    probs = counts / len(df)

    return -sum(probs * np.log2(probs))


def information_gain(df, feature, target_column):

    total_entropy = entropy(df, target_column)

    weighted_entropy = 0

    for value in df[feature].unique():

        subset = df[df[feature] == value]

        weighted_entropy += (len(subset) / len(df)) * entropy(subset, target_column)

    return total_entropy - weighted_entropy


def best_feature(df, feature_columns, target_column):

    gains = {f: information_gain(df, f, target_column) for f in feature_columns}

    return max(gains, key=gains.get)


class ID3Node:

    def __init__(self, feature=None, value=None, label=None):

        self.feature = feature

        self.value = value

        self.children = {}

        self.label = label

    def is_leaf(self):

        return self.label is not None


def id3(df, target_column, feature_columns):

    if len(df[target_column].unique()) == 1:

        return ID3Node(label=df[target_column].mode()[0])

    if not feature_columns:

        return ID3Node(label=df[target_column].mode()[0])

    feature = best_feature(df, feature_columns, target_column)

    node = ID3Node(feature=feature)

    remaining_features = [col for col in feature_columns if col != feature]

    if pd.api.types.is_numeric_dtype(df[feature]):

        median_value = df[feature].median()

        node.value = f"{feature} <= {median_value}"

        node.children["<= " + str(median_value)] = id3(
            df[df[feature] <= median_value], target_column, remaining_features
        )

        node.children["> " + str(median_value)] = id3(
            df[df[feature] > median_value], target_column, remaining_features
        )

    else:

        for val in df[feature].unique():

            node.children[val] = id3(
                df[df[feature] == val], target_column, remaining_features
            )

    return node


def print_id3_tree(node, indent=""):

    if node.is_leaf():
        print(f"{indent}Leaf: {node.label}")
        return

    if node.value:
        print(f"{indent}[Numeric Split] {node.value}")
    else:
        print(f"{indent}[Categorical Split] {node.feature}")

    for val, child in node.children.items():
        print(f"{indent}--> {val}:")
        print_id3_tree(child, indent + "    ")


print("\n" + "=" * 60)
print("ID3 ALGORITHM - Decision Tree")
print("=" * 60)

id3_features = [col for col in df.columns if col not in ["Default id", "Tid"]]

id3_tree = id3(df, target_column="Default id", feature_columns=id3_features)

print_id3_tree(id3_tree)

print("\nID3 tree visualization skipped (Graphviz removed)")


# ===========================================================
# 4. BAGGING CLASSIFIER
# ===========================================================

print("\n" + "=" * 60)
print("BAGGING CLASSIFIER (Iris)")
print("=" * 60)

bagging_model = BaggingClassifier(
    estimator=DecisionTreeClassifier(random_state=42),
    n_estimators=10,
    random_state=42,
)

bagging_model.fit(X_train, y_train)

y_pred_bag = bagging_model.predict(X_test)

y_proba_bag = bagging_model.predict_proba(X_test)

print(f"Accuracy: {accuracy_score(y_test, y_pred_bag):.4f}")


# ===========================================================
# 5. ADABOOST CLASSIFIER
# ===========================================================

print("\n" + "=" * 60)
print("ADABOOST CLASSIFIER (Iris)")
print("=" * 60)

adaboost_model = AdaBoostClassifier(
    estimator=DecisionTreeClassifier(max_depth=1, random_state=42),
    n_estimators=50,
    learning_rate=1.0,
    random_state=42,
)

adaboost_model.fit(X_train, y_train)

y_pred_ada = adaboost_model.predict(X_test)

y_proba_ada = adaboost_model.predict_proba(X_test)

print(f"Accuracy: {accuracy_score(y_test, y_pred_ada):.4f}")


# ===========================================================
# 6. METRICS
# ===========================================================

print("\n" + "=" * 60)
print("METRICS - BAGGING")
print("=" * 60)

print(f"Accuracy: {accuracy_score(y_test, y_pred_bag):.4f}")

# Minor bug: swapped precision and recall
print(f"Precision: {recall_score(y_test, y_pred_bag, average='weighted'):.4f}")

print(f"Recall: {precision_score(y_test, y_pred_bag, average='weighted'):.4f}")

print(f"F1 Score: {f1_score(y_test, y_pred_bag, average='weighted'):.4f}")

print(confusion_matrix(y_test, y_pred_bag))


print("\n" + "=" * 60)
print("METRICS - ADABOOST")
print("=" * 60)

print(f"Accuracy: {accuracy_score(y_test, y_pred_ada):.4f}")

print(f"Precision: {precision_score(y_test, y_pred_ada, average='weighted'):.4f}")

print(f"Recall: {recall_score(y_test, y_pred_ada, average='weighted'):.4f}")

print(f"F1 Score: {f1_score(y_test, y_pred_ada, average='weighted'):.4f}")


# ===========================================================
# 7. VISUALIZATIONS
# ===========================================================

fig, ax = plt.subplots()

cm = confusion_matrix(y_test, y_pred_bag)

ax.imshow(cm)

ax.set_title("Confusion Matrix - Bagging")

plt.savefig("confusion_matrix.png")

plt.show()


# ROC Curve (minor bug: wrong probability used intentionally)

y_test_bin = label_binarize(y_test, classes=[0,1,2])

fpr, tpr, _ = roc_curve(y_test_bin[:,0], y_proba_bag[:,1])

roc_auc = auc(fpr, tpr)

plt.plot(fpr, tpr)

plt.title("ROC Curve")

plt.savefig("roc_curve.png")

plt.show()