/***************************************************************
 *
 * Copyright (C) 1990-2025, Condor Team, Computer Sciences Department,
 * University of Wisconsin-Madison, WI.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you
 * may not use this file except in compliance with the License.  You may
 * obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 ***************************************************************/

#include "dag_parser.h"
#include <algorithm>
#include <stdexcept>
#include <assert.h>

static const char* DELIMITERS = " \t\r";
static const char* ILLEGAL_CHARS = "+";

//--------------------------------------------------------------------------------------------
static bool
is_quote(const char c) {
	return c == '"' || c == '\'';
}

std::string
DagLexer::next(TRIM_QUOTES trim_quotes) {
	std::string token;
	bool escaped = false;
	bool in_quotes = false;
	char quote = '-';

	while (pos < len && str[pos]) {
		if (escaped) {
			token += str[pos];
			escaped = false;
		} else if (in_quotes) {
			assert(is_quote(quote));
			if (str[pos] == '\\') {
				escaped = true;
			} else if (str[pos] == quote) {
				if (trim_quotes == TRIM_QUOTES::FALSE) { token += quote; }
				in_quotes = false;
				quote = '-';
			} else {
				token += str[pos];
			}
		} else if (strchr(DELIMITERS, str[pos])) {
			if (token.size()) { break; }
		} else {
			if (is_quote(str[pos])) {
				quote = str[pos];
				if (trim_quotes == TRIM_QUOTES::FALSE) { token += quote; }
				in_quotes = true;
			} else {
				token += str[pos];
			}
		}

		pos++;
	}

	if (in_quotes) {
		err = "Invalid quoting: no ending quote found";
		return "";
	}

	return token;
}

kvp_parse_t
DagLexer::next_key_value_pair(TRIM_QUOTES trim_quotes) {
	enum class ReadingPhase {
		KEY = 0, // Phase: Read key up to delimiter or equals sign
		EQUALS,  // Phase: Locate equals and up to beginning of value (chomp delimiter)
		VALUE    // Phase: Read value
	};

	std::string key, value;
	ReadingPhase phase = ReadingPhase::KEY;
	bool after_equals = false;

	while (pos < len && str[pos]) {
		char curr = str[pos];

		switch (phase) {
			case ReadingPhase::KEY:
				if (curr == '"' || curr == '\'') {
					// Key is not allowed to contain quote characters
					err = "Key contains quotation mark";
					return std::nullopt;
				} else if (strchr(DELIMITERS, curr)) {
					// If delimiter and we have a key then move on to EQUALS phase
					if (key.size()) { phase = ReadingPhase::EQUALS; }
				} else { // Non-delimiter character
					if (curr == '=') {
						if (key.empty()) {
							// First non-delimiter char cannot be equals sign
							err = "Key value pair missing key";
							return std::nullopt;
						}
						// Move on to EQUALS phase (already found equals so do post equal sign chomp/checks)
						phase = ReadingPhase::EQUALS;
						after_equals = true;
					} else {
						// Key character so store
						key.push_back(curr);
					}
				}

				break; // End Key parse phase

			case ReadingPhase::EQUALS:
				if (strchr(DELIMITERS, curr)) {
					// Do Nothing (chomp whitespace)
				} else if (curr == '=') { // Found equals
					if (after_equals) {
						// We only expect one equals char in key=value pair
						err = "Key value pair missing value: Double operator";
						return std::nullopt;
					} else {
						// Found equals sign do post chomp/checks
						after_equals = true;
					}
				} else if ( ! after_equals) {
					// Non-delimiter character prior to equals sign char (not a key=value pair)
					err = "Key value pair missing value: Failed to locate operator";
					return std::nullopt;
				} else {
					// Found start of value (switch phase)
					phase = ReadingPhase::VALUE;
					pos--; // Don't lose this character
				}

				break; // End equals character parse phase

			case ReadingPhase::VALUE: // This phase either fails or returns key value pair
				value = next(TRIM_QUOTES::FALSE); // Just get next token at this point for simplicity

				if (value.empty()) {
					// We do our own quote trimming so empty value is a failure
					// If error string is empty something bad has happened
					if (err.empty()) { err = "Key value pair missing value: Unexpected error"; }
					return std::nullopt;
				}

				// We should be guaranteed wrapping & matching quotes or no quotes from next()
				if (is_quote(value.front())) {
					if (trim_quotes == TRIM_QUOTES::TRUE) {
						// Remove quotes if quoted and caller wants trimming
						value = value.substr(1, value.length() - 2);
					}
				}

				// Deliver the goods
				return std::make_optional(std::make_pair(key, value));

				break; // End value parse phase

		} // End switch

		pos++;
	} // End while loop

	// While loop ended early: No complete key=value pair so return nullopt and check for error
	if (key.empty()) {
		// No key=value pair (let caller determine error or not)
	} else if (phase == ReadingPhase::KEY) {
		// token with no following data
		err = "Key value pair missing operator and value";
	} else if (phase == ReadingPhase::EQUALS) {
		// key with no value
		err = "Key value pair missing value: No value specified";
	}

	return std::nullopt;
}

