import random
import time
import gurobipy as gp
import numpy as np
import traceback
from collections import defaultdict, deque


jobs_data = None
job_due_dates = None
num_jobs = 0
num_machines = 0
warmstart_schedule = None
warmstart_makespan = None
warmstart_sequencing = None
gurobi_makespan_lower_bound = None
lp_start_times = None
lp_assignments = None

import psutil
import os

global tabu_tenure
global beamwidth_val
global filterwidth_val


def print_gurobi_schedule(model, x_vars, c_vars, y_vars):
    """Prints the schedule from a Gurobi solution in a readable format."""
    schedule = []
    # Identify active assignments
    for (j_id, o_id, m_id), var in y_vars.items():
        if var.x > 0.5:
            start_time = x_vars[j_id, o_id].x
            completion_time = c_vars[j_id, o_id].x
            schedule.append((f"J{j_id+1}", o_id+1, m_id+1, start_time, completion_time))

    # Sort by Start Time
    sorted_schedule = sorted(schedule, key=lambda item: item[3])

    print(f"\n{'Job':<8} {'Op':<8} {'Machine':<10} {'Start Time':<15} {'Completion Time':<15}")
    print("-" * 60)
    for entry in sorted_schedule:
        print(f"{entry[0]:<8} {entry[1]:<8} {entry[2]:<10} {entry[3]:<15.2f} {entry[4]:<15.2f}")


def print_gurobi_schedule(model, x_vars, c_vars, y_vars):
    """Prints the schedule from a Gurobi solution in a readable format."""
    schedule = []
    
    # Iterate through assignment variables to find which machine was chosen for each operation
    for (j_id, o_id, m_id), var in y_vars.items():
        if var.x > 0.5:  # Check if the variable is active (assigned)
            start_time = x_vars[j_id, o_id].x
            completion_time = c_vars[j_id, o_id].x
            # Add to the schedule list as a tuple
            schedule.append((j_id, o_id, m_id, start_time, completion_time))
# Sort the schedule by start time
    sorted_schedule = sorted(schedule, key=lambda item: item[3])
    
    #output_text.insert(tk.END, "\n--- Final Schedule from Gurobi ---")
    print("\nJob\tOp\tMachine\tStart Time\tCompletion Time\n")
    
    for item in sorted_schedule:
        j_id, o_id, m_id, s_time, c_time = item
        print(f"{j_id}\t{o_id}\t{m_id}\t\t{s_time:.2f}\t\t{c_time:.2f}")






def print_fbs_schedule(schedule, jobs_data):
    """Prints the schedule from the FBS solver in a readable format."""
    #output_text.insert("\n--- Final Schedule from FBS ---")
    print("\nJob\tOp\tMachine\tStart Time\tCompletion Time\n")
    
    # The FBS schedule is a list of tuples: (job_id, op_id, m_id, start_time, proc_time)
    
    # Sort the schedule primarily by start time
    sorted_schedule = sorted(schedule, key=lambda x: x[3])
    
    for item in sorted_schedule:
        j_id, o_id, m_id, s_time, p_time = item
        c_time = s_time + p_time
        print(f"{j_id}\t{o_id}\t{m_id}\t\t{s_time:.2f}\t\t{c_time:.2f}")


try:
    p = psutil.Process(os.getpid())
    # Set priority to HIGH
    # On Windows: 'HIGH_PRIORITY_CLASS' (or 'ABOVE_NORMAL_PRIORITY_CLASS', 'NORMAL_PRIORITY_CLASS', 'BELOW_NORMAL_PRIORITY_CLASS', 'IDLE_PRIORITY_CLASS', 'REALTIME_PRIORITY_CLASS')
    # On Linux/macOS: requires a numerical 'nice' value (e.g., -20 for highest)
    # For cross-platform, psutil simplifies this
    p.nice(psutil.HIGH_PRIORITY_CLASS)
    print(f"Python process {os.getpid()} priority set to High.")
except Exception as e:
    print(f"Could not set Python process priority: {e}")




class Operation:
    def __init__(self, op_id, processing_times):
        self.op_id = op_id
        self.processing_times = processing_times

class Job:
    def __init__(self, job_id, operations):
        self.job_id = job_id
        self.operations = operations

class ScheduleNode:
    def __init__(self, scheduled_operations, job_completion_times, machine_completion_times, parent=None, decision=None):
        self.scheduled_operations = scheduled_operations
        self.job_completion_times = job_completion_times
        self.machine_completion_times = machine_completion_times
        self.parent = parent
        self.decision = decision
        self.cost = float('inf')

    @property
    def current_makespan(self):
        return max(self.job_completion_times.values()) if self.job_completion_times else 0

class FilteredBeamSearch:
    def __init__(self, jobs, job_due_dates, beamwidth, filterwidth, local_heuristic, global_heuristic, weights, makespan_lower_bound=0, lp_start_times=None, lp_assignments=None):
        self.jobs = jobs
        self.job_due_dates = job_due_dates
        self.beamwidth = beamwidth
        self.filterwidth = filterwidth
        self.weights = weights
        self.local_heuristic = local_heuristic
        self.global_heuristic = global_heuristic
        self.max_makespan = 1
        self.max_flow_time = 1
        self.max_tardiness = 1
        self.makespan_lower_bound = makespan_lower_bound
        self.lp_start_times = lp_start_times
        self.lp_assignments = lp_assignments

    def calculate_metrics(self, node):
        makespan = node.current_makespan
        total_flow_time = sum(node.job_completion_times.values())
        total_tardiness = 0
        for job_id, completion_time in node.job_completion_times.items():
            due_date = self.job_due_dates.get(job_id, float('inf'))
            total_tardiness += max(0, completion_time - due_date)
        self.max_makespan = max(self.max_makespan, makespan)
        self.max_flow_time = max(self.max_flow_time, total_flow_time)
        self.max_tardiness = max(self.max_tardiness, total_tardiness)
        return makespan, total_flow_time, total_tardiness

    def get_candidate_operations(self, scheduled_operations):
        candidates = {}
        for job_id, job in self.jobs.items():
            for op in job.operations:
                if (job_id, op.op_id) not in scheduled_operations:
                    if op.op_id == 1 or (job_id, op.op_id - 1) in scheduled_operations:
                        candidates[(job_id, op.op_id)] = op
                        break
        return candidates

    def run(self):
        root = ScheduleNode(scheduled_operations=set(), job_completion_times={}, machine_completion_times={})
        beam = [root]
        total_operations_to_schedule = sum(len(j.operations) for j in self.jobs.values())
        while beam:
            next_level_nodes = []
            for node in beam:
                candidates = self.get_candidate_operations(node.scheduled_operations)
                filtered_nodes = []
                for (job_id, op_id), op in candidates.items():
                    for machine_id, proc_time in op.processing_times.items():
                        machine_ready_time = node.machine_completion_times.get(machine_id, 0)
                        job_ready_time = node.job_completion_times.get(job_id, 0)
                        start_time = max(job_ready_time, machine_ready_time)
                        new_scheduled_operations = node.scheduled_operations.copy()
                        new_scheduled_operations.add((job_id, op_id))
                        new_machine_completion_times = node.machine_completion_times.copy()
                        new_machine_completion_times[machine_id] = start_time + proc_time
                        new_job_completion_times = node.job_completion_times.copy()
                        new_job_completion_times[job_id] = start_time + proc_time
                        new_node = ScheduleNode(
                            scheduled_operations=new_scheduled_operations,
                            job_completion_times=new_job_completion_times,
                            machine_completion_times=new_machine_completion_times,
                            parent=node,
                            decision=(job_id, op_id, machine_id, start_time, proc_time)
                        )
                        new_node.cost = self.local_heuristic(self, new_node, self.weights, proc_time, start_time)
                        filtered_nodes.append(new_node)
                filtered_nodes.sort(key=lambda n: n.cost)
                next_level_nodes.extend(filtered_nodes[:self.filterwidth])
            if not next_level_nodes:
                return beam[0]
            if len(next_level_nodes[0].scheduled_operations) == total_operations_to_schedule:
                return next_level_nodes[0]
            for node in next_level_nodes:
                node.cost = self.global_heuristic(self, node, self.weights, 0, 0)
            next_level_nodes.sort(key=lambda n: n.cost)
            beam = next_level_nodes[:self.beamwidth]
        return None




