import os
import sys
import json

# ==============================================================================
# AUTO-CONFIG: Find and link hidden XLA compiler libraries inside the venv
# ==============================================================================
def configure_xla_paths():
    venv_base = sys.prefix
    for root, _, files in os.walk(venv_base):
        if "libdevice.10.bc" in files:
            cuda_dir = root.split("/nvvm")[0]
            os.environ["XLA_FLAGS"] = f"--xla_gpu_cuda_data_dir={cuda_dir}"
            print(f"[XLA Config] Successfully linked compiler data to: {cuda_dir}")
            return True
    print("[XLA Config] Warning: libdevice.10.bc not found. Training may crash.")
    return False

configure_xla_paths()

import tensorflow as tf
#tf.debugging.set_log_device_placement(True)
import numpy as np

# ==========================================
# 1. ARCHITECTURE CONFIGURATION
# ==========================================
EMBEDDING_DIM = 64
RNN_UNITS = 256

# Pipeline & Training Hyperparameters
TEXT_FILE = "combined_training_data.txt"
VOCAB_FILE = "vocab.txt"

# DYNAMICALLY MANGLED DIRECTORY:
# This will automatically evaluate to something like: ./checkpoints_emb256_rnn512
CHECKPOINT_DIR = f"./checkpoints_emb{EMBEDDING_DIM}_rnn{RNN_UNITS}"
CONFIG_FILE = os.path.join(CHECKPOINT_DIR, "config.json")

SEQ_LENGTH = 128
BATCH_SIZE = 128
EPOCHS = 100
BUFFER_SIZE = 10000
SEED_TEXT = "it was a dark and stormy night"

print("Num GPUs Available: ", len(tf.config.list_physical_devices('GPU')))
os.makedirs(CHECKPOINT_DIR, exist_ok=True)

# ==========================================
# 2. CONFIG CHECKPOINT GUARD (THE MAGIC SOUP)
# ==========================================
# If an existing config file is found, OVERWRITE your local variables
# to force the model to build at the size matching the saved weights.
if os.path.exists(CONFIG_FILE):
    print(f"Found existing model configuration file at '{CONFIG_FILE}'.")
    with open(CONFIG_FILE, 'r') as f:
        saved_config = json.load(f)

    EMBEDDING_DIM = saved_config["embedding_dim"]
    RNN_UNITS = saved_config["rnn_units"]
    print(f"-> Overrode network sizes to match saved profile: Embedding={EMBEDDING_DIM}, RNN Units={RNN_UNITS}")
else:
    # Save the current configuration since it's a brand new run
    current_config = {
        "embedding_dim": EMBEDDING_DIM,
        "rnn_units": RNN_UNITS
    }
    with open(CONFIG_FILE, 'w') as f:
        json.dump(current_config, f, indent=4)
    print(f"Created a new model configuration footprint file at '{CONFIG_FILE}'.")

# ==========================================
# 3. DATA PREPROCESSING
# ==========================================
if os.path.exists(VOCAB_FILE):
    print(f"Loading existing vocabulary file from '{VOCAB_FILE}'...")
    with open(VOCAB_FILE, 'r', encoding='utf-8') as f:
        vocab = json.load(f)
else:
    if not os.path.exists(TEXT_FILE):
        print(f"File '{TEXT_FILE}' not found locally. Please run the process.py script!")
        quit(1)

    print(f"Loading raw text from {TEXT_FILE} to build vocabulary...")
    with open(TEXT_FILE, 'r', encoding='utf-8') as f:
        text = f.read()
    temp_vectorizer = tf.keras.layers.TextVectorization(split="character", standardize="lower")
    temp_vectorizer.adapt(tf.data.Dataset.from_tensor_slices([text]))
    vocab = temp_vectorizer.get_vocabulary()
    with open(VOCAB_FILE, 'w', encoding='utf-8') as f:
        json.dump(vocab, f, ensure_ascii=False)

vocab_size = len(vocab)
vectorize_layer = tf.keras.layers.TextVectorization(
    split="character", standardize="lower", vocabulary=vocab, output_mode="int"
)

dataset = None
if os.path.exists(TEXT_FILE):
    with open(TEXT_FILE, 'r', encoding='utf-8') as f:
        text = f.read()
    all_ids = vectorize_layer(tf.constant([text]))[0]
    ids_dataset = tf.data.Dataset.from_tensor_slices(all_ids)
    sequences = ids_dataset.batch(SEQ_LENGTH + 1, drop_remainder=True)

    def split_input_target(sequence):
        return sequence[:-1], sequence[1:]

    dataset = sequences.map(split_input_target).shuffle(BUFFER_SIZE).batch(BATCH_SIZE, drop_remainder=True).prefetch(tf.data.AUTOTUNE)