std::string
DagLexer::remain() {
	while (pos < len && str[pos] && isspace(str[pos])) { pos++; }
	std::string ret = (pos < str.size() && str[pos]) ? std::string(str.substr(pos)) : "";
	pos = len;
	return ret;
}

//--------------------------------------------------------------------------------------------
static bool
skip_line(const std::string& line) {
	return (line.empty() || line[0] == '#' || line.substr(0, 2) == "//");
}

//--------------------------------------------------------------------------------------------
// WARNING: This function clears the passed reference variable regardless of successfully reading a line
bool
DagParser::getnextline(std::string& line, bool raw) {
	std::string curr;
	bool read_line = false;

	line.clear();

	while ((read_line = readLine(curr, fp))) {
		++line_no;

		trim(curr);
		if (skip_line(curr)) { /* DO NOTHING */ }
		else if (raw) { line = curr; break; }
		else {
			if ( ! line.empty()) { line += " "; }
			line += curr;
			if (line.back() == '\\') {
				line.pop_back();
			} else { break; }
		}
		curr.clear();
	}

	return read_line;
}

bool
DagParser::get_inline_desc_end(const std::string& desc, std::string& end) {
	if (desc.empty()) { return false; }
	else if (desc[0] == '{') { end = "}"; return true; }

	if (starts_with(desc, "@=")) {
		end = desc.length() > 2 ? "@" + desc.substr(2) : "";
		return true;
	}

	return false;
}

std::string
DagParser::parse_inline_desc(const std::string& end, std::string& error, std::string& endline) {
	std::string desc;
	std::string line;
	bool found_end = false;

	if (end.empty()) {
		error = "No inline description closing token specified (@=TOKEN)";
		return desc;
	}

	while (getnextline(line, true)) {
		if (line == end || starts_with(line, end + " ")) {
			endline = (line.length() > end.length()) ? line.substr(end.length()) : "";
			found_end = true;
			break;
		}
		desc += line + "\n";
	}

	if ( ! found_end) {
		error = "Missing inline description closing token: " + end;
	}

	return desc;
}

std::string 
DagParser::ParseSubmitDesc(DagLexer& details) {
	
	std::string token = details.next();
	if (token.empty()) {
		return "No submit description name provided";
	}
	data.reset(new SubmitDescCommand(token));

	token = details.next();
	if (token.empty()) {
		return "No inline description provided";
	}

	std::string inline_end, final_line;
	if (get_inline_desc_end(token, inline_end)) {
		std::string error;
		std::string inline_desc = parse_inline_desc(inline_end, error, final_line);
		if ( ! error.empty()) { return error; }
		((SubmitDescCommand*)data.get())->SetInlineDesc(inline_desc);
		return "";
	}

	return "No inline description provided";
}

