import subprocess
import datetime
import itertools
import re
import time
import yaml
import fnmatch
from pathlib import Path
from typing import Any, Dict, List, Optional
from rich.console import Console

from sbatchman.core.config_manager import load_local_config
from sbatchman.core.job import Job, Status
from sbatchman.core.jobs_manager import job_exists, register_job, count_active_jobs
from sbatchman.exceptions import ConfigurationError, ClusterNameNotSetError, ConfigurationNotFoundError, JobExistsError, JobSubmitError
from sbatchman.config.global_config import get_cluster_name, get_max_queued_jobs
from sbatchman.config.project_config import get_project_config_dir, get_scheduler_from_cluster_name

from sbatchman.config.project_config import get_experiments_dir
from sbatchman.schedulers.pbs import pbs_submit
from sbatchman.schedulers.slurm import slurm_submit

console = Console()

DEFAULT_QUEUE_WAIT_INTERVAL = 30  # seconds to wait between queue checks

def wait_for_queue_slot(
  max_jobs: Optional[int] = None,
  wait_interval: int = DEFAULT_QUEUE_WAIT_INTERVAL
) -> None:
  """
  Waits until there is a slot available in the job queue.
  
  Args:
    max_jobs: Maximum number of allowed queued/running jobs. If None, uses the global setting.
    wait_interval: Seconds to wait between checks.
  """
  if max_jobs is None:
    max_jobs = get_max_queued_jobs()
  
  if max_jobs is None:
    return  # No limit configured
  
  while True:
    current_count = count_active_jobs()
    if current_count < max_jobs:
      return
    
    console.print(
      f"[yellow]Queue limit reached ({current_count}/{max_jobs} jobs). "
      f"Waiting {wait_interval}s for a slot...[/yellow]"
    )
    time.sleep(wait_interval)


