#!/usr/bin/env python3
"""Bab 07 — Supervised Learning Playground.

Standard library only. Educational implementations, not production replacements.
Compares: majority baseline, kNN, Gaussian Naive Bayes, logistic regression,
decision stump, ensemble stumps, and perceptron.
"""
from __future__ import annotations

import math
import random
from statistics import mean, pstdev

SEED = 42
random.seed(SEED)

Vector = list[float]


def make_dataset(n: int = 80) -> list[dict[str, float | int]]:
    rows = []
    for i in range(n):
        rating = round(random.uniform(2.5, 5.0), 2)
        discount = round(random.uniform(0.0, 0.45), 2)
        clicks = random.randint(0, 12)
        price_rel = round(random.uniform(0.2, 1.0), 2)
        noise = random.uniform(-0.35, 0.35)
        score = 1.25 * rating + 3.0 * discount + 0.23 * clicks - 2.2 * price_rel + noise
        bought = int(score > 4.7)
        demand = max(0, int(8 + 8 * rating + 30 * discount + 2 * clicks - 12 * price_rel + noise * 5))
        rows.append({"rating": rating, "discount": discount, "clicks": clicks, "price_rel": price_rel, "bought": bought, "demand": demand})
    return rows


def features(row: dict[str, float | int]) -> Vector:
    return [float(row["rating"]), float(row["discount"]), float(row["clicks"]), float(row["price_rel"])]


def labels(rows: list[dict[str, float | int]]) -> list[int]:
    return [int(r["bought"]) for r in rows]


def train_test_split(rows: list[dict[str, float | int]], test_ratio: float = 0.25) -> tuple[list[dict[str, float | int]], list[dict[str, float | int]]]:
    xs = rows[:]
    random.shuffle(xs)
    cut = int(len(xs) * (1 - test_ratio))
    return xs[:cut], xs[cut:]


def standardize_fit(x_train: list[Vector]) -> tuple[Vector, Vector]:
    cols = list(zip(*x_train))
    mus = [mean(c) for c in cols]
    sigmas = [pstdev(c) or 1.0 for c in cols]
    return mus, sigmas


def standardize_transform(x_rows: list[Vector], mus: Vector, sigmas: Vector) -> list[Vector]:
    return [[(v - m) / s for v, m, s in zip(row, mus, sigmas)] for row in x_rows]


def confusion_matrix(y_true: list[int], y_pred: list[int]) -> dict[str, int]:
    tp = sum(1 for y, p in zip(y_true, y_pred) if y == 1 and p == 1)
    fp = sum(1 for y, p in zip(y_true, y_pred) if y == 0 and p == 1)
    tn = sum(1 for y, p in zip(y_true, y_pred) if y == 0 and p == 0)
    fn = sum(1 for y, p in zip(y_true, y_pred) if y == 1 and p == 0)
    return {"TP": tp, "FP": fp, "TN": tn, "FN": fn}


def safe_div(a: float, b: float) -> float:
    return 0.0 if b == 0 else a / b


def metrics(y_true: list[int], y_pred: list[int]) -> dict[str, float | int]:
    cm = confusion_matrix(y_true, y_pred)
    tp, fp, tn, fn = cm["TP"], cm["FP"], cm["TN"], cm["FN"]
    precision = safe_div(tp, tp + fp)
    recall = safe_div(tp, tp + fn)
    return {
        **cm,
        "accuracy": safe_div(tp + tn, tp + fp + tn + fn),
        "precision": precision,
        "recall": recall,
        "f1": safe_div(2 * precision * recall, precision + recall),
    }


def print_metrics(name: str, y_true: list[int], y_pred: list[int]) -> None:
    m = metrics(y_true, y_pred)
    print(f"\n{name}")
    print("-" * len(name))
    for k in ["TP", "FP", "TN", "FN", "accuracy", "precision", "recall", "f1"]:
        v = m[k]
        print(f"{k:>9}: {v:.3f}" if isinstance(v, float) else f"{k:>9}: {v}")


def majority_predict(y_train: list[int], n: int) -> list[int]:
    pred = 1 if sum(y_train) >= len(y_train) / 2 else 0
    return [pred] * n


def euclidean(a: Vector, b: Vector) -> float:
    return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))


def knn_predict(x_train: list[Vector], y_train: list[int], x_test: list[Vector], k: int = 5) -> list[int]:
    preds = []
    for row in x_test:
        neighbors = sorted(zip(x_train, y_train), key=lambda pair: euclidean(row, pair[0]))[:k]
        preds.append(1 if sum(y for _, y in neighbors) >= k / 2 else 0)
    return preds


def gaussian_nb_fit(x_train: list[Vector], y_train: list[int]) -> dict[int, tuple[float, Vector, Vector]]:
    model = {}
    for cls in [0, 1]:
        rows = [x for x, y in zip(x_train, y_train) if y == cls]
        prior = len(rows) / len(x_train)
        cols = list(zip(*rows))
        mus = [mean(c) for c in cols]
        sigmas = [pstdev(c) or 1e-6 for c in cols]
        model[cls] = (prior, mus, sigmas)
    return model


def normal_log_pdf(x: float, mu: float, sigma: float) -> float:
    return -math.log(sigma) - ((x - mu) ** 2) / (2 * sigma * sigma)


def gaussian_nb_predict(model: dict[int, tuple[float, Vector, Vector]], x_test: list[Vector]) -> list[int]:
    preds = []
    for row in x_test:
        scores = {}
        for cls, (prior, mus, sigmas) in model.items():
            scores[cls] = math.log(prior + 1e-12) + sum(normal_log_pdf(v, m, s) for v, m, s in zip(row, mus, sigmas))
        preds.append(max(scores, key=scores.get))
    return preds