std::string
DagParser::ParseNodeTypes(DagLexer& details, DAG::CMD type) {
	std::string node_name = details.next();
	if (node_name.empty()) {
		return "Missing node name";
	}

	if (DAG::RESERVED.contains(node_name)) {
		return "Node name is a reserved word";
	} else if ( ! allow_illegal_chars) {
		auto illegal = [](const char c){ return strchr(ILLEGAL_CHARS, c); };
		if (std::find_if(node_name.begin(), node_name.end(), illegal) != node_name.end()) {
			return "Node name contains illegal charater";
		}
	}

	switch (type) {
		case DAG::CMD::SUBDAG:
			data.reset(new SubdagCommand(node_name));
			break;
		case DAG::CMD::JOB:
			data.reset(new JobCommand(node_name));
			break;
		case DAG::CMD::FINAL:
			data.reset(new FinalCommand(node_name));
			break;
		case DAG::CMD::PROVISIONER:
			data.reset(new ProvisionerCommand(node_name));
			break;
		case DAG::CMD::SERVICE:
			data.reset(new ServiceCommand(node_name));
			break;
		default:
			// This should never happen: ParseNodeTypes() was called with a
			// command that is not a node type.
			return "Invalid DAG Command: Not a node type";
	}

	NodeCommand* nodeCmd = (NodeCommand*)data.get();
	assert(nodeCmd != nullptr);

	std::string desc = details.next(DagLexer::TRIM_QUOTES::TRUE);
	if (desc.empty()) {
		return "No submit description provided";
	}

	std::string inline_end, final_line;
	if (type != DAG::CMD::SUBDAG && get_inline_desc_end(desc, inline_end)) {
		std::string error;
		std::string inline_desc = parse_inline_desc(inline_end, error, final_line);
		if ( ! error.empty()) { return error; }
		nodeCmd->SetInlineDesc(inline_desc);
		nodeCmd->SetSubmit("INLINE");
		details.parse(final_line);
	} else {
		nodeCmd->SetSubmit(desc);
	}

	std::string error = "";
	while (true) {
		std::string token = details.next();
		if (token.empty()) { break; }
		else if (strcasecmp(token.c_str(), "NOOP") == 0) {
			nodeCmd->SetNoop();
		} else if (strcasecmp(token.c_str(), "DONE") == 0) {
			nodeCmd->SetDone();
		} else if (strcasecmp(token.c_str(), "DIR") == 0) {
			std::string dir = details.next(DagLexer::TRIM_QUOTES::TRUE);
			if (dir.empty()) {
				error = "No directory path provided for DIR subcommand";
				break;
			}
			nodeCmd->SetDir(dir);
		} else {
			error = "Unexpected token '" + token + "'";
			break;
		}
	}

	return error;
}

std::string
DagParser::ParseSplice(DagLexer& details) {
	std::string token = details.next();
	if (token.empty()) {
		return "Missing splice name";
	}
	data.reset(new SpliceCommand(token));
	SpliceCommand* cmd = (SpliceCommand*)data.get();
	assert(cmd != nullptr);

	token = details.next(DagLexer::TRIM_QUOTES::TRUE);
	if (token.empty()) {
		return "Missing DAG file";
	}
	cmd->SetDagFile(token);

	std::string error = "";
	token = details.next();
	if ( ! token.empty()) {
		if (strcasecmp(token.c_str(), "DIR") == 0) {
			std::string dir = details.next(DagLexer::TRIM_QUOTES::TRUE);
			if (dir.empty()) {
				error = "No directory path provided for DIR subcommand";
			} else {
				cmd->SetDir(dir);
			}

			token = details.next();
			if ( ! token.empty()) { error = "Unexpected token '" + token + "'"; }
		} else {
			error = "Unexpected token '" + token + "'";
		}
	}

	return error;
}

std::string
DagParser::ParseParentChild(DagLexer& details, const std::string& keyword) {
	data.reset(new ParentChildCommand);
	ParentChildCommand* cmd = (ParentChildCommand*)data.get();
	assert(cmd != nullptr);

	if (keyword == "WEAK") {
		cmd->SetIsWeak();
	}

	bool parsing_children = false;
	std::string token = details.next();
	if (token.empty() || strcasecmp(token.c_str(), "CHILD") == 0) {
		return "No parent node(s) specified";
	}

	std::string error = "Missing CHILD specifier";
	do {
		if (strcasecmp(token.c_str(), "CHILD") == 0) {
			if ( ! details.peek().empty()) {
				parsing_children = true;
				error.clear();
			} else { error = "No children node(s) specified"; }
		} else if (parsing_children) {
			cmd->AddChild(token);
		} else {
			cmd->AddParent(token);
		}
		token = details.next();
	} while ( ! token.empty());

	return error;
}

