"""
Как нейросеть учится функции
Neural network progressively fitting a complex function
Pure numpy implementation (no torch dependency)
1080x1080, 25 seconds, 30fps, dark theme
"""
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation, FFMpegWriter

# === Config ===
FPS = 30
DURATION = 25
N_FRAMES = FPS * DURATION
W, H = 1080, 1080
DPI = 120

BG_COLOR = '#1a1a2e'
TEXT_COLOR = '#e0e0e0'
TRUE_COLOR = '#ff6b6b'
PRED_COLOR = '#00d2ff'
NEURON_COLOR = '#ffd93d'
LOSS_COLOR = '#6bcb77'

# === Target function ===
def target_fn(x):
    return np.sin(x) * np.cos(3*x) + 0.5 * np.sin(5*x)

# === Training data ===
np.random.seed(42)

x_train = np.linspace(-3, 3, 200).astype(np.float64)
y_train = target_fn(x_train).astype(np.float64)
x_plot = np.linspace(-3.5, 3.5, 500).astype(np.float64)
y_true_plot = target_fn(x_plot)

# Reshape for matrix ops: (N, 1)
X = x_train.reshape(-1, 1)
Y = y_train.reshape(-1, 1)
X_plot = x_plot.reshape(-1, 1)

# === Simple 2-layer NN in numpy ===
# Architecture: input(1) -> hidden(64, tanh) -> hidden2(32, tanh) -> output(1)
HIDDEN1 = 64
HIDDEN2 = 32
LR = 0.003

# Xavier initialization
def xavier(fan_in, fan_out):
    std = np.sqrt(2.0 / (fan_in + fan_out))
    return np.random.randn(fan_in, fan_out) * std

W1 = xavier(1, HIDDEN1)
b1 = np.zeros((1, HIDDEN1))
W2 = xavier(HIDDEN1, HIDDEN2)
b2 = np.zeros((1, HIDDEN2))
W3 = xavier(HIDDEN2, 1)
b3 = np.zeros((1, 1))

def tanh(x):
    return np.tanh(x)

def tanh_deriv(x):
    return 1 - np.tanh(x) ** 2

def forward(X, W1, b1, W2, b2, W3, b3):
    z1 = X @ W1 + b1
    a1 = tanh(z1)
    z2 = a1 @ W2 + b2
    a2 = tanh(z2)
    z3 = a2 @ W3 + b3
    return z3, (z1, a1, z2, a2)

# Adam optimizer state
def init_adam():
    params = [W1, b1, W2, b2, W3, b3]
    m = [np.zeros_like(p) for p in params]
    v = [np.zeros_like(p) for p in params]
    return m, v

def adam_update(params, grads, m, v, t, lr=LR, beta1=0.9, beta2=0.999, eps=1e-8):
    updated = []
    for i, (p, g) in enumerate(zip(params, grads)):
        m[i] = beta1 * m[i] + (1 - beta1) * g
        v[i] = beta2 * v[i] + (1 - beta2) * g**2
        m_hat = m[i] / (1 - beta1**(t+1))
        v_hat = v[i] / (1 - beta2**(t+1))
        p_new = p - lr * m_hat / (np.sqrt(v_hat) + eps)
        updated.append(p_new)
    return updated, m, v

m_adam, v_adam = init_adam()