def reconstruct_schedule(final_node):
    schedule = []
    current_node = final_node
    while current_node and current_node.decision:
        schedule.append(current_node.decision)
        current_node = current_node.parent
    return list(reversed(schedule))





def _calculate_node_lower_bound(fbs_instance, node):
    lb_makespan = node.current_makespan
    for job_id, job in fbs_instance.jobs.items():
        if job_id in node.job_completion_times:
            remaining_proc_time = sum(
                min(op.processing_times.values())
                for op in job.operations[len(node.scheduled_operations) - 1:]
                if (job_id, op.op_id) not in node.scheduled_operations
            )
            lb_makespan = max(lb_makespan, node.job_completion_times[job_id] + remaining_proc_time)
    for machine_id in range(1, num_machines + 1):
        machine_completion_time = node.machine_completion_times.get(machine_id, 0)
        remaining_proc_time_on_machine = sum(
            o_obj.processing_times[machine_id]
            for j_id, job in fbs_instance.jobs.items()
            for op_id, o_obj in enumerate(job.operations, start=1)
            if (j_id, op_id) not in node.scheduled_operations and machine_id in o_obj.processing_times
        )
        lb_makespan = max(lb_makespan, machine_completion_time + remaining_proc_time_on_machine)
    return lb_makespan




def evaluation_makespan_from_lower_bound(fbs_instance, node, weights, *args):
    dynamic_lower_bound = _calculate_node_lower_bound(fbs_instance, node)
    return dynamic_lower_bound - fbs_instance.makespan_lower_bound




def evaluation_weighted_sum(fbs_instance, node, weights, *args):
    makespan, total_flow_time, total_tardiness = fbs_instance.calculate_metrics(node)
    w1, w2, w3 = weights
    normalized_makespan = makespan / fbs_instance.max_makespan if fbs_instance.max_makespan > 0 else 0
    normalized_flow_time = total_flow_time / fbs_instance.max_flow_time if fbs_instance.max_flow_time > 0 else 0
    normalized_tardiness = total_tardiness / fbs_instance.max_tardiness if fbs_instance.max_tardiness > 0 else 0
    return (w1 * normalized_makespan) + (w2 * normalized_flow_time) + (w3 * normalized_tardiness)




def evaluation_spt(fbs_instance, node, weights, proc_time, *args):
    return proc_time

def evaluation_eet(fbs_instance, node, weights, *args):
    start_time = node.decision[3] if node.decision else 0
    return start_time
    



def evaluation_lp_guidance(fbs_instance, node, weights, *args):
    if fbs_instance.lp_start_times is None or fbs_instance.lp_assignments is None:
        return 0
    job_id, op_id, machine_id, current_start_time, proc_time = node.decision
    lp_start_time = fbs_instance.lp_start_times.get((job_id, op_id), 0)
    lp_assignment_prob = fbs_instance.lp_assignments.get((job_id, op_id, machine_id), 0)
    start_time_deviation = abs(current_start_time - lp_start_time)
    machine_preference_cost = 1 - lp_assignment_prob
    weight_deviation = 0.5
    weight_preference = 0.5
    cost = (weight_deviation * start_time_deviation) + (weight_preference * machine_preference_cost)
    return cost




def create_random_problem_data(num_jobs, num_machines, min_ops, max_ops):
    jobs_data = {}
    job_due_dates = {}
    for i in range(1, num_jobs + 1):
        job_id = f'J{i}'
        num_operations = random.randint(min_ops, max_ops)
        operations = []
        job_total_proc_time = 0
        for j in range(1, num_operations + 1):
            processing_times = {}
            available_machines = random.sample(range(1, num_machines + 1), random.randint(3, 8))
            #available_machines = range(5, num_machines)


            for machine_id in available_machines:
                proc_time = random.randint(10, 100)
                processing_times[machine_id] = proc_time
            operations.append(Operation(j, processing_times))
            job_total_proc_time += sum(processing_times.values()) / len(processing_times)
        jobs_data[job_id] = Job(job_id, operations)
        job_due_dates[job_id] = int(job_total_proc_time * 1.5)


    print("\n--- Problem Details ---\n")
    total_operations = 0
    for job_id, job in jobs_data.items():
        num_ops = len(job.operations)
        total_operations += num_ops
        print(f"Job {job_id} | Operations: {num_ops} | Due Date: {job_due_dates.get(job_id, 'N/A')}")
        for op in job.operations:
            print(f"  - Operation {op.op_id}")
            for machine_id, proc_time in op.processing_times.items():
                print(f"    - Machine {machine_id} | Processing Time: {proc_time}")
        print(f"\nTotal operations to be scheduled: {total_operations}\n")
    return jobs_data, job_due_dates