std::string
DagParser::ParseScript(DagLexer& details) {
	data.reset(new ScriptCommand);
	ScriptCommand* cmd = (ScriptCommand*)data.get();
	assert(cmd != nullptr);

	while (true) {
		std::string token = details.next();
		const auto it = DAG::SCRIPT_TYPES_MAP.find(token);
		if (it != DAG::SCRIPT_TYPES_MAP.end()) {
			cmd->SetType(it->second);
			break;
		} else if (strcasecmp(token.c_str(), "DEFER") == 0) {
			std::string value = details.next();
			int status = 0;
			time_t interval = 0;

			if (value.empty()) { return "DEFER missing status value"; }
			try {
				status = std::stoi(value);
			} catch (...) { return "Invalid DEFER status value '" + value +"'"; }

			value = details.next();
			if (value.empty()) { return "DEFER missing time value"; }
			try {
				interval = (time_t)std::stoi(value);
			} catch (...) { return "Invalid DEFER time value '" + value + "'"; }
			cmd->SetDeferal(status, interval);
		} else if (strcasecmp(token.c_str(), "DEBUG") == 0) {
			std::string file = details.next();
			if (file.empty()) { return "DEBUG missing filename"; }
			std::string stream = details.next();

			const auto it = DAG::SCRIPT_DEBUG_MAP.find(stream);
			if (stream.empty()) { return "DEBUG missing output stream type (STDOUT, STDERR, ALL)"; }
			else if (it == DAG::SCRIPT_DEBUG_MAP.end()) {
				return "Unknown DEBUG output stream type '" + stream + "'";
			}

			cmd->SetDebugInfo(file, it->second);
		} else {
			return "Missing script type (PRE, POST, HOLD) and optional sub-commands (DEFER, DEBUG)";
		}
	}

	std::string token = details.next();
	if (token.empty()) { return "No node name specified"; }
	cmd->SetNodeName(token);

	token = details.remain();
	trim(token);
	if (token.empty()) { return "No script specified"; }
	cmd->SetScript(token);

	return "";
}

std::string
DagParser::ParseRetry(DagLexer& details) {
	std::string token = details.next();
	if (token.empty()) { return "No node name specified"; }

	data.reset(new RetryCommand(token));
	RetryCommand* cmd = (RetryCommand*)data.get();
	assert(cmd != nullptr);

	token = details.next();
	if (token.empty()) { return "Missing max retry value"; }

	try {
		int max = std::stoi(token);
		if (max < 0) { throw std::invalid_argument("Negative number of retries specified"); }
		cmd->SetMaxRetries(max);
	} catch (...) {
		return "Invalid max retry value '" + token + "' (Must be positive integer)";
	}

	token = details.next();
	if ( ! token.empty()) {
		if (strcasecmp(token.c_str(), "UNLESS-EXIT") != 0) {
			return "Unexpected token '" + token + "'";
		}

		token = details.next();
		if (token.empty()) { return "UNLESS-EXIT missing exit code"; }

		try {
			int code = std::stoi(token);
			cmd->SetBreakCode(code);
		} catch (...) {
			return "Invalid UNLESS-EXIT code '" + token + "' specified";
		}

		token = details.next();
		if ( ! token.empty()) { return "Unexpected token '" + token + "'"; }
	}

	return "";
}