def job_submit(job: Job, force: bool = False, previous_job_id: Optional[int] = None):
  try:
    config_cluster_name = get_cluster_name()
    if job.cluster_name is None: # Use global cluster name if not provided
      job.cluster_name = config_cluster_name
    elif job.cluster_name != config_cluster_name: # Mismatch in cluster names
      raise JobSubmitError(
        f"Cluster name '{job.cluster_name}' does not match the globally set cluster name '{config_cluster_name}'. "
        "You may be running jobs meant for a different cluster. If you want to change this cluster name, use 'sbatchman set-cluster-name <cluster_name>' to set a new global default."
      )
  except ClusterNameNotSetError:
    if not job.cluster_name:
      raise ConfigurationError(
        "Cluster name not specified and not set globally. "
        "Please provide '--cluster-name' or use 'sbatchman set-cluster-name <cluster_name>' to set a global default."
      )
  
  scheduler = get_scheduler_from_cluster_name(job.cluster_name)

  config_path = get_project_config_dir() / job.cluster_name / f"{job.config_name}.sh"
  if not config_path.exists():
    raise ConfigurationNotFoundError(f"Configuration '{job.config_name}' for cluster '{job.cluster_name}' not found at '{config_path}'.")
  template_script = open(config_path, "r").read()


  if job_exists(job.command, job.config_name, job.cluster_name, job.tag, job.preprocess, job.postprocess) and not force:
    raise JobExistsError(
      f"An identical job already exists for config '{job.config_name}' with tag '{job.tag}'. "
      "Use '--force' to submit it anyway"
    )

  # Capture the Current Working Directory at the time of launch
  submission_cwd = Path.cwd()
    
  # 2. Create a unique, nested directory for this experiment run
  timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
  # Directory structure: <cluster_name>/<config_name>/<tag>/<timestamp>
  # Find a directory name that has not been used yet
  base_exp_dir_local = Path(job.cluster_name) / job.config_name / job.tag / timestamp
  exp_dir_local = base_exp_dir_local
  exp_dir = get_experiments_dir() / exp_dir_local
  counter = 1
  while exp_dir.exists():
    exp_dir_local = base_exp_dir_local.with_name(f"{base_exp_dir_local.name}_{counter}")
    exp_dir = get_experiments_dir() / exp_dir_local
    counter += 1
  exp_dir.mkdir(parents=True, exist_ok=False)

  # 3. Prepare the final runnable script
  # Replace placeholders for log and CWD
  final_script_content = template_script.replace(
    "{JOB_NAME}", f'{job.tag}-{job.config_name}'
  ).replace(
    "{EXP_DIR}", str(exp_dir.resolve())
  ).replace(
    "{CWD}", str(submission_cwd.resolve())
  ).replace(
    "{PREPROCESS}", str(job.preprocess) if job.preprocess is not None else ''
  ).replace(
    "{CMD}", str(job.command)
  ).replace(
    "{POSTPROCESS}", str(job.postprocess) if job.postprocess is not None else ''
  )
  
  run_script_path = exp_dir / "run.sh"
  with open(run_script_path, "w") as f:
    f.write(final_script_content)
  run_script_path.chmod(0o755)

  queued_ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S.%f")
  job = Job(
    config_name=job.config_name,
    cluster_name=job.cluster_name,
    exp_dir=str(exp_dir_local),
    command=job.command,
    status=Status.SUBMITTING.value,
    scheduler=scheduler,
    job_id=0,
    tag=job.tag,
    archive_name=None,
    preprocess=job.preprocess,
    postprocess=job.postprocess,
    variables=job.variables if job.variables is not None else {},
    queued_timestamp=queued_ts,
  )

  job.write_metadata()
  
  # Register in cache to speed up subsequent checks in the same process
  register_job(job)

  try:
    # 5. Submit the job using the scheduler's own logic
    if scheduler == 'slurm':
      job.job_id = slurm_submit(run_script_path, exp_dir, previous_job_id)
    elif scheduler == 'pbs':
      job.job_id = pbs_submit(run_script_path, exp_dir, previous_job_id)
    elif scheduler == 'local':
      console.print(f"✅ Submitting job with command '[bold cyan]{job.command}[/bold cyan]'.")
      config = load_local_config(job.config_name)
      if config is None:
        raise ConfigurationError(f'Couldn\'t find configuration `{job.config_name}`')
      job.job_id, timed_out = config.local_submit(run_script_path, exp_dir)
      if timed_out:
        job.status = Status.TIMEOUT.value
        job.end_timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S.%f")
        job.write_job_status()
    else:
      raise JobSubmitError(f"No submission class found for scheduler '{scheduler}'. Supported schedulers are: slurm, pbs, local.")
    
    job.write_metadata(override_status=False)
  
  except (ValueError, FileNotFoundError) as e:
    job.status = Status.FAILED_SUBMISSION.value
    job.end_timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S.%f")
    job.write_metadata()
    err_str = "Failed to submit job. Error: " + str(e)
    with open(job.get_stderr_path(), 'w+') as err_file:
      err_file.write(err_str)
    raise JobSubmitError(err_str) from e
  except subprocess.CalledProcessError as e:
    job.status = Status.FAILED_SUBMISSION.value
    job.end_timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S.%f")
    job.write_metadata()
    err_str = f"Job submission failed with error code {e.returncode}.\nOutput stream:\n" + e.output + "\nError stream:\n" + e.stderr if e.stderr else ""
    with open(job.get_stderr_path(), 'w+') as err_file:
      err_file.write(err_str)
    raise JobSubmitError(err_str) from e