def run_fbs_solver(local_heuristic, global_heuristic, makespan_lower_bound=0, lp_start_times=None, lp_assignments=None):
    choice_beam = input("\nENTER BEAM WIDTH: ")
    beamwidth_val = int(choice_beam) 
    choice_filter = input("ENTER FILTER WIDTH: ")
    filterwidth_val = int(choice_filter)
    
    global jobs_data, job_due_dates, warmstart_schedule, warmstart_makespan
    if jobs_data is None:
        print("Please generate a new problem first.\n")
        return None, None
    start_time = time.time()
    try:
        w1, w2, w3 = 1.0, 0.0, 0.0
        weights = (w1, w2, w3) if w1 + w2 + w3 == 0 else (w1 / (w1 + w2 + w3), w2 / (w1 + w2 + w3), w3 / (w1 + w2 + w3))
        heuristics_map = {
            "Weighted Sum": evaluation_weighted_sum,
            "SPT": evaluation_spt,
            "EET": evaluation_eet,
            "Makespan from LP": evaluation_makespan_from_lower_bound,
            "LP Guidance": evaluation_lp_guidance
        }
        local_heuristic_func = heuristics_map[local_heuristic]
        global_heuristic_func = heuristics_map[global_heuristic]
        
         
        #beamwidth_val = 3000
        #filterwidth_val = 3600
        fbs = FilteredBeamSearch(jobs=jobs_data, job_due_dates=job_due_dates, beamwidth=beamwidth_val, filterwidth=filterwidth_val, local_heuristic=local_heuristic_func, global_heuristic=global_heuristic_func, weights=weights, makespan_lower_bound=makespan_lower_bound, lp_start_times=lp_start_times, lp_assignments=lp_assignments)
        result_node = fbs.run()
        end_time = time.time()
        print("\n\n--- FBS Results ---\n")
        
        if result_node:
            makespan, total_flow_time, total_tardiness = fbs.calculate_metrics(result_node)
            print(f"F1 = {makespan:.2f} (M)      F2 = {total_flow_time:.2f} (f)      F3 = {total_tardiness:.2f} (T)")
            print(f"          Runtime: {end_time - start_time:.2f} seconds\n\n")
            warmstart_schedule = reconstruct_schedule(result_node)
            warmstart_makespan = makespan
            return warmstart_schedule, warmstart_makespan
        else:
            print("\nNo solution found.\n")
            return None, None
    except ValueError:
        print("Invalid input for weights, beamwidth, and filterwidth.\n")
        return None, None



def rebuild_schedule(assignment_list):
    global jobs_data, num_jobs, num_machines
    
    machine_occupancy = defaultdict(list)
    job_precedence = defaultdict(dict)

    for j_id, op_id, m_id, _, proc_time in assignment_list:
        machine_occupancy[m_id].append({
            'j_id': j_id,
            'op_id': op_id,
            'proc_time': proc_time,
            'start_time': 0,
            'completion_time': 0
        })
        job_precedence[j_id][op_id] = {'m_id': m_id, 'proc_time': proc_time}

    for m_id in machine_occupancy:
        machine_occupancy[m_id].sort(key=lambda op: op['op_id'])

    schedule_events = []
    
    job_completion_times = defaultdict(int)
    machine_completion_times = defaultdict(int)
    
    next_op_to_schedule = {j_id: 1 for j_id in jobs_data.keys()}

    total_operations = sum(len(job.operations) for job in jobs_data.values())
    scheduled_count = 0
    
    while scheduled_count < total_operations:
        earliest_start = float('inf')
        best_op_info = None
        
        for j_id, op_id in next_op_to_schedule.items():
            op_data = job_precedence[j_id].get(op_id)
            if op_data:
                m_id = op_data['m_id']
                proc_time = op_data['proc_time']
                
                job_ready_time = job_completion_times.get(j_id, 0)
                machine_ready_time = machine_completion_times.get(m_id, 0)
                
                start_time = max(job_ready_time, machine_ready_time)

                if start_time < earliest_start:
                    earliest_start = start_time
                    best_op_info = (j_id, op_id, m_id, proc_time)
        
        if not best_op_info:
            break

        j_id, op_id, m_id, proc_time = best_op_info
        
        start_time = max(job_completion_times[j_id], machine_completion_times[m_id])
        completion_time = start_time + proc_time

        job_completion_times[j_id] = completion_time
        machine_completion_times[m_id] = completion_time
        
        schedule_events.append((j_id, op_id, m_id, start_time, proc_time))
        next_op_to_schedule[j_id] = op_id + 1
        if op_id + 1 > len(jobs_data[j_id].operations):
            del next_op_to_schedule[j_id]
        
        scheduled_count += 1
    
    makespan = max(job_completion_times.values()) if job_completion_times else 0
    
    sequencing_decisions = {}
    machine_sequencing = defaultdict(list)
    for j_id, op_id, m_id, _, _ in sorted(schedule_events, key=lambda x: x[3]):
        machine_sequencing[m_id].append((j_id, op_id))
    
    for m_id, seq in machine_sequencing.items():
        for i in range(len(seq) - 1):
            op1, op2 = seq[i], seq[i+1]
            sequencing_decisions[tuple(sorted((op1, op2)) + [m_id])] = op1
            
    return schedule_events, makespan, sequencing_decisions




def get_critical_op_tuple(current_assignment):
    """
    Finds the operation tuple that finishes latest in the current schedule.
    This is a proxy for finding an operation on the Critical Path, as it
    identifies the bottleneck operation (which must be on the critical path
    to determine the Makespan).
    
    The operation tuple is assumed to be: 
    (job_id_idx, op_id_idx, machine_id, start_time, proc_time)
    
    Returns: The critical operation tuple.
    """
    max_finish_time = -1
    critical_op = None
    for op_tuple in current_assignment:
        # op_tuple: (job_id_idx, op_id_idx, machine_id, start_time, proc_time)
        if len(op_tuple) >= 5:
            finish_time = op_tuple[3] + op_tuple[4]
            if finish_time > max_finish_time:
                max_finish_time = finish_time
                critical_op = op_tuple
    return critical_op