# === Train and record snapshots ===
TOTAL_EPOCHS = 4000
epochs_per_frame = max(1, TOTAL_EPOCHS // N_FRAMES)

predictions_history = []
loss_history = []
epoch_numbers = []
w1_history = []  # for neuron visualization

N = len(X)

for epoch in range(TOTAL_EPOCHS):
    # Forward
    z1 = X @ W1 + b1
    a1 = tanh(z1)
    z2 = a1 @ W2 + b2
    a2 = tanh(z2)
    out = a2 @ W3 + b3

    # Loss
    loss = np.mean((out - Y) ** 2)

    # Backward
    dout = 2 * (out - Y) / N

    dW3 = a2.T @ dout
    db3 = np.sum(dout, axis=0, keepdims=True)

    da2 = dout @ W3.T
    dz2 = da2 * tanh_deriv(z2)

    dW2 = a1.T @ dz2
    db2 = np.sum(dz2, axis=0, keepdims=True)

    da1 = dz2 @ W2.T
    dz1 = da1 * tanh_deriv(z1)

    dW1 = X.T @ dz1
    db1 = np.sum(dz1, axis=0, keepdims=True)

    # Adam update
    params = [W1, b1, W2, b2, W3, b3]
    grads = [dW1, db1, dW2, db2, dW3, db3]
    updated, m_adam, v_adam = adam_update(params, grads, m_adam, v_adam, epoch)
    W1, b1, W2, b2, W3, b3 = updated

    # Record
    if epoch % epochs_per_frame == 0 or epoch == TOTAL_EPOCHS - 1:
        y_pred, _ = forward(X_plot, W1, b1, W2, b2, W3, b3)
        predictions_history.append(y_pred.flatten().copy())
        loss_history.append(loss)
        epoch_numbers.append(epoch)
        w1_history.append((W1.copy(), b1.copy()))

# Pad to N_FRAMES if needed
while len(predictions_history) < N_FRAMES:
    predictions_history.append(predictions_history[-1])
    loss_history.append(loss_history[-1])
    epoch_numbers.append(epoch_numbers[-1])
    w1_history.append(w1_history[-1])

print(f"Recorded {len(predictions_history)} frames, final loss: {loss_history[-1]:.6f}")

# === Create figure ===
fig = plt.figure(figsize=(W/DPI, H/DPI), dpi=DPI)
fig.patch.set_facecolor(BG_COLOR)

gs = fig.add_gridspec(3, 1, height_ratios=[3, 1.2, 1], hspace=0.28,
                      left=0.08, right=0.95, top=0.92, bottom=0.06)

ax_main = fig.add_subplot(gs[0])
ax_neurons = fig.add_subplot(gs[1])
ax_loss = fig.add_subplot(gs[2])

fig.suptitle('Как нейросеть учится аппроксимировать функцию',
             fontsize=17, color=TEXT_COLOR, fontweight='bold', y=0.97)

# --- Main plot ---
ax_main.set_facecolor(BG_COLOR)
ax_main.set_xlim(-3.5, 3.5)
ax_main.set_ylim(-2.5, 2.5)
ax_main.set_ylabel('f(x)', fontsize=12, color=TEXT_COLOR)
ax_main.tick_params(colors='#555555', labelsize=8)
for spine in ax_main.spines.values():
    spine.set_color('#333355')

ax_main.plot(x_plot, y_true_plot, color=TRUE_COLOR, linewidth=2.5, alpha=0.8,
             label='sin(x) cos(3x) + 0.5 sin(5x)', linestyle='--')
ax_main.fill_between(x_plot, y_true_plot - 0.15, y_true_plot + 0.15,
                     color=TRUE_COLOR, alpha=0.08)

pred_line, = ax_main.plot([], [], color=PRED_COLOR, linewidth=2.5, alpha=0.9,
                          label='Neural Network (2 hidden layers)')

ax_main.scatter(x_train[::5], y_train[::5], color=TRUE_COLOR, s=8, alpha=0.3, zorder=1)
ax_main.legend(loc='upper right', fontsize=9, facecolor=BG_COLOR, edgecolor='#333355',
               labelcolor=TEXT_COLOR)

epoch_text = ax_main.text(0.02, 0.92, '', transform=ax_main.transAxes,
                          fontsize=11, color=NEURON_COLOR, fontfamily='monospace',
                          fontweight='bold')

# Error shading (animated)
error_fill = [None]

# --- Neurons plot ---
ax_neurons.set_facecolor(BG_COLOR)
ax_neurons.set_xlim(-3.5, 3.5)
ax_neurons.set_ylim(-1.2, 1.2)
ax_neurons.set_ylabel('Нейроны', fontsize=10, color=TEXT_COLOR)
ax_neurons.set_title('Активации первого скрытого слоя (8 нейронов)', fontsize=10,
                     color='#888888', pad=4)
ax_neurons.tick_params(colors='#555555', labelsize=7)
for spine in ax_neurons.spines.values():
    spine.set_color('#333355')

neuron_colors = ['#ff6b6b', '#00d2ff', '#ffd93d', '#6bcb77',
                 '#ff9ff3', '#48dbfb', '#feca57', '#ff6348']
neuron_lines = []
for i in range(8):
    ln, = ax_neurons.plot([], [], color=neuron_colors[i], linewidth=1.0, alpha=0.6)
    neuron_lines.append(ln)

# --- Loss plot ---
ax_loss.set_facecolor(BG_COLOR)
ax_loss.set_ylabel('Loss (MSE)', fontsize=10, color=TEXT_COLOR)
ax_loss.set_xlabel('Epoch', fontsize=10, color=TEXT_COLOR)
ax_loss.tick_params(colors='#555555', labelsize=8)
for spine in ax_loss.spines.values():
    spine.set_color('#333355')

ax_loss.set_xlim(0, TOTAL_EPOCHS)
max_loss = max(loss_history[:10]) if loss_history else 1.0
ax_loss.set_yscale('log')
ax_loss.set_ylim(min(loss_history) * 0.5, max_loss * 1.5)

loss_line, = ax_loss.plot([], [], color=LOSS_COLOR, linewidth=2)
loss_fill_obj = [None]
loss_text = ax_loss.text(0.75, 0.85, '', transform=ax_loss.transAxes,
                         fontsize=10, color=LOSS_COLOR, fontfamily='monospace',
                         fontweight='bold')

def animate(frame):
    idx = min(frame, len(predictions_history) - 1)

    # Prediction line
    pred_line.set_data(x_plot, predictions_history[idx])

    # Error shading between prediction and truth
    if error_fill[0] is not None:
        error_fill[0].remove()
    error_fill[0] = ax_main.fill_between(
        x_plot, y_true_plot, predictions_history[idx],
        alpha=0.12, color=PRED_COLOR
    )

    epoch_text.set_text(f'Epoch {epoch_numbers[idx]}')

    # Neuron activations from current weights
    w1_cur, b1_cur = w1_history[idx]
    for i in range(8):
        activation = np.tanh(w1_cur[0, i] * x_plot + b1_cur[0, i])
        neuron_lines[i].set_data(x_plot, activation)

    # Loss curve
    show_losses = loss_history[:idx+1]
    show_epochs = epoch_numbers[:idx+1]
    loss_line.set_data(show_epochs, show_losses)

    if loss_fill_obj[0] is not None:
        loss_fill_obj[0].remove()
    loss_fill_obj[0] = ax_loss.fill_between(show_epochs, show_losses, alpha=0.15,
                                            color=LOSS_COLOR)

    loss_text.set_text(f'MSE: {loss_history[idx]:.5f}')

    return [pred_line, epoch_text, loss_line, loss_text] + neuron_lines

anim = FuncAnimation(fig, animate, frames=N_FRAMES, interval=1000/FPS, blit=False)

outpath = '/root/Strategy/content/generated/nn_learning_animation.mp4'
writer = FFMpegWriter(fps=FPS, bitrate=3000,
                      extra_args=['-vcodec', 'libx264', '-pix_fmt', 'yuv420p'])
anim.save(outpath, writer=writer)
plt.close()
print(f"Saved: {outpath}")