def launch_job(
  config_name: str,
  command: str,
  cluster_name: Optional[str] = None,
  tag: str = "notag",
  preprocess: Optional[str] = None,
  postprocess: Optional[str] = None,
  force: bool = False,
  previous_job_id: Optional[int] = None,
  variables: Optional[Dict[str, Any]] = None,
  dry_run: bool = False,
  max_queued_jobs: Optional[int] = None,
) -> Job:
  """
  Launches an experiment based on a configuration name.
  Args:
    config_name: The name of the configuration to use.
    command: The command to run for this job.
    cluster_name: Optional; if not provided, will use the global cluster name.
    tag: A tag for this experiment run, used in directory structure.
    preprocess: Optional; a command to run before the main command.
    postprocess: Optional; a command to run after the main command.
    previous_job_id: Optional; if this is set, the job will be only launched after the previous is done.
    max_queued_jobs: Optional; if set, will wait before submitting if the queue has this many jobs.
  Returns:
    A Job object representing the launched job.
  Raises:
    ConfigurationError: If there is a mismatch in cluster names or if the cluster name is not set.
    ClusterNameNotSetError: If the cluster name is not set globally and not provided.
    ConfigurationNotFoundError: If the configuration file does not exist.
    JobSubmitError: If there is an error during job submission.
  """

  try:
    config_cluster_name = get_cluster_name()
    if cluster_name is None: # Use global cluster name if not provided
      cluster_name = config_cluster_name
    elif cluster_name != config_cluster_name: # Mismatch in cluster names
      raise JobSubmitError(
        f"Cluster name '{cluster_name}' does not match the globally set cluster name '{config_cluster_name}'. "
        "You may be running jobs meant for a different cluster. If you want to change this cluster name, use 'sbatchman set-cluster-name <cluster_name>' to set a new global default."
      )
  except ClusterNameNotSetError:
    if not cluster_name:
      raise ConfigurationError(
        "Cluster name not specified and not set globally. "
        "Please provide '--cluster-name' or use 'sbatchman set-cluster-name <cluster_name>' to set a global default."
      )
  
  scheduler = get_scheduler_from_cluster_name(cluster_name)

  config_path = get_project_config_dir() / cluster_name / f"{config_name}.sh"
  if not config_path.exists():
    raise ConfigurationNotFoundError(f"Configuration '{config_name}' for cluster '{cluster_name}' not found at '{config_path}'.")
  template_script = open(config_path, "r").read()

  # Wait for queue slot if limit is configured (skip for dry runs)
  if not dry_run:
    wait_for_queue_slot(max_queued_jobs)

  if not force and job_exists(command, config_name, cluster_name, tag, preprocess, postprocess):
    raise JobExistsError(
      f"An identical job already exists for config '{config_name}' with tag '{tag}'. "
      "Use '--force' to submit it anyway"
    )

  # Capture the Current Working Directory at the time of launch
  submission_cwd = Path.cwd()
    
  # 2. Create a unique, nested directory for this experiment run
  timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
  # Directory structure: <cluster_name>/<config_name>/<tag>/<timestamp>
  # Find a directory name that has not been used yet
  base_exp_dir_local = Path(cluster_name) / config_name / tag / timestamp
  exp_dir_local = base_exp_dir_local
  exp_dir = get_experiments_dir() / exp_dir_local
  counter = 1
  while exp_dir.exists():
    exp_dir_local = base_exp_dir_local.with_name(f"{base_exp_dir_local.name}_{counter}")
    exp_dir = get_experiments_dir() / exp_dir_local
    counter += 1
  if not dry_run:
    exp_dir.mkdir(parents=True, exist_ok=False)

  # 3. Prepare the final runnable script
  # Replace placeholders for log and CWD
  final_script_content = template_script.replace(
    "{JOB_NAME}", f'{tag}-{config_name}'
  ).replace(
    "{EXP_DIR}", str(exp_dir.resolve())
  ).replace(
    "{CWD}", str(submission_cwd.resolve())
  ).replace(
    "{PREPROCESS}", str(preprocess) if preprocess is not None else ''
  ).replace(
    "{CMD}", str(command)
  ).replace(
    "{POSTPROCESS}", str(postprocess) if postprocess is not None else ''
  )
  
  if not dry_run:
    run_script_path = exp_dir / "run.sh"
    with open(run_script_path, "w") as f:
      f.write(final_script_content)
    run_script_path.chmod(0o755)

  queued_ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S.%f")
  job = Job(
    config_name=config_name,
    cluster_name=cluster_name,
    exp_dir=str(exp_dir_local),
    command=command,
    status=Status.SUBMITTING.value,
    scheduler=scheduler,
    job_id=0,
    tag=tag,
    archive_name=None,
    preprocess=preprocess,
    postprocess=postprocess,
    variables=variables if variables is not None else {},
    queued_timestamp=queued_ts,
  )

  if dry_run:
    return job

  job.write_metadata()

  try:
    # 5. Submit the job using the scheduler's own logic
    if scheduler == 'slurm':
      job.job_id = slurm_submit(run_script_path, exp_dir, previous_job_id)
    elif scheduler == 'pbs':
      job.job_id = pbs_submit(run_script_path, exp_dir, previous_job_id)
    elif scheduler == 'local':
      console.print(f"✅ Submitting job with command '[bold cyan]{job.command}[/bold cyan]'.")
      config = load_local_config(config_name)
      if config is None:
        raise ConfigurationError(f'Couldn\'t find configuration `{config_name}`')
      job.job_id, timed_out = config.local_submit(run_script_path, exp_dir)
      if timed_out:
        job.status = Status.TIMEOUT.value
        job.end_timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S.%f")
        job.write_job_status()
    else:
      raise JobSubmitError(f"No submission class found for scheduler '{scheduler}'. Supported schedulers are: slurm, pbs, local.")
    
    job.write_metadata(override_status=False)
  
  except (ValueError, FileNotFoundError) as e:
    job.status = Status.FAILED_SUBMISSION.value
    job.end_timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S.%f")
    job.write_metadata()
    err_str = "Failed to submit job. Error: " + str(e)
    with open(job.get_stderr_path(), 'w+') as err_file:
      err_file.write(err_str)
    raise JobSubmitError(err_str) from e
  except subprocess.CalledProcessError as e:
    job.status = Status.FAILED_SUBMISSION.value
    job.end_timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S.%f")
    job.write_metadata()
    err_str = f"Job submission failed with error code {e.returncode}.\nOutput stream:\n" + e.output + "\nError stream:\n" + e.stderr if e.stderr else ""
    with open(job.get_stderr_path(), 'w+') as err_file:
      err_file.write(err_str)
    raise JobSubmitError(err_str) from e
  finally:    
    return job


