from __future__ import annotations

from dataclasses import dataclass
import json
import random
from typing import Iterable


def bipolar_sign(value: float, *, threshold: float = 0.0) -> int:
    return 1 if value >= threshold else -1


@dataclass(frozen=True)
class AdalineResult:
    activation: float
    output: float
    error: float


class AdalineNetwork:
    def __init__(
        self,
        *,
        input_size: int,
        learning_rate: float = 0.1,
        threshold: float = 0.0,
        weights: Iterable[float] | None = None,
        bias: float = 0.0,
    ) -> None:
        if input_size <= 0:
            raise ValueError("input_size must be positive")
        if learning_rate <= 0.0:
            raise ValueError("learning_rate must be positive")
        self.input_size = input_size
        self.learning_rate = learning_rate
        self.threshold = threshold
        self.weights = (
            [float(value) for value in weights]
            if weights is not None
            else [0.0 for _ in range(input_size)]
        )
        if len(self.weights) != input_size:
            raise ValueError("weights length must match input_size")
        self.bias = float(bias)

    @classmethod
    def random(
        cls,
        *,
        input_size: int,
        learning_rate: float = 0.1,
        threshold: float = 0.0,
        rng: random.Random | None = None,
        weight_scale: float = 0.25,
    ) -> "AdalineNetwork":
        generator = rng or random.Random()
        return cls(
            input_size=input_size,
            learning_rate=learning_rate,
            threshold=threshold,
            weights=[generator.uniform(-weight_scale, weight_scale) for _ in range(input_size)],
            bias=generator.uniform(-weight_scale, weight_scale),
        )

    def activation(self, inputs: Iterable[float]) -> float:
        values = tuple(float(value) for value in inputs)
        if len(values) != self.input_size:
            raise ValueError(f"expected {self.input_size} inputs, got {len(values)}")
        return sum(weight * value for weight, value in zip(self.weights, values)) + self.bias

    def predict(self, inputs: Iterable[float], *, thresholded: bool = False) -> AdalineResult:
        activation = self.activation(inputs)
        output = (
            float(bipolar_sign(activation, threshold=self.threshold))
            if thresholded
            else activation
        )
        return AdalineResult(
            activation=activation,
            output=output,
            error=0.0,
        )

    def classify(self, inputs: Iterable[float]) -> int:
        return bipolar_sign(self.activation(inputs), threshold=self.threshold)

    def train_step(self, inputs: Iterable[float], target: float) -> AdalineResult:
        values = tuple(float(value) for value in inputs)
        if len(values) != self.input_size:
            raise ValueError(f"expected {self.input_size} inputs, got {len(values)}")
        activation = self.activation(values)
        error = float(target) - activation
        for index, value in enumerate(values):
            self.weights[index] += self.learning_rate * error * value
        self.bias += self.learning_rate * error
        updated = self.predict(values)
        return AdalineResult(
            activation=updated.activation,
            output=updated.output,
            error=error,
        )

    def to_dict(self) -> dict[str, object]:
        return {
            "input_size": self.input_size,
            "learning_rate": self.learning_rate,
            "threshold": self.threshold,
            "weights": list(self.weights),
            "bias": self.bias,
        }

    @classmethod
    def from_dict(cls, data: dict[str, object]) -> "AdalineNetwork":
        return cls(
            input_size=int(data["input_size"]),
            learning_rate=float(data["learning_rate"]),
            threshold=float(data["threshold"]),
            weights=[float(value) for value in data["weights"]],  # type: ignore[index]
            bias=float(data["bias"]),
        )

    def save_json(self, path: str) -> None:
        with open(path, "w", encoding="utf-8") as handle:
            json.dump(self.to_dict(), handle, indent=2)

    @classmethod
    def load_json(cls, path: str) -> "AdalineNetwork":
        with open(path, "r", encoding="utf-8") as handle:
            return cls.from_dict(json.load(handle))