def sigmoid(z: float) -> float:
    return 1 / (1 + math.exp(-max(-40, min(40, z))))


def logistic_fit(x_train: list[Vector], y_train: list[int], lr: float = 0.15, epochs: int = 300) -> tuple[Vector, float]:
    w = [0.0] * len(x_train[0])
    b = 0.0
    for _ in range(epochs):
        grad_w = [0.0] * len(w)
        grad_b = 0.0
        for x, y in zip(x_train, y_train):
            p = sigmoid(sum(wi * xi for wi, xi in zip(w, x)) + b)
            err = p - y
            for j in range(len(w)):
                grad_w[j] += err * x[j]
            grad_b += err
        n = len(x_train)
        w = [wi - lr * gw / n for wi, gw in zip(w, grad_w)]
        b -= lr * grad_b / n
    return w, b


def logistic_predict(w: Vector, b: float, x_test: list[Vector], threshold: float = 0.5) -> list[int]:
    return [int(sigmoid(sum(wi * xi for wi, xi in zip(w, x)) + b) >= threshold) for x in x_test]


def stump_fit(x_train: list[Vector], y_train: list[int]) -> tuple[int, float, int]:
    best = (0, 0.0, 1, -1.0)
    for feature_idx in range(len(x_train[0])):
        values = sorted(set(row[feature_idx] for row in x_train))
        for threshold in values:
            for polarity in [1, -1]:
                pred = [1 if polarity * row[feature_idx] >= polarity * threshold else 0 for row in x_train]
                acc = sum(int(p == y) for p, y in zip(pred, y_train)) / len(y_train)
                if acc > best[3]:
                    best = (feature_idx, threshold, polarity, acc)
    return best[0], best[1], best[2]


def stump_predict(stump: tuple[int, float, int], x_test: list[Vector]) -> list[int]:
    i, threshold, polarity = stump
    return [1 if polarity * row[i] >= polarity * threshold else 0 for row in x_test]


def ensemble_stumps_predict(x_train: list[Vector], y_train: list[int], x_test: list[Vector]) -> list[int]:
    stumps = []
    for seed in [1, 2, 3, 4, 5]:
        random.seed(seed)
        sample_idx = [random.randrange(len(x_train)) for _ in x_train]
        xs = [x_train[i] for i in sample_idx]
        ys = [y_train[i] for i in sample_idx]
        stumps.append(stump_fit(xs, ys))
    votes = [stump_predict(stump, x_test) for stump in stumps]
    return [1 if sum(vote[i] for vote in votes) >= len(stumps) / 2 else 0 for i in range(len(x_test))]


def perceptron_fit(x_train: list[Vector], y_train: list[int], lr: float = 0.1, epochs: int = 30) -> tuple[Vector, float]:
    w = [0.0] * len(x_train[0])
    b = 0.0
    y_signed = [1 if y == 1 else -1 for y in y_train]
    for _ in range(epochs):
        for x, y in zip(x_train, y_signed):
            score = sum(wi * xi for wi, xi in zip(w, x)) + b
            if y * score <= 0:
                w = [wi + lr * y * xi for wi, xi in zip(w, x)]
                b += lr * y
    return w, b


def perceptron_predict(w: Vector, b: float, x_test: list[Vector]) -> list[int]:
    return [1 if sum(wi * xi for wi, xi in zip(w, x)) + b >= 0 else 0 for x in x_test]


def mae(preds: list[float], actuals: list[float]) -> float:
    return mean(abs(p - a) for p, a in zip(preds, actuals))


def main() -> None:
    print("Bab 07 — Supervised Learning Playground")
    print("=" * 68)
    rows = make_dataset()
    train, test = train_test_split(rows)
    x_train_raw, x_test_raw = [features(r) for r in train], [features(r) for r in test]
    y_train, y_test = labels(train), labels(test)
    mus, sigmas = standardize_fit(x_train_raw)
    x_train = standardize_transform(x_train_raw, mus, sigmas)
    x_test = standardize_transform(x_test_raw, mus, sigmas)
    print(f"data: train={len(train)} test={len(test)} positif_train={sum(y_train)} positif_test={sum(y_test)}")

    print_metrics("Majority baseline", y_test, majority_predict(y_train, len(test)))
    for k in [1, 3, 5]:
        print_metrics(f"kNN k={k}", y_test, knn_predict(x_train, y_train, x_test, k=k))
    nb = gaussian_nb_fit(x_train, y_train)
    print_metrics("Gaussian Naive Bayes", y_test, gaussian_nb_predict(nb, x_test))
    w, b = logistic_fit(x_train, y_train)
    for threshold in [0.4, 0.5, 0.6]:
        print_metrics(f"Logistic regression threshold={threshold}", y_test, logistic_predict(w, b, x_test, threshold))
    stump = stump_fit(x_train, y_train)
    print_metrics("Decision stump", y_test, stump_predict(stump, x_test))
    print_metrics("Ensemble stumps", y_test, ensemble_stumps_predict(x_train, y_train, x_test))
    pw, pb = perceptron_fit(x_train, y_train)
    print_metrics("Perceptron linear", y_test, perceptron_predict(pw, pb, x_test))

    mean_demand = mean(float(r["demand"]) for r in train)
    demand_test = [float(r["demand"]) for r in test]
    print("\nRegression baseline")
    print("-------------------")
    print(f"prediksi demand konstan={mean_demand:.2f} MAE={mae([mean_demand]*len(test), demand_test):.3f}")

    print("\nCatatan: implementasi manual ini untuk belajar. Untuk produksi, gunakan library teruji dan validasi lebih ketat.")


if __name__ == "__main__":
    main()