def _load_variable_values(var_value):
  # If var_value is a list, return as is
  if isinstance(var_value, list):
    return var_value
  # If var_value is a string and a file, read lines
  elif isinstance(var_value, str):
    path = Path(var_value)
    if path.is_file():
      with open(path, "r") as f:
        return [line.strip().replace('\n', '') for line in f if line.strip()]
    elif path.is_dir():
      # Return sorted list of file names in the directory
      return sorted([str(p.absolute()) for p in path.iterdir() if p.is_file()])
    else:
      raise JobSubmitError(
        f"Variable value '{var_value}' is not a list, file, or directory.\n"
        "YAML script semantics:\n"
        "- Variables can be lists, a path to a file (one value per line), or a path to a directory (all file absolute paths used as values).\n"
        "- The cartesian product of all variable values is used to generate jobs.\n"
        "- Experiments can define configuration names (possibly using variables) and tags.\n"
        "- 'command' and 'variables' can be redefined or extended in inner YAML tags.\n"
        "- The '{var_name}' syntax is substituted with the actual value of 'var_name'."
      )
  else:
    return [var_value]


def _merge_dicts(base, override):
  # Recursively merge two dictionaries
  result = dict(base)
  for k, v in override.items():
    if k in result and isinstance(result[k], dict) and isinstance(v, dict):
      result[k] = _merge_dicts(result[k], v)
    else:
      result[k] = v
  return result


def _substitute(template, variables):
  # Replace {var} in template with values from variables
  if not isinstance(template, str):
    return template
  return template.format(**variables)


def _extract_used_vars(*templates):
  """Extract variable names used in {var} format from given templates."""
  var_names = set()
  for template in templates:
    if isinstance(template, str):
      var_names.update(re.findall(r"{(\w+)}", template))
  return var_names


def _should_skip_job(
  job_tag: str,
  var_dict: Dict[str, Any],
  filter_tags: Optional[List[str]],
  filter_variables: Optional[Dict[str, Any]],
) -> bool:
  """
  Returns True if the job should be skipped based on the filter criteria.
  
  Args:
    job_tag: The substituted tag for the job.
    var_dict: The variable dictionary for this job combination.
    filter_tags: If provided, only allow jobs whose tag matches one of these (supports wildcards).
    filter_variables: If provided, only allow jobs where variables match all key=value pairs.
  """
  # Check tag filter (supports wildcards)
  if filter_tags is not None:
    matched = False
    for pattern in filter_tags:
      if fnmatch.fnmatch(job_tag, pattern):
        matched = True
        break
    if not matched:
      return True
  
  # Check variable filter
  if filter_variables is not None:
    for key, value in filter_variables.items():
      if key not in var_dict:
        return True
      # Convert both to string for comparison (variables from CLI are strings)
      if str(var_dict[key]) != str(value):
        return True
  
  return False