def run_local_search_improved(initial_schedule, initial_makespan,LS,tab):
    
    #choice_tenure = input("\nENTER TABU TENURE: \n")
    #tabu_tenure = int(choice_tenure)
    tabu_tenure=int(tab)
    global jobs_data, num_jobs, gurobi_makespan_lower_bound
    print("\n--- Running LS ---")
    start_time = time.time()
    # Note: initial_schedule is the assignment list (j, o, m, start, proc)
    best_schedule, best_makespan = list(initial_schedule), initial_makespan
    best_sequencing = {}
    current_assignment = list(initial_schedule)
    max_iterations = 2400
    no_improvement_limit = 1000 
    no_improvement_count = 0
    
    
    tabu_list = deque(maxlen=tabu_tenure)

    all_operations_list = [op for op in current_assignment if len(op) >= 3] # Ensure list has valid operations
    
    for i in range(max_iterations):
        if i %200 == 0:
            print(f"Best makespan so far: {best_makespan:.2f}")
        if no_improvement_count >= 1400:
            print(f"Stop == {no_improvement_limit} iterations\n")
            print("--- LS Results ---")
            print(f"BEST MAKESPAN: {best_makespan:.2f}")
            
            break
        
        # Initialize variables for the new assignment list and move details
        forward_move = None
        new_assignment_list_raw = None # Temporarily holds the j, o, m changes, maintaining order
        
        # Variables to track the modified operations for later processing
        # Key: (job_id, op_id), Value: new_machine_id
        modified_op_info = {} 

        # Variables for Tabu updates
        op_to_change_tuple = None 
        machine_id_orig = None 
        job_a_idx, op_a_idx, job_b_idx, op_b_idx = None, None, None, None
        op_a, op_b, op_c = None, None, None
        machine_to_swap = None 

        # --- Move Type Selection ---
        if LS == 1: 
            move_type = 'reassignment' # Random Reassignment
        elif LS == 2: 
            move_type = 'critical_reassignment' # NEW: Targeted Reassignment
        elif LS == 3:
            move_type = 'machine_swap' # NEW: Larger Resource Perturbation
        elif LS == 4:
            move_type = '2-opt' # Moved Sequencing move (Original LS=2)
        elif LS == 5:
            move_type = '3-opt' # Moved Sequencing move (Original LS=3)
        else:
            continue

        # --- Move Generation ---

        # LS=1: Random Reassignment (Original Logic)
        if move_type == 'reassignment':
            if not all_operations_list:
                continue
            op_to_change_tuple = random.choice(all_operations_list)
            job_id_orig, op_id_orig, machine_id_orig, _, _ = op_to_change_tuple
            op_obj_orig = jobs_data[job_id_orig].operations[op_id_orig - 1]
            available_machines = list(op_obj_orig.processing_times.keys())
            
            if len(available_machines) <= 1:
                continue
            
            new_machine = random.choice([m for m in available_machines if m != machine_id_orig])
            
            forward_move = ('reassignment', job_id_orig, op_id_orig, new_machine)
            
            new_assignment_list = []
            for op in current_assignment:
                if op == op_to_change_tuple:
                    proc_time = op_obj_orig.processing_times[new_machine]
                    new_assignment_list.append((op[0], op[1], new_machine, 0, proc_time))
                else:
                    new_assignment_list.append(op)
        

            
        # LS=2: Critical Path Reassignment (Targeted, Greedy) - NEW
        elif move_type == 'critical_reassignment': 
            op_to_change_tuple = get_critical_op_tuple(current_assignment)
            if op_to_change_tuple is None:
                continue
                
            job_id_orig, op_id_orig, machine_id_orig = op_to_change_tuple[0], op_to_change_tuple[1], op_to_change_tuple[2]
            op_obj_orig = jobs_data[job_id_orig].operations[op_id_orig - 1] 
            available_machines = list(op_obj_orig.processing_times.keys()) 
             
            if len(available_machines) <= 1: 
                continue 
                
            # Find the GREEDY best alternative machine (min processing time)
            best_proc_time = float('inf')
            new_machine = None
            
            for m in available_machines:
                proc_time = op_obj_orig.processing_times[m]
                # Only consider alternative machines
                if m != machine_id_orig and proc_time < best_proc_time:
                    best_proc_time = proc_time
                    new_machine = m
                    
            if new_machine is None:
                continue
             
            forward_move = ('critical_reassignment', job_id_orig, op_id_orig, new_machine) 
            new_assignment_list_raw = list(current_assignment)
            modified_op_info[(job_id_orig, op_id_orig)] = new_machine
            
        # LS=3: Machine Swap (Larger Perturbation) - NEW
        elif move_type == 'machine_swap': 
            ops_on_machines = defaultdict(list) 
            for op_tuple in current_assignment: 
                # Ensure 5 elements are available, though we only care about j, o, m here
                if len(op_tuple) >= 5:
                    ops_on_machines[op_tuple[2]].append(op_tuple) 

            candidate_machines = list(ops_on_machines.keys())
            
            if len(candidate_machines) < 2:
                continue
                
            try:
                machine_a, machine_b = random.sample(candidate_machines, 2)
            except ValueError:
                continue 

            ops_a = ops_on_machines[machine_a]
            ops_b = ops_on_machines[machine_b]

            if not ops_a or not ops_b:
                continue

            op_a = random.choice(ops_a)
            op_b = random.choice(ops_b)
            
            job_a_idx, op_a_idx = op_a[0], op_a[1] 
            job_b_idx, op_b_idx = op_b[0], op_b[1]
            
            op_obj_a = jobs_data[job_a_idx].operations[op_a_idx - 1]
            op_obj_b = jobs_data[job_b_idx].operations[op_b_idx - 1]
            
            is_feasible_a_to_b = machine_b in op_obj_a.processing_times
            is_feasible_b_to_a = machine_a in op_obj_b.processing_times
            
            if not (is_feasible_a_to_b and is_feasible_b_to_a):
                continue

            forward_move = ('machine_swap', (job_a_idx, op_a_idx, machine_a), (job_b_idx, op_b_idx, machine_b))
            
            new_assignment_list_raw = list(current_assignment)
            # Operations swap machines
            modified_op_info[(job_a_idx, op_a_idx)] = machine_b
            modified_op_info[(job_b_idx, op_b_idx)] = machine_a

        # LS=4: 2-opt (Machine Sequencing) - MOVED from LS=2
        elif move_type == '2-opt':
            ops_on_machines = defaultdict(list)
            for op_tuple in current_assignment:
                if len(op_tuple) >= 3:
                    ops_on_machines[op_tuple[2]].append(op_tuple)

            candidate_machines = [m for m, ops in ops_on_machines.items() if len(ops) >= 2]
            if not candidate_machines:
                continue
            
            machine_to_swap = random.choice(candidate_machines)
            ops_to_swap = ops_on_machines[machine_to_swap]
            op1, op2 = random.sample(ops_to_swap, 2)
            
            # Find indices in the current_assignment list
            indices = sorted([current_assignment.index(op1), current_assignment.index(op2)])
            idx1, idx2 = indices[0], indices[1]
            
            op_a = current_assignment[idx1]
            op_b = current_assignment[idx2]

            forward_move = ('2-opt', (op_a[0], op_a[1]), (op_b[0], op_b[1]), machine_to_swap)
            
            new_assignment_list_raw = list(current_assignment)
            # Swap operations in the list
            new_assignment_list_raw[idx1], new_assignment_list_raw[idx2] = new_assignment_list_raw[idx2], new_assignment_list_raw[idx1]
            
        # LS=5: 3-opt (Machine Sequencing) - MOVED from LS=3
        elif move_type == '3-opt':
            ops_on_machines = defaultdict(list)
            for op_tuple in current_assignment:
                if len(op_tuple) >= 3:
                    ops_on_machines[op_tuple[2]].append(op_tuple)
            
            candidate_machines = [m for m, ops in ops_on_machines.items() if len(ops) >= 3]
            if not candidate_machines:
                continue

            machine_to_swap = random.choice(candidate_machines)
            ops_to_swap = ops_on_machines[machine_to_swap]
            op1, op2, op3 = random.sample(ops_to_swap, 3)

            indices = sorted([current_assignment.index(op1), current_assignment.index(op2), current_assignment.index(op3)])
            idx_a, idx_b, idx_c = indices[0], indices[1], indices[2]
            op_a, op_b, op_c = current_assignment[idx_a], current_assignment[idx_b], current_assignment[idx_c]
            
            forward_move = ('3-opt', (op_a[0], op_a[1]), (op_b[0], op_b[1]), (op_c[0], op_c[1]), machine_to_swap)
            
            new_assignment_list_raw = list(current_assignment)
            # Reversing the order of the three selected operations: [A, B, C] -> [C, B, A]
            new_assignment_list_raw[idx_a], new_assignment_list_raw[idx_b], new_assignment_list_raw[idx_c] = op_c, op_b, op_a


        if forward_move is None or new_assignment_list_raw is None:
            continue
            
        # --- Critical Step: Finalize Assignment List with Correct Proc Times ---
        # This is where the KeyError happened because 'j' was a string job ID
        new_assignment_list = []
        is_move_valid = True
        
        for op in new_assignment_list_raw:
            if len(op) < 3: continue 

            j, o, m = op[0], op[1], op[2] # Current job, op, machine from the raw sequence
            op_key = (j, o)

            # Check if this operation's machine assignment was modified by the move
            if op_key in modified_op_info:
                m = modified_op_info[op_key] # Use the new machine ID
            
            # Lookup the operation object
            op_obj = jobs_data[j].operations[o - 1]
            
            # Use the correct processing time for the assigned machine (m)
            if m in op_obj.processing_times:
                current_proc_time = op_obj.processing_times[m]
                # Append the 5-element tuple with START_TIME=0 and the correct PROC_TIME
                new_assignment_list.append((j, o, m, 0, current_proc_time))
            else:
                # This should not happen if the move generation was feasible, but serves as a safeguard
                print(f"Warning: Operation ({j}, {o}) cannot run on machine {m}. Skipping move.")
                is_move_valid = False
                break

        if not is_move_valid or forward_move in tabu_list:
            continue
        
        # New sequencing is the full 5-element list output by rebuild_schedule
        new_schedule, new_makespan, new_sequencing = rebuild_schedule(new_assignment_list)
        
        if new_makespan < best_makespan:
            # Aspiration criterion
            best_makespan = new_makespan
            best_schedule = new_schedule
            best_sequencing = new_sequencing
            current_assignment = new_schedule # The fully timed schedule
            no_improvement_count = 0
            
            # --- Tabu List Update ---
            if move_type == 'reassignment':
                reverse_move = ('reassignment', op_to_change_tuple[0], op_to_change_tuple[1], machine_id_orig)
                tabu_list.append(reverse_move)
            elif move_type == 'critical_reassignment': 
                reverse_move = ('critical_reassignment', job_id_orig, op_id_orig, machine_id_orig) 
                tabu_list.append(reverse_move) 
            elif move_type == 'machine_swap': 
                # Reverse move swaps back to the original machines
                # Store reverse swap in terms of the machines they are moving TO (the originals)
                reverse_move = ('machine_swap', (job_a_idx, op_a_idx, machine_b), (job_b_idx, op_b_idx, machine_a)) 
                tabu_list.append(reverse_move) 
            elif move_type == '2-opt':
                # Reverse: swap op_b and op_a back (sequence swap)
                reverse_move = ('2-opt', (op_b[0], op_b[1]), (op_a[0], op_a[1]), machine_to_swap)
                tabu_list.append(reverse_move)
            elif move_type == '3-opt':
                # Reverse: swap op_c, op_b, op_a back to op_a, op_b, op_c (sequence reversal)
                reverse_move = ('3-opt', (op_c[0], op_c[1]), (op_b[0], op_b[1]), (op_a[0], op_a[1]), machine_to_swap)
                tabu_list.append(reverse_move)
            
        else:
            no_improvement_count += 1
            tabu_list.append(forward_move) 

            
    end_time = time.time()
    print(f"Runtime:  {end_time - start_time:.2f} seconds")
    # Assuming gurobi_makespan_lower_bound is defined globally
    print(f"\n\nGUROBI: {gurobi_makespan_lower_bound:.2f}         FBS: {initial_makespan:.2f}         LS: {best_makespan:.2f}\n\n")
    

    return best_schedule, best_makespan, best_sequencing