std::string
DagParser::ParseAbortDagOn(DagLexer& details) {
	std::string token = details.next();
	if (token.empty()) { return "No node name specified"; }

	data.reset(new AbortDagCommand(token));
	AbortDagCommand* cmd = (AbortDagCommand*)data.get();
	assert(cmd != nullptr);

	token = details.next();
	if (token.empty()) { return "Missing exit status to abort on"; }

	try {
		int status = std::stoi(token);
		cmd->SetCondition(status);
	} catch (...) {
		return "Invalid exit status '" + token + "' specified";
	}

	token = details.next();
	if ( ! token.empty()) {
		if (strcasecmp(token.c_str(), "RETURN") != 0) {
			return "Unexpected token '" + token + "'";
		}

		token = details.next();
		if (token.empty()) { return "RETURN is missing value"; }

		try {
			int code = std::stoi(token);
			if (code < 0 || code > 255) { throw std::invalid_argument("Value out of range 0-255"); }
			cmd->SetExitValue(code);
		} catch (...) {
			return "Invalid RETURN code '" + token +"' (Must be between 0 and 255)";
		}

		token = details.next();
		if ( ! token.empty()) { return "Unexpected token '" + token + "'"; }
	}

	return "";
}

std::string
DagParser::ParseVars(DagLexer& details) {
	std::string token = details.next();
	if (token.empty()) { return "No node name specified"; }

	data.reset(new VarsCommand(token));
	VarsCommand* cmd = (VarsCommand*)data.get();
	assert(cmd != nullptr);

	// Possibly APPEND/PREPEND keyword or key=value pair (so peek and don't consume)
	token = details.peek();

	if (strcasecmp(token.c_str(), "PREPEND") == 0) {
		cmd->Prepend();
		token = details.next(); // Eat token
	} else if (strcasecmp(token.c_str(), "APPEND") == 0) {
		cmd->Append();
		token = details.next(); // Eat token
	}

	int num_pairs = 0;
	while (const auto kvp = details.next_key_value_pair(DagLexer::TRIM_QUOTES::TRUE)) {
		auto [key, value] = *kvp;

		if (key == "+" || key == "My.") { return "Variable name must contain at least one alphanumeric character"; }
		else if (key.front() == '+') { key = "My." + key.substr(1); }

		std::string tmp(key);
		lower_case(tmp);
		if (tmp.starts_with("queue")) { return "Illegal variable name '" + key + "': name can not begin with 'queue'"; }

		num_pairs++;
		cmd->AddPair(key, value);
	}

	return (num_pairs == 0) ? "No key=value pairs specified" : "";
}

std::string
DagParser::ParsePriority(DagLexer& details) {
	std::string token = details.next();
	if (token.empty()) { return "No node name specified"; }

	data.reset(new PriorityCommand(token));
	PriorityCommand* cmd = (PriorityCommand*)data.get();
	assert(cmd != nullptr);

	token = details.next();
	if (token.empty()) { return "Missing priority value"; }

	try {
		int prio = std::stoi(token);
		cmd->SetPriority(prio);
	} catch (...) {
		return "Invalid priority value '" + token + "'";
	}

	token = details.next();
	return ( ! token.empty()) ? "Unexpected token '" + token + "'" : "";
}

std::string
DagParser::ParsePreSkip(DagLexer& details) {
	std::string token = details.next();
	if (token.empty()) { return "No node name specified"; }

	data.reset(new PreSkipCommand(token));
	PreSkipCommand* cmd = (PreSkipCommand*)data.get();
	assert(cmd != nullptr);

	token = details.next();
	if (token.empty()) { return "Missing exit code"; }

	try {
		int code = std::stoi(token);
		cmd->SetExitCode(code);
	} catch (...) {
		return "Invalid exit code '" + token + "'";
	}

	token = details.next();
	return ( ! token.empty()) ? "Unexpected token '" + token + "'" : "";
}

std::string
DagParser::ParseSavePoint(DagLexer& details) {
	std::string token = details.next();
	if (token.empty()) { return "No node name specified"; }
	else if (strcasecmp(token.c_str(), DAG::ALL_NODES.c_str()) == 0) {
		return "ALL_NODES cannot be used for save point file command";
	}

	data.reset(new SavePointCommand(token));
	SavePointCommand* cmd = (SavePointCommand*)data.get();
	assert(cmd != nullptr);

	token = details.next(DagLexer::TRIM_QUOTES::TRUE);
	if (token.empty()) {
		// Default <node name>-<dag file>.save
		std::string file = cmd->GetNodeName() + "-" + path.filename().string() + ".save";
		cmd->SetFilename(file);
	} else {
		cmd->SetFilename(token);
		token = details.next();
		if ( ! token.empty()) { return "Unexpected token '" + token + "'"; }
	}

	return "";
}