def launch_jobs_from_file(
  jobs_file_path: Path,
  force: bool = False,
  dry_run: bool = False,
  filter_tags: Optional[List[str]] = None,
  filter_variables: Optional[Dict[str, Any]] = None,
) -> List[Job]:
  """  Launches jobs based on a YAML configuration file.
  Args:
    jobs_file_path: Path to the YAML file containing job definitions.
    force: If True, will overwrite existing jobs with the same configuration.
    dry_run: If True, will return the list of jobs but will not launch them (warning: won't work for sequential jobs)
    filter_tags: If provided, only launch jobs whose tag matches one of these values.
    filter_variables: If provided, only launch jobs where variables match all key=value pairs.
  Returns:
    A list of Job objects representing the launched jobs.
  Raises:
    ConfigurationError: If the jobs file is not found or has invalid syntax.
  """

  with open(jobs_file_path, "r") as f:
    config = yaml.safe_load(f)

  global_is_sequential = bool(config.get("sequential"))
  global_vars = config.get("variables", {})
  global_command = config.get("command", None)
  global_preprocess = config.get("preprocess", None)
  global_postprocess = config.get("postprocess", None)
  global_cluster_name = config.get("cluster_name", None)
  
  machine_cluster_name = None
  try:
    machine_cluster_name = get_cluster_name()
  except ClusterNameNotSetError:
    pass

  if global_is_sequential:
    console.print('[yellow]Jobs will be scheduled sequentially.[/yellow]')
  
  # Prepare global variable values (expand files if needed)
  expanded_global_vars = {k: _load_variable_values(v) for k, v in global_vars.items()}
  
  launched_jobs = []
  job_definitions = config.get("jobs", [])
  previous_job_id = None

  for job_def in job_definitions:
    job_config_template = job_def.get("config")
    if not job_config_template:
      continue # Skip job definition if it has no config

    job_command_template = job_def.get("command", global_command)
    job_preprocess_template = job_def.get("preprocess", global_preprocess)
    job_postprocess_template = job_def.get("postprocess", global_postprocess)
    job_cluster_name = job_def.get("cluster_name", global_cluster_name)
    job_vars = job_def.get("variables", {})

    expanded_job_vars = {k: _load_variable_values(v) for k, v in job_vars.items()}

    # Merge global and job-specific variables
    merged_job_vars = {**expanded_global_vars, **expanded_job_vars}

    config_jobs = job_def.get("config_jobs", [])
    if not config_jobs:
      job_tag = job_def.get("tag", "default")
      
      # Early tag filter: skip if tag doesn't match any filter pattern
      if filter_tags is not None:
        matched = any(fnmatch.fnmatch(job_tag, pattern) for pattern in filter_tags)
        if not matched:
          continue
      
      if job_cluster_name is not None and machine_cluster_name is not None and job_cluster_name != machine_cluster_name:
        continue # Skip job if job's cluster name doesn't match the machine's cluster name

      # If no config_jobs, run with the job's own context
      previous_job_id = _launch_job_combinations(
        job_config_template,
        job_command_template,
        job_tag,
        job_preprocess_template,
        job_postprocess_template,
        job_cluster_name,
        merged_job_vars,
        launched_jobs,
        force,
        global_is_sequential,
        previous_job_id,
        dry_run=dry_run,
        filter_tags=filter_tags,
        filter_variables=filter_variables,
      )
    else:
      for entry in config_jobs:
        tag_name = entry.get("tag")
        if not tag_name:
          continue # Skip matrix entry if it has no tag

        # Early tag filter: skip if static tag doesn't match any filter pattern
        # (dynamic tags with variables like {model} are checked later after substitution)
        if filter_tags is not None and not _extract_used_vars(tag_name):
          matched = any(fnmatch.fnmatch(tag_name, pattern) for pattern in filter_tags)
          if not matched:
            continue

        entry_command_template = entry.get("command", job_command_template)
        entry_preprocess_template = entry.get("preprocess", job_preprocess_template)
        entry_postprocess_template = entry.get("postprocess", job_postprocess_template)
        entry_cluster_name = entry.get("cluster_name", job_cluster_name)
        entry_vars = entry.get("variables", {})
        expanded_entry_vars = {k: _load_variable_values(v) for k, v in entry_vars.items()}
        
        # Merge all variables: global -> job -> entry
        final_vars = {**merged_job_vars, **expanded_entry_vars}

        if entry_cluster_name is not None and machine_cluster_name is not None and entry_cluster_name != machine_cluster_name:
          continue # Skip job if entry's cluster name doesn't match the machine's cluster name

        previous_job_id = _launch_job_combinations(
          job_config_template,
          entry_command_template,
          tag_name,
          entry_preprocess_template,
          entry_postprocess_template,
          entry_cluster_name,
          final_vars,
          launched_jobs,
          force,
          global_is_sequential,
          previous_job_id,
          dry_run=dry_run,
          filter_tags=filter_tags,
          filter_variables=filter_variables,
        )

  return launched_jobs