# ==========================================
# 4. MODEL ARCHITECTURE
# ==========================================

class CharacterTextModel(tf.keras.Model):
    def __init__(self, vocab_size, embedding_dim, rnn_units, num_layers=2, dropout_rate=0.4):
        super().__init__()
        self.embedding = tf.keras.layers.Embedding(vocab_size, embedding_dim)

        # Create paired lists of GRUs and Dropouts based on your desired depth
        self.gru_layers = []
        self.dropout_layers = []

        # Loop runs (num_layers - 1) times, leaving the final layer separate
        for _ in range(num_layers - 1):
            self.gru_layers.append(
                tf.keras.layers.GRU(rnn_units, return_sequences=True)
            )
            self.dropout_layers.append(
                tf.keras.layers.Dropout(dropout_rate)
            )

        # Switched to GRU. Note: GRU only has 1 state output (h), not 2 (h, c)
        self.gru_final = tf.keras.layers.GRU(rnn_units, return_sequences=True, return_state=True)
        self.dropout_final = tf.keras.layers.Dropout(dropout_rate)
        self.dense = tf.keras.layers.Dense(vocab_size)

    def call(self, inputs, states=None, return_state=False, training=False):
        x = self.embedding(inputs, training=training)

        # Loop through both lists simultaneously using zip()
        for gru, dropout in zip(self.gru_layers, self.dropout_layers):
            x = gru(x, training=training)
            x = dropout(x, training=training)

        # Final GRU Layer returns the output 'x' and a single hidden state 'h'
        x, h = self.gru_final(x, initial_state=states, training=training)
        x = self.dropout_final(x, training=training)
        x = self.dense(x, training=training)

        if return_state:
            return x, h  # Returns just 'h' instead of '(h, c)'
        return x

# Automatically scales to whatever dimensions were chosen or loaded!
model = CharacterTextModel(vocab_size=vocab_size, embedding_dim=EMBEDDING_DIM, rnn_units=RNN_UNITS)
model.build(input_shape=(BATCH_SIZE, SEQ_LENGTH))

import glob

# Find all files matching the pattern
checkpoint_files = glob.glob(os.path.join(CHECKPOINT_DIR, "ckpt_*.weights.h5"))

if checkpoint_files:
    # Sort files naturally or by modification time to get the latest one
    latest_checkpoint = max(checkpoint_files, key=os.path.getmtime)
    print(f"Restoring model layers from: {latest_checkpoint}")
    model.load_weights(latest_checkpoint)
else:
    print("Starting a clean initialization.")


loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
model.compile(optimizer='adam', loss=loss)

# ==========================================
# 5. SAMPLING & GENERATION LOGIC
# ==========================================
def produce_sample(model, seed, num_generate=300, temperature=0.7):
    input_chars = vectorize_layer(tf.constant([seed]))
    input_ids = input_chars[0][:len(seed)].numpy().tolist()
    generated_ids, states = [], None

    for _ in range(num_generate):
        current_tokens = input_ids[-SEQ_LENGTH:]
        predictions, states = model(tf.expand_dims(current_tokens, 0), states=states, return_state=True, training=False)
        predictions = predictions[0, -1, :] / temperature
        predicted_id = tf.random.categorical(tf.expand_dims(predictions, 0), num_samples=1)[0, 0].numpy()
        generated_ids.append(predicted_id)
        input_ids.append(predicted_id)

    return "".join([vocab[idx] for idx in generated_ids])

class GenerationCallback(tf.keras.callbacks.Callback):
    def on_epoch_end(self, epoch, logs=None):
        print(f"\n--- End of Epoch {epoch+1} ---")
        if epoch % 10 == 0:
            for temp in [0.1, 0.4, 0.7]:
                print(f"Seed: \"{SEED_TEXT}\" -> {produce_sample(self.model, seed=SEED_TEXT, num_generate=200, temperature=temp)}\n")

checkpoint_prefix = os.path.join(CHECKPOINT_DIR, "ckpt_{epoch}.weights.h5")
checkpoint_callback = tf.keras.callbacks.ModelCheckpoint(filepath=checkpoint_prefix, save_weights_only=True)

# Stop training when validation loss stops improving for 3 epochs
early_stopping = tf.keras.callbacks.EarlyStopping(
    monitor='val_loss',
    patience=3,
    restore_best_weights=True # Automatically rolls back to epoch 15 weights!
)

# ==========================================
# 6. START RUN
# ==========================================
if dataset is not None:
    model.fit(dataset, epochs=EPOCHS, callbacks=[GenerationCallback(), checkpoint_callback])
else:
    print(f"\n--- Inference Mode ({EMBEDDING_DIM}dim, {RNN_UNITS}units) ---")
    print(f"Result: {produce_sample(model, seed=SEED_TEXT, num_generate=300, temperature=0.6)}")