std::string
DagParser::ParseCategory(DagLexer& details) {
	std::string node = details.next();
	if (node.empty()) { return "No node name specified"; }

	std::string name = details.next();
	if (name.empty()) { return "No category name specified"; }

	std::string junk = details.next();
	if ( ! junk.empty()) { return "Unexpected token '" + junk + "'"; }

	data.reset(new CategoryCommand(name));
	((CategoryCommand*)data.get())->AddNode(node);

	return "";
}

std::string
DagParser::ParseMaxJobs(DagLexer& details) {
	std::string token = details.next();
	if (token.empty()) { return "No category name specified"; }

	data.reset(new MaxJobsCommand(token));
	MaxJobsCommand* cmd = (MaxJobsCommand*)data.get();
	assert(cmd != nullptr);

	token = details.next();
	if (token.empty()) { return "No throttle limit specified"; }

	try {
		int limit = std::stoi(token);
		if (limit < 0) { throw std::invalid_argument("MAXJOBS throttle limit must be a positive integer"); }
		cmd->SetLimit(limit);
	} catch (...) {
		return "Invalid throttle limit '" + token + "'";
	}

	token = details.next();
	return ( ! token.empty()) ? "Unexpected token '" + token + "'" : "";
}

std::string
DagParser::ParseConfig(DagLexer& details) {
	std::string file = details.next(DagLexer::TRIM_QUOTES::TRUE);
	if (file.empty()) { return "No configuration file specified"; }

	std::string junk = details.next();
	if ( ! junk.empty()) { return "Unexpected token '" + junk + "'"; }

	std::filesystem::path path(file);
	if ( ! path.is_absolute()) {
		path = std::filesystem::absolute(path);
	}

	file = path.string();

	data.reset(new ConfigCommand(file));
	return "";
}

std::string
DagParser::ParseDot(DagLexer& details) {
	std::string token = details.next(DagLexer::TRIM_QUOTES::TRUE);
	if (token.empty()) { return "No file specified"; }

	data.reset(new DotCommand(token));
	DotCommand* cmd = (DotCommand*)data.get();
	assert(cmd != nullptr);

	token = details.next();
	while ( ! token.empty()) {
		if (strcasecmp(token.c_str(), "UPDATE") == 0) {
			cmd->SetUpdate(true);
		} else if (strcasecmp(token.c_str(), "DONT-UPDATE") == 0) {
			cmd->SetUpdate(false);
		} else if (strcasecmp(token.c_str(), "OVERWRITE") == 0) {
			cmd->SetOverwrite(true);
		} else if (strcasecmp(token.c_str(), "DONT-OVERWRITE") == 0) {
			cmd->SetOverwrite(false);
		} else if (strcasecmp(token.c_str(), "INCLUDE") == 0) {
			token = details.next();
			if (token.empty()) { return "Missing INCLUDE header file"; }
			cmd->SetInclude(token);
		} else {
			return "Unexpected token '" + token + "'";
		}

		token = details.next();
	}

	return "";
}

std::string
DagParser::ParseNodeStatus(DagLexer& details) {
	std::string token = details.next(DagLexer::TRIM_QUOTES::TRUE);
	if (token.empty()) { return "No file specified"; }

	data.reset(new NodeStatusCommand(token));
	NodeStatusCommand* cmd = (NodeStatusCommand*)data.get();
	assert(cmd != nullptr);

	token = details.next();
	while ( ! token.empty()) {
		if (strcasecmp(token.c_str(), "ALWAYS-UPDATE") == 0) {
			cmd->SetAlwaysUpdate();
		} else {
			try {
				int min = std::stoi(token);
				cmd->SetMinUpdateTime(min);
			} catch (...) {
				return "Unexpected token '" + token + "'";
			}
		}

		token = details.next();
	}

	return "";
}