def _launch_job_combinations(
  config_template: str,
  command_template: str,
  tag: str,
  preprocess_template: Optional[str],
  postprocess_template: Optional[str],
  cluster_name: Optional[str],
  variables: Dict[str, Any],
  launched_jobs: List[Job],
  force: bool = False,
  sequential: bool = False,
  previous_job_id: Optional[int] = None,
  dry_run: bool = False,
  filter_tags: Optional[List[str]] = None,
  filter_variables: Optional[Dict[str, Any]] = None,
) -> Optional[int]:
    """
    Generates and launches jobs for all combinations of variables.

    Args:
      filter_tags: If provided, only launch jobs whose substituted tag matches one of these values.
      filter_variables: If provided, only launch jobs where variables match all key=value pairs.

    Returns: the id of the last submitted job
    """
    if not command_template:
      return

    # Determine which variables are actually used in the templates
    used_vars = _extract_used_vars(config_template, command_template, tag, preprocess_template, postprocess_template)
    filtered_vars = {k: v for k, v in variables.items() if k in used_vars}

    if not filtered_vars:
      # If no variables are used, launch a single job
      config_name = _substitute(config_template, {})
      command = _substitute(command_template, {})
      job_tag = _substitute(tag, {})
      preprocess = _substitute(preprocess_template, {})
      postprocess = _substitute(postprocess_template, {})
      
      # Check if this job should be skipped based on filters
      if _should_skip_job(job_tag, {}, filter_tags, filter_variables):
        return previous_job_id
      
      # print('='*40)
      # print(f'{config_name=}')
      # print(f'{preprocess=}')
      # print(f'{command=}')
      # print(f'{postprocess=}')
      # print(f'{job_tag=}')
      # print('='*40)
      try:
        job = launch_job(config_name, command, tag=job_tag, preprocess=preprocess, postprocess=postprocess, force=force, previous_job_id=(previous_job_id if sequential else None), cluster_name=cluster_name, dry_run=dry_run)
        console.print(f"✅ Submitted job '{job.config_name}' with tag '{job.tag}'")
        launched_jobs.append(job)
        previous_job_id = job.job_id
      except JobExistsError as e:
        console.print(f"Skipping job: {e.message}")
      except JobSubmitError as e:
        console.print(f"Failed to submit job: {e.message}")
      return previous_job_id

    keys, values = zip(*filtered_vars.items())
    for combination in itertools.product(*values):
      var_dict = dict(zip(keys, combination))
      config_name = _substitute(config_template, var_dict)
      command = _substitute(command_template, var_dict)
      job_tag = _substitute(tag, var_dict)
      preprocess = _substitute(preprocess_template, var_dict)
      postprocess = _substitute(postprocess_template, var_dict)
      
      # Check if this job should be skipped based on filters
      if _should_skip_job(job_tag, var_dict, filter_tags, filter_variables):
        continue
      
      # print('='*40)
      # print(f'{config_name=}')
      # print(f'{preprocess=}')
      # print(f'{command=}')
      # print(f'{postprocess=}')
      # print(f'{job_tag=}')
      # print('='*40)
      try:
        job = launch_job(config_name, command, tag=job_tag, preprocess=preprocess, postprocess=postprocess, force=force, previous_job_id=(previous_job_id if sequential else None), variables=var_dict, cluster_name=cluster_name, dry_run=dry_run)
        console.print(f"✅ Submitted job '{job.config_name}' with tag '{job.tag}'")
        launched_jobs.append(job)
        previous_job_id = job.job_id
      except JobExistsError as e:
        console.print(f"Skipping job: {e.message}")
      except JobSubmitError as e:
        console.print(f"Failed to submit job: {e.message}")
    
    return previous_job_id