def run_gurobi_lp_and_fbs(local_heuristic, global_heuristic):
    global jobs_data, num_jobs, num_machines, gurobi_makespan_lower_bound, lp_start_times, lp_assignments, warmstart_schedule, warmstart_makespan, warmstart_sequencing
    if jobs_data is None:
        print("\nPlease generate a new problem first.\n")
        return
    try:
        m = gp.Model("FlexibleJobShopLP")
        m.setParam("MIPFocus", 2)
        m.setParam("Outputflag", 0)
        m.setParam("Method", 1)
        m.setParam("NodeMethod", 1)
        #m.setParam("LazyConstraints", 1)
        m.setParam("Cuts", 2)
        m.setParam("Presolve", 2)
        m.setParam("Heuristics", 0.79)
        m.setParam("Threads", 0)
        m.setParam("DisplayInterval", 1)

        all_ops = [(j_id, op.op_id) for j_id, job in jobs_data.items() for op in job.operations]
        x = m.addVars(all_ops, vtype=gp.GRB.CONTINUOUS, name="Start_Time")
        c = m.addVars(all_ops, vtype=gp.GRB.CONTINUOUS, name="Completion_Time")
        y = m.addVars([(j_id, o.op_id, m_id) for j_id, job in jobs_data.items() for o in job.operations for m_id in o.processing_times], vtype=gp.GRB.CONTINUOUS, lb=0.0, ub=1.0, name="Assignment")
        Cmax = m.addVar(vtype=gp.GRB.CONTINUOUS, name="Cmax")
        m.setObjective(Cmax, gp.GRB.MINIMIZE)
        m.addConstrs((gp.quicksum(y[j_id, o.op_id, m_id] for m_id in o.processing_times) == 1 for j_id, job in jobs_data.items() for o in job.operations), name="One_Machine_Per_Op")
        for j_id, job in jobs_data.items():
            for op in job.operations:
                m.addConstr(c[j_id, op.op_id] == x[j_id, op.op_id] + gp.quicksum(y[j_id, op.op_id, m_id] * op.processing_times[m_id] for m_id in op.processing_times), name=f"CompletionTime_J{j_id}_O{op.op_id}")
        for j_id, job in jobs_data.items():
            last_op = job.operations[-1]
            m.addConstr(Cmax >= c[j_id, last_op.op_id], name=f"Makespan_J{j_id}")
        for j_id, job in jobs_data.items():
            for i in range(1, len(job.operations)):
                prev_op, curr_op = job.operations[i-1], job.operations[i]
                m.addConstr(x[j_id, curr_op.op_id] >= c[j_id, prev_op.op_id], name=f"Precedence_J{j_id}_O{curr_op.op_id}")
        start_time = time.time()
        m.optimize()
        end_time = time.time()
        print("\n--- Gurobi LP Relaxation Results ---")
        #print(f"Runtime: {end_time - start_time:.2f} seconds")
        gurobi_makespan_lower_bound = m.ObjVal
        print(f"Makespan Lower Bound: {gurobi_makespan_lower_bound:.2f}")
        lp_start_times = {op: var.x for op, var in x.items()}
        lp_assignments = {key: y[key].x for key in y.keys()}
        print("\n\n--- Running FBS ---")
        fbs_start_time = time.time()
        fbs_schedule, fbs_makespan = run_fbs_solver(local_heuristic="Weighted Sum", global_heuristic="LP Guidance")
        fbs_end_time = time.time()
        warmstart_schedule, warmstart_makespan = fbs_schedule, fbs_makespan
        #print(f"FBS Runtime: {fbs_end_time - fbs_start_time:.2f} seconds")
        print("\n--- Final Schedule from FBS ---")
        print_fbs_schedule(warmstart_schedule, jobs_data)
        


        if warmstart_schedule:
            #tab=12
            choice_tenure = input("\nENTER TABU TENURE: \n")
            tab = int(choice_tenure)
            

            # Initialize variables for the convergence check
            previous_makespan = warmstart_makespan
            stagnation_counter = 0

            for i in range(1, 4):  # Increased range for demonstration, loop will exit early if stagnated
              tab = tab + 1 * i
    
		# Store the makespan before the updates to check for changes later
              current_iteration_start_makespan = warmstart_makespan

    		# Run the local search sequences
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan, 6, tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan, random.randint(1, 6), tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan, 4, tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan, 3, tab)

    # CHECK FOR STAGNATION:
    # If the makespan has not improved (or changed) from the start of this iteration
              if warmstart_makespan == current_iteration_start_makespan:
                 stagnation_counter += 1
              else:
                 stagnation_counter = 0  # Reset counter if an improvement is found
    
    # Exit if no change for more than 6 iterations
              if stagnation_counter >= 2:
                 print(f"Stopping: warmstart_makespan has not changed for {stagnation_counter} iterations.")
                 break
                            
              #for j in range(1, 3):  # inner loop: 1 to 5
               #   warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,j,tab)
           

            for i in range(1, 3):  # outer loop: 1 to 10
              tab=tab-4  
           
              #tab=tab+2*i
              #choice_tenure = input("\nENTER TABU TENURE: \n")
              #tab = int(choice_tenure)

              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(1, 6),tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,4,tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,5,tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,6,tab)
                            
              for j in range(1, 3):  # inner loop: 1 to 5
                  warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,j,tab)
           

            for i in range(1,3):  # outer loop: 1 to 10
              tab=tab+2*i
              
              #tab=tab+2*i
              #choice_tenure = input("\nENTER TABU TENURE: \n")
              #tab = int(choice_tenure)

              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,6,tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(1, 6),tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,4,tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,3,tab)
                            
              for j in range(1, 2):  # inner loop: 1 to 5
                  warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,j,tab)
           

            for i in range(1, 2):  # outer loop: 1 to 10
              tab=tab-1*i   
           
              #tab=tab+2*i
              #choice_tenure = input("\nENTER TABU TENURE: \n")
              #tab = int(choice_tenure)

              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(1, 6),tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,4,tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,5,tab)
              warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,6,tab)
                            
              for j in range(1, 2):  # inner loop: 1 to 5
                  warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,j,tab)



            for j in range(1, 1):  # inner loop: 1 to 5
                tab=random.randint(5, 10)
         
                warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(1,3),tab)

                tab=random.randint(10, 15)
         
                warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(3,6),tab)

                
                tab=random.randint(15, 20)
         
                warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(1,3),tab)

                tab=random.randint(20, 35)
         
                warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(3,6),tab)



            for j in range(1, 1):  # inner loop: 1 to 5
                tab=random.randint(10, 20)
         
                warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(1,3),tab)

                tab=random.randint(20, 35)
         
                warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(3,6),tab)

                
                tab=random.randint(25, 40)
         
                warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(1,3),tab)

                tab=random.randint(40, 55)
         
                warmstart_schedule, warmstart_makespan, warmstart_sequencing = run_local_search_improved(warmstart_schedule, warmstart_makespan,random.randint(3,6),tab)




    except gp.GurobiError as e:
        print(f"Gurobi Error: {e}")
    except Exception as e:
        print(f"An error occurred: {e}")
        traceback.print_exc()