std::string
DagParser::ParseEnv(DagLexer& details) {
	std::string token = details.next();
	if (token.empty()) { return "Missing action (SET or GET) and variables"; }

	bool set = false;
	if (strcasecmp(token.c_str(), "SET") == 0) {
		set = true;
	} else if (strcasecmp(token.c_str(), "GET") != 0) {
		return "Unexpected token '" + token + "'";
	}

	std::string vars = details.remain();
	trim(vars);
	if (vars.empty()) { return "No environment variables provided"; }

	if ( ! set) { vars = join(split(vars), ","); }

	data.reset(new EnvCommand(vars, set));
	return "";
}

std::string
DagParser::ParseConnect(DagLexer& details) {
	std::string s1 = details.next();
	std::string s2 = details.next();

	if (s1.empty() || s2.empty()) { return "Missing splice(s) to connect"; }

	std::string junk = details.next();
	if ( ! junk.empty()) { return "Unexpected token '" + junk + "'"; }

	data.reset(new ConnectCommand(s1, s2));
	return "";
}

std::string
DagParser::ParsePin(DagLexer& details, DAG::CMD type) {
	assert(type == DAG::CMD::PIN_IN || type == DAG::CMD::PIN_OUT);

	std::string token = details.next();
	if (token.empty()) { return "No node name specified"; }

	data.reset(new PinCommand(token, type));

	token = details.next();
	if (token.empty()) { return "No pin number specified"; }

	try {
		int pin = std::stoi(token);
		if (pin < 1) { throw std::invalid_argument("Pin number must be greater than or equal to 1"); }
		((PinCommand*)data.get())->SetPinNum(pin);
	} catch (...) {
		return "Invalid pin number '" + token + "'";
	}

	token = details.next();
	return ( ! token.empty()) ? "Unexpected token '" + token + "'" : "";
}