@dataclass(frozen=True)
class MadalineResult:
    hidden_results: tuple[AdalineResult, ...]
    hidden_outputs: tuple[int, ...]
    output_result: AdalineResult
    output: int


class MadalineNetwork:
    """
    A small bipolar MADALINE-style classifier.

    This implementation is intentionally modest: one hidden layer of Adaline
    units with thresholded bipolar outputs, followed by one output Adaline.
    The training rule is deliberately conservative rather than a claim of
    exhaustive historical fidelity: hidden units are adapted toward the target,
    then the output Adaline is trained over the hidden bipolar responses.
    """

    def __init__(
        self,
        *,
        hidden_units: tuple[AdalineNetwork, ...],
        output_unit: AdalineNetwork,
    ) -> None:
        if not hidden_units:
            raise ValueError("hidden_units must not be empty")
        if output_unit.input_size != len(hidden_units):
            raise ValueError("output_unit input size must match the hidden unit count")
        self.hidden_units = list(hidden_units)
        self.output_unit = output_unit

    @classmethod
    def random(
        cls,
        *,
        input_size: int,
        hidden_unit_count: int,
        learning_rate: float = 0.1,
        rng: random.Random | None = None,
        weight_scale: float = 0.25,
    ) -> "MadalineNetwork":
        generator = rng or random.Random()
        hidden_units = tuple(
            AdalineNetwork.random(
                input_size=input_size,
                learning_rate=learning_rate,
                rng=generator,
                weight_scale=weight_scale,
            )
            for _ in range(hidden_unit_count)
        )
        output_unit = AdalineNetwork.random(
            input_size=hidden_unit_count,
            learning_rate=learning_rate,
            rng=generator,
            weight_scale=weight_scale,
        )
        return cls(hidden_units=hidden_units, output_unit=output_unit)

    def predict(self, inputs: Iterable[float]) -> MadalineResult:
        values = tuple(float(value) for value in inputs)
        hidden_results = tuple(
            unit.predict(values, thresholded=True)
            for unit in self.hidden_units
        )
        hidden_outputs = tuple(int(result.output) for result in hidden_results)
        linear_output = self.output_unit.predict(hidden_outputs)
        return MadalineResult(
            hidden_results=hidden_results,
            hidden_outputs=hidden_outputs,
            output_result=linear_output,
            output=bipolar_sign(linear_output.activation, threshold=self.output_unit.threshold),
        )

    def train_step(self, inputs: Iterable[float], target: int) -> MadalineResult:
        if target not in (-1, 1):
            raise ValueError("target must be bipolar: -1 or 1")
        values = tuple(float(value) for value in inputs)
        for hidden_unit in self.hidden_units:
            hidden_unit.train_step(values, float(target))
        hidden_result = self.predict(values)
        self.output_unit.train_step(hidden_result.hidden_outputs, float(target))
        return self.predict(values)

    def classify(self, inputs: Iterable[float]) -> int:
        return self.predict(inputs).output

    def to_dict(self) -> dict[str, object]:
        return {
            "hidden_units": [unit.to_dict() for unit in self.hidden_units],
            "output_unit": self.output_unit.to_dict(),
        }

    @classmethod
    def from_dict(cls, data: dict[str, object]) -> "MadalineNetwork":
        return cls(
            hidden_units=tuple(
                AdalineNetwork.from_dict(unit_data)
                for unit_data in data["hidden_units"]  # type: ignore[index]
            ),
            output_unit=AdalineNetwork.from_dict(data["output_unit"]),  # type: ignore[arg-type]
        )

    def save_json(self, path: str) -> None:
        with open(path, "w", encoding="utf-8") as handle:
            json.dump(self.to_dict(), handle, indent=2)

    @classmethod
    def load_json(cls, path: str) -> "MadalineNetwork":
        with open(path, "r", encoding="utf-8") as handle:
            return cls.from_dict(json.load(handle))