def run_gurobi_warmstart_solver(objective):
    global jobs_data, num_jobs, num_machines, warmstart_schedule, warmstart_makespan, warmstart_sequencing
    
    if jobs_data is None:
        print("\nPlease generate a new problem first.\n")
        return
    if warmstart_schedule is None or warmstart_sequencing is None:
        print("\nPlease run the Gurobi LP + FBS + Local Search solver first to generate a warmstart solution.\n")
        return
    
    print(f"\n--- Running Gurobi with Hard-MIP Warmstart ---")
    print(f"Warmstart solution makespan: {warmstart_makespan:.2f}")

    try:
        m = gp.Model("FlexibleJobShopWarmstart") 
        
        # Solver Settings
        m.setParam("MIPFocus", 2)
        m.setParam("Method", 1)
        m.setParam("NodeMethod", 1)
        m.setParam("Cuts", 3)
        m.setParam("Presolve", 2)
        m.setParam("Threads", 0)
        m.setParam("Heuristics", 0.6)

        m.setParam("PreSparsify", 1)  # Compress matrix representation for large models
        m.setParam("Disconnected", 2)
        m.setParam("Symmetry", 2)

        # Calculate a safe Big M (Sum of all processing times)
        bigM = 350

        # Variables
        all_ops = [(j_id, op.op_id) for j_id, job in jobs_data.items() for op in job.operations]
        x = m.addVars(all_ops, vtype=gp.GRB.CONTINUOUS, name="Start_Time")
        c = m.addVars(all_ops, vtype=gp.GRB.CONTINUOUS, name="Completion_Time")
        y = m.addVars([(j_id, o.op_id, m_id) for j_id, job in jobs_data.items() for o in job.operations for m_id in o.processing_times], vtype=gp.GRB.BINARY, name="Assignment")
        
        # Sequencing Variables
        z_keys = [(op1, op2, m_id) for i, op1 in enumerate(all_ops) for j, op2 in enumerate(all_ops) if i < j and op1[0] != op2[0] for m_id in range(1, num_machines + 1) if m_id in jobs_data[op1[0]].operations[op1[1]-1].processing_times and m_id in jobs_data[op2[0]].operations[op2[1]-1].processing_times]
        z = m.addVars(z_keys, vtype=gp.GRB.BINARY, name="Sequence")
        
        Cmax = m.addVar(vtype=gp.GRB.CONTINUOUS, name="Cmax")
        T = m.addVars(jobs_data.keys(), vtype=gp.GRB.CONTINUOUS, name="Tardiness")

        # 1. Assignment Constraints
        m.addConstrs((gp.quicksum(y[j_id, o.op_id, m_id] for m_id in o.processing_times) == 1 for j_id, job in jobs_data.items() for o in job.operations), name="One_Machine_Per_Op")

        # 2. Timing and Precedence
        for j_id, job in jobs_data.items():
            for op in job.operations:
                m.addConstr(c[j_id, op.op_id] == x[j_id, op.op_id] + gp.quicksum(y[j_id, op.op_id, m_id] * op.processing_times[m_id] for m_id in op.processing_times))
            
            for i in range(1, len(job.operations)):
                m.addConstr(x[j_id, job.operations[i].op_id] >= c[j_id, job.operations[i-1].op_id])
            
            # Tardiness and Makespan
            last_op = job.operations[-1]
            m.addConstr(Cmax >= c[j_id, last_op.op_id])
            m.addConstr(T[j_id] >= c[j_id, last_op.op_id] - job_due_dates[j_id])
            m.addConstr(T[j_id] >= 0)

        # 3. SEQUENCING CONSTRAINTS (The replacement for the callback)
        # This prevents two jobs from being on the same machine at once.
        for (op1, op2, m_id) in z_keys:
            p1 = jobs_data[op1[0]].operations[op1[1]-1].processing_times[m_id]
            p2 = jobs_data[op2[0]].operations[op2[1]-1].processing_times[m_id]

            # Logic: If both op1 and op2 are on machine m_id, one must precede the other
            # The + bigM * (2 - y... - y...) ensures this only applies if BOTH are on machine m_id
            m.addConstr(x[op1] + p1 <= x[op2] + bigM * (1 - z[op1, op2, m_id]) + bigM * (2 - y[op1[0], op1[1], m_id] - y[op2[0], op2[1], m_id]))
            m.addConstr(x[op2] + p2 <= x[op1] + bigM * z[op1, op2, m_id] + bigM * (2 - y[op1[0], op1[1], m_id] - y[op2[0], op2[1], m_id]))

        # 4. Objective Setting
        if objective == "Makespan (F1)":
            m.setObjective(Cmax, gp.GRB.MINIMIZE)
        elif objective == "Total Flow Time (F2)":
            m.setObjective(gp.quicksum(c[j_id, job.operations[-1].op_id] for j_id, job in jobs_data.items()), gp.GRB.MINIMIZE)
        else:
            m.setObjective(Cmax, gp.GRB.MINIMIZE)

        # 5. Apply Warmstart
        for j_id, op_id, m_id, start_time, proc_time in warmstart_schedule:
            x[j_id, op_id].start = start_time
            y[j_id, op_id, m_id].start = 1
        
        for (op1, op2, m_id), first_op in warmstart_sequencing.items():
            if (op1, op2, m_id) in z:
                z[op1, op2, m_id].start = 1 if op1 == first_op else 0

        # 6. Solve and Print
        start_exec_time = time.time()
        m.optimize() # NO CALLBACK HERE
        end_exec_time = time.time()

        print("\n--- Gurobi Solver Results ---")
        print(f"Runtime: {end_exec_time - start_exec_time:.2f} seconds")
        
        if m.status == gp.GRB.OPTIMAL:
            print(f"Optimal Result: {m.ObjVal:.2f}")
            f1 = Cmax.x
            f2 = sum(c[j_id, job.operations[-1].op_id].x for j_id, job in jobs_data.items())
            print(f"F1 (Makespan) = {f1:.2f} | F2 (Flow Time) = {f2:.2f}")
            print_gurobi_schedule(m, x, c, y)
        else:
            print("Optimal solution not found.")

    except Exception as e:
        print(f"An error occurred: {e}")
        