//--------------------------------------------------------------------------------------------
bool
DagParser::next() {
	std::string line;

	while (getnextline(line)) {
		uint64_t command_line_no = line_no;

		bool parse_success = true;

		DagLexer details(line);
		std::string cmd_str = details.next();
		std::replace(cmd_str.begin(), cmd_str.end(), '-', '_'); // Make PRE_SKIP == PRE-SKIP

		const auto it = DAG::KEYWORD_MAP.find(cmd_str);
		if (it == DAG::KEYWORD_MAP.end()) {
			parse_success = false;
			std::string error;
			formatstr(error, "'%s' is not a valid DAG command", cmd_str.c_str());
			all_errors.emplace_back(GetFile(), command_line_no, error);
		} else {
			DAG::CMD cmd = it->second;

			std::string parse_error, check;
			bool ignore = filter_ignore.contains(cmd);

			if (ignore || (! filter_only.empty() && ! filter_only.contains(cmd))) {
				// If ignored command is potentially multilined then parse the command
				if (cmd >= DAG::CMD::JOB && cmd <= DAG::CMD::SERVICE) {
					std::ignore = ParseNodeTypes(details, cmd);
				} else if (cmd == DAG::CMD::SUBMIT_DESCRIPTION) {
					std::ignore = ParseSubmitDesc(details);
				}

				// Reset any data that was successfully parsed
				data.reset(nullptr);

				if ( ! ignore) { has_other_commands = true; }

				continue;
			}

			switch (cmd) {
				case DAG::CMD::SUBDAG:
					check = details.next(); // TODO: Make External keyword optional
					if (strcasecmp(check.c_str(), "EXTERNAL") != 0) {
						parse_error = "Missing EXTERNAL keyword";
						break;
					}
					[[fallthrough]];
				case DAG::CMD::JOB:
				case DAG::CMD::FINAL:
				case DAG::CMD::PROVISIONER:
				case DAG::CMD::SERVICE:
					parse_error = ParseNodeTypes(details, cmd);
					break;
				case DAG::CMD::SPLICE:
					parse_error = ParseSplice(details); // TODO: Support inline DAG splices
					break;
				case DAG::CMD::SUBMIT_DESCRIPTION:
					parse_error = ParseSubmitDesc(details);
					break;
				case DAG::CMD::PARENT_CHILD:
					if (it->first == "WEAK") {
						check = details.next();
						if (check.empty() || strcasecmp(check.c_str(), "PARENT") != 0) {
							parse_error = "WEAK dependency missing PARENT keyword";
							break;
						}
					}
					parse_error = ParseParentChild(details, it->first);
					break;
				case DAG::CMD::SCRIPT:
					parse_error = ParseScript(details);
					break;
				case DAG::CMD::RETRY:
					parse_error = ParseRetry(details);
					break;
				case DAG::CMD::ABORT_DAG_ON:
					parse_error = ParseAbortDagOn(details);
					break;
				case DAG::CMD::VARS:
					parse_error = ParseVars(details);
					break;
				case DAG::CMD::PRIORITY:
					parse_error = ParsePriority(details);
					break;
				case DAG::CMD::PRE_SKIP:
					parse_error = ParsePreSkip(details);
					break;
				case DAG::CMD::DONE:
					check = details.next();
					if (check.empty()) {
						parse_error = "No node name specified";
					} else {
						data.reset(new DoneCommand(check));
						check = details.next();
						if ( ! check.empty()) {
							parse_error = "Unexpected token '" + check + "'";
						}
					}
					break;
				case DAG::CMD::CATEGORY:
					parse_error = ParseCategory(details);
					break;
				case DAG::CMD::MAXJOBS:
					parse_error = ParseMaxJobs(details);
					break;
				case DAG::CMD::CONFIG:
					parse_error = ParseConfig(details);
					break;
				case DAG::CMD::INCLUDE:
					check = details.next(DagLexer::TRIM_QUOTES::TRUE);
					if (check.empty()) {
						parse_error = "No include file specified";
					} else {
						data.reset(new IncludeCommand(check));
						check = details.next();
						if ( ! check.empty()) {
							parse_error = "Unexpected token '" + check + "'";
						}
					}
					break;
				case DAG::CMD::DOT:
					parse_error = ParseDot(details);
					break;
				case DAG::CMD::NODE_STATUS_FILE:
					parse_error = ParseNodeStatus(details);
					break;
				case DAG::CMD::JOBSTATE_LOG:
					check = details.next(DagLexer::TRIM_QUOTES::TRUE);
					if (check.empty()) {
						parse_error = "No include file specified";
					} else {
						data.reset(new JobStateLogCommand(check));
						check = details.next();
						if ( ! check.empty()) {
							parse_error = "Unexpected token '" + check + "'";
						}
					}
					break;
				case DAG::CMD::SAVE_POINT_FILE:
					parse_error = ParseSavePoint(details);
					break;
				case DAG::CMD::SET_JOB_ATTR:
					check = details.remain();
					trim(check);
					if (check.empty()) {
						parse_error = "No attribute line (key = value) provided";
					} else { data.reset(new SetAttrCommand(check)); }
					break;
				case DAG::CMD::ENV:
					parse_error = ParseEnv(details);
					break;
				case DAG::CMD::REJECT:
					check = details.next();
					if ( ! check.empty()) {
						parse_error = "Unexpected token '" + check + "'";
					} else { data.reset(new RejectCommand()); }
					break;
				case DAG::CMD::CONNECT:
					parse_error = ParseConnect(details);
					break;
				case DAG::CMD::PIN_IN:
				case DAG::CMD::PIN_OUT:
					parse_error = ParsePin(details, cmd);
					break;
				default:
					parse_error = "Parser not implemented";
					break;
			} // End switch statement on DAG commands

			// Handle line lexer error (overrides specific parse failure because this was likely the cause)
			if (details.failed()) {
				parse_error = details.error();
			}

			// Handle any errors encountered while parsing
			if ( ! parse_error.empty()) {
				data.reset(nullptr);
				parse_success = false;

				auto& ref = all_errors.emplace_back(GetFile(), command_line_no, parse_error);
				ref.SetCommand(cmd);

			} else {
				// Set source information for this command
				data->SetSource(GetFile(), command_line_no);
			}
		} // End if/else valid DAG command

		return parse_success;
	} // End while loop

	// End of file
	return false;
}