# Print the headers exactly as requested
        print(f"\n {'Nodes':>10} | {'Current Node':>16} | {'Objective Bounds':>22} | {'Work':>10}")
        print(f" {'Expl Unexpl':>10} | {'Obj Depth IntInf':>16} | {'Incumbent BestBd Gap':>22} | {'It/Node Time':>10}\n")

        # Print the final iteration line using Gurobi's final state attributes
        print(f"{int(m.NodeCount):7d} {0:5d} {m.ObjVal:12.5f} {'-':>4} {'-':>4} {m.ObjVal:10.5f} {m.ObjBound:10.5f} {m.MIPGap*100:5.2f}% {m.IterCount/m.NodeCount:5.1f} {m.Runtime:5.0f}s")
        
        end_time = time.time()
        print("\n--- Gurobi Solver Results with Warmstart ---")
        print(f"\nRuntime: {end_time - start_time:.2f} seconds")
        #print_gurobi_schedule(m, x, c, y)
        if m.status == gp.GRB.OPTIMAL:
            print(f"\nOptimal {objective_name}: {m.ObjVal:.2f}\n")
            final_makespan = Cmax.x
            final_flow_time = sum(c[j_id, job.operations[-1].op_id].x for j_id, job in jobs_data.items())
            final_tardiness = sum(T[j_id].x for j_id in jobs_data.keys())
            print(f"Final Makespan: {final_makespan:.2f}")
            print(f"Total Flow Time: {final_flow_time:.2f}")
            print(f"Total Tardiness: {final_tardiness:.2f}")    
            print_gurobi_schedule(m, x, c, y)
        elif m.status == gp.GRB.INFEASIBLE:
            print("\nModel is infeasible.\n")
        else:
            print(f"\nSolver finished with status: {m.status}\n")
            
    except gp.GurobiError as e:
        print(f"Gurobi Error: {e}")
    except Exception as e:
        print(f"An error occurred: {e}")
        traceback.print_exc()
      
def run_full_pipeline():
    
    print("\n--- Running Gurobi LP + FBS + Local Search ---")
    run_gurobi_lp_and_fbs(local_heuristic="Weighted Sum", global_heuristic="LP Guidance")
    
def run_solver_menu():
    global jobs_data, num_jobs, num_machines
    if jobs_data is None:
        print("\nPlease generate a problem first.\n")
        return
    print("\nProblem Data:")
    print(f"Number of jobs: {num_jobs}")
    print(f"Number of machines: {num_machines}")
    total_operations = sum(len(j.operations) for j in jobs_data.values())

    print(f"Total operations to be scheduled: {total_operations}\n")
    while True:

        

        print("Select an algorithm to run:")
        print("1. Gurobi LP + FBS + Local Search")
        print("2. Gurobi with Warmstart (run Gurobi LP + FBS + Local Search first)")
        print("3. Gurobi MIP (run Gurobi MIP)")
        print("4. Exit")
        choice = input("Enter the number of your choice: ")
        if choice == '1':
            
            run_full_pipeline()
            

        elif choice == '2':
            print("\n--- Gurobi with Warmstart Selected ---")
            objective = input("Select objective (Makespan (F1), Total Flow Time (F2), Total Tardiness (F3), or Weighted Sum): ")
            run_gurobi_warmstart_solver(objective)

        elif choice == '3':
            print("\n--- Gurobi MIP ---")
            run_gurobi_mip_solver()

        elif choice == '4':
            print("\nExiting solver menu.")
            return
        else:
            print("Invalid choice, please try again.")
     



def run_gurobi_mip_solver():
    global jobs_data, num_jobs, num_machines, warmstart_schedule, warmstart_makespan
    if jobs_data is None:
        output_text.insert(tk.END, "\nPlease generate a new problem first.\n")
        return
    try:
        # Clear warmstart data for a clean run
        warmstart_schedule = None
        m = gp.Model("FlexibleJobShop")
        #m.Params.TimeLimit = 300.0
        # Gurobi parameters
        m.setParam("MIPFocus", 2)
        m.setParam("Method", 1)
        m.setParam("NodeMethod", 0)
        m.setParam("Cuts", 1)
        m.setParam("Presolve", 2)
        m.setParam("Threads", 16)
        m.setParam("Heuristics", 0.49)

        all_ops = [(j_id, op.op_id) for j_id, job in jobs_data.items() for op in job.operations]
        x = m.addVars(all_ops, vtype=gp.GRB.CONTINUOUS, name="Start_Time")
        c = m.addVars(all_ops, vtype=gp.GRB.CONTINUOUS, name="Completion_Time")
        y = m.addVars([(j_id, o.op_id, m_id) for j_id, job in jobs_data.items() for o in job.operations for m_id in o.processing_times], vtype=gp.GRB.BINARY, name="Assignment")
        z = m.addVars([(op1, op2, m_id) for i, op1 in enumerate(all_ops) for j, op2 in enumerate(all_ops) if i < j and op1[0] != op2[0] for m_id in range(1, num_machines + 1) if m_id in jobs_data[op1[0]].operations[op1[1]-1].processing_times and m_id in jobs_data[op2[0]].operations[op2[1]-1].processing_times], vtype=gp.GRB.BINARY, name="Sequence")
        Cmax = m.addVar(vtype=gp.GRB.CONTINUOUS, name="Cmax")
        T = m.addVars(jobs_data.keys(), vtype=gp.GRB.CONTINUOUS, name="Tardiness")
        bigM = 350
        
        m.addConstrs((gp.quicksum(y[j_id, o.op_id, m_id] for m_id in o.processing_times) == 1 for j_id, job in jobs_data.items() for o in job.operations), name="One_Machine_Per_Op")
        for j_id, job in jobs_data.items():
            for op in job.operations:
                m.addConstr(c[j_id, op.op_id] == x[j_id, op.op_id] + gp.quicksum(y[j_id, op.op_id, m_id] * op.processing_times[m_id] for m_id in op.processing_times), name=f"CompletionTime_J{j_id}_O{op.op_id}")
        for j_id, job in jobs_data.items():
            last_op = job.operations[-1]
            m.addConstr(Cmax >= c[j_id, last_op.op_id], name=f"Makespan_J{j_id}")
        for j_id, job in jobs_data.items():
            for i in range(1, len(job.operations)):
                prev_op = job.operations[i-1]
                curr_op = job.operations[i]
                m.addConstr(x[j_id, curr_op.op_id] >= c[j_id, prev_op.op_id], name=f"Precedence_J{j_id}_O{curr_op.op_id}")
        for i, op1 in enumerate(all_ops):
            for j, op2 in enumerate(all_ops):
                if i < j and op1[0] != op2[0]:
                    op1_obj = jobs_data[op1[0]].operations[op1[1]-1]
                    op2_obj = jobs_data[op2[0]].operations[op2[1]-1]
                    common_machines = set(op1_obj.processing_times.keys()) & set(op2_obj.processing_times.keys())
                    for m_id in common_machines:
                        m.addConstr(x[op1[0], op1[1]] + op1_obj.processing_times[m_id] <= x[op2[0], op2[1]] + bigM * (1 - z[op1, op2, m_id]) + bigM * (1 - y[op1[0], op1[1], m_id]) + bigM * (1 - y[op2[0], op2[1], m_id]))
                        m.addConstr(x[op2[0], op2[1]] + op2_obj.processing_times[m_id] <= x[op1[0], op1[1]] + bigM * z[op1, op2, m_id] + bigM * (1 - y[op1[0], op1[1], m_id]) + bigM * (1 - y[op2[0], op2[1], m_id]))
        for j_id, job in jobs_data.items():
            last_op = job.operations[-1]
            due_date = job_due_dates[j_id]
            m.addConstr(T[j_id] >= c[j_id, last_op.op_id] - due_date, name=f"TardinessDef_J{j_id}")
            m.addConstr(T[j_id] >= 0, name=f"TardinessNonNeg_J{j_id}")
            selected_objective = "Makespan (F1)"
            m.setObjective(Cmax, gp.GRB.MINIMIZE)
            objective_name = "Makespan (F1)"
        
        start_time = time.time()
        m.optimize()
        end_time = time.time()

    
        print("\n--- Gurobi Solver Results ---")
        print(f"Runtime: {end_time - start_time:.2f} seconds")

        if m.status == gp.GRB.OPTIMAL:
          print(f"Optimal {objective_name}: {m.ObjVal:.2f}\n")
    
          final_makespan = Cmax.x
          final_flow_time = sum(c[j_id, job.operations[-1].op_id].x for j_id, job in jobs_data.items())
          final_tardiness = sum(v.x for v in T.values())
          print(f"F1 = {final_makespan:.2f} (M)       F2 = {final_flow_time:.2f} (f)       F3 = {final_tardiness:.2f} (T)\n")
        else:
          print("\nGurobi did not find an optimal solution within the time limit.\n")

    except gp.GurobiError as e:
       print(f"Gurobi Error: {e.errno} - {e}")
       print("Please ensure you have a valid Gurobi installation and license.\n")
    except Exception as e:
       print(f"An error occurred: {e}")

        




def main_menu():
    global jobs_data, job_due_dates, num_jobs, num_machines
    num_jobs, num_machines, min_ops, max_ops = 14, 10, 5, 5
    jobs_data, job_due_dates = create_random_problem_data(num_jobs, num_machines, min_ops, max_ops)
    print("\nRandom problem instance created with hardcoded values.")
    while True:
        print("\nMain Menu")
        print("1. Run Solver Menu")
        print("2. Exit")
        choice = input("Enter the number of your choice: ")
        if choice == '1':
            run_solver_menu()
        elif choice == '2':
            print("Exiting application.")
            break
        else:
            print("Invalid choice, please try again.")

if __name__ == "__main__":
    
    main_menu()