"""Incorporate numInvalidOKs into Test Table

Classes:
    TTGWithEDAPathConfig
    TTGwithEDAOrchestrator
    MultiOrderOrCommandTTGwithEDA
    ResumableTTGwithEDA
"""

__all__ = []

from abc import ABC, abstractmethod
import argparse
from dataclasses import dataclass
import json
from os import walk
from pathlib import Path
import platform
import re
import sys
import time
from typing import Optional

from edge_deletion_analysis import (
    EdgeDeletionAnalyzer,
)
from planaritytesting_utils import (
    parse_range,
    is_path_to_executable,
    PLANARITY_ALGORITHM_SPECIFIERS,
    EDGE_DELETION_ANALYSIS_SPECIFIERS,
    max_num_edges_for_order,
    raise_on_duplicates,
)
from test_table_generator import (
    TestTableGenerator,
)


@dataclass
class TTGWithEDAPathConfig:
    """Dataclass storing attributes pertaining to path config for TTA with EDA

    Args:
        planarity_path: Path to planarity executable to use when running
            edge-deletion analysis. If none provided, defaults to:
                {planarity_project_root}/Release/planarity
        graph_dir: Path to parent directory containing subdirectories for
            each graph order, the contents of which are .g6 graph files,
            one for each edgecount from 0 to (N * (N - 1)) / 2. If none
            provided, defaults to:
                TestSupport/results/graph_generation_orchestrator/
        planarity_output_dir: Path to parent directory containing
            subdirectories for each graph order, the contents of which are
            subdirectories for each graph algorithm extension. Each of
            these directories contains the results of running planarity
            Test All Graphs for the given command on all graphs of a given
            order and number of edges. If none provided, defaults to:
                TestSupport/results/planarity_testAllGraphs_orchestrator/
        test_table_output_dir: Parent directory under which subdirectory
            named after each {order} will be created for output results. If
            none provided, defaults to:
                TestSupport/tables/
        canonical_files: Whether or not the .g6 files are in canonical form
        makeg_files: Whether or not the .g6 files were generated by makeg

    Raises:
        ValueError:
            - planarity_path doesn't correspond to an executable
            - the graph_dir is not a directory
            - the planarity_output_dir is not a directory
            - the test_table_output_dir is not a directory
    """

    planarity_executable_path: Optional[Path] = None
    graph_dir: Optional[Path] = None
    planarity_output_dir: Optional[Path] = None
    test_table_output_dir: Optional[Path] = None
    canonical_files: bool = False
    makeg_files: bool = False

    def __post_init__(self):
        """Custom post-init logic to normalize and validate path config"""
        self.planarity_project_root: Path = (
            Path(sys.argv[0]).resolve().parent.parent.parent
        )
        self._normalize_and_validate_planarity_executable_path()
        self._normalize_and_validate_graph_dir()
        self._normalize_and_validate_planarity_output_dir()
        self._normalize_and_validate_test_table_output_dir()

    def _normalize_and_validate_planarity_executable_path(self) -> None:
        """Ensure planarity path refers to a valid executable

        Raises:
            ValueError:
                - planarity_path cannot be resolved
                - planarity_path doesn't correspond to an executable
        """
        if not self.planarity_executable_path:
            extension = ".exe" if platform.system() == "Windows" else ""
            self.planarity_executable_path = Path.joinpath(
                self.planarity_project_root, "Release", f"planarity{extension}"
            )
        try:
            self.planarity_executable_path = (
                self.planarity_executable_path.resolve()
            )
        except OSError as planarity_path_resolve_error:
            raise ValueError(
                "Path to planarity executable "
                f"'{self.planarity_executable_path}' cannot be resolved."
            ) from planarity_path_resolve_error
        if not is_path_to_executable(self.planarity_executable_path):
            raise ValueError(
                "Path for planarity executable "
                f"'{self.planarity_executable_path}' does not correspond to "
                "an executable."
            )

    def _normalize_and_validate_graph_dir(self) -> None:
        """Set default for graph_dir and ensure directory exists

        Raises:
            ValueError: if self.graph_dir.is_dir() is false
        """
        if not self.graph_dir:
            self.graph_dir = Path.joinpath(
                self.planarity_project_root,
                "TestSupport",
                "results",
                "graph_generation_orchestrator",
            )
        if not self.graph_dir.is_dir():
            raise ValueError(
                f"graph_dir = '{self.graph_dir}' does not correspond to a "
                "directory."
            )

    def _normalize_and_validate_planarity_output_dir(self) -> None:
        """Set default for planarity_output_dir and ensure directory exists

        Raises:
            ValueError: if self.planarity_output_dir.is_dir() is false
        """
        if not self.planarity_output_dir:
            self.planarity_output_dir = Path.joinpath(
                self.planarity_project_root,
                "TestSupport",
                "results",
                "planarity_testAllGraphs_orchestrator",
            )
        if not self.planarity_output_dir.is_dir():
            raise ValueError(
                f"planarity_output_dir = '{self.planarity_output_dir}' does "
                "not correspond to a directory."
            )

    def _normalize_and_validate_test_table_output_dir(self) -> None:
        """Set default for test_table_output_dir and ensure directory exists

        Raises:
            ValueError: if self.test_table_output_dir.is_dir() is false
        """
        if not self.test_table_output_dir:
            self.test_table_output_dir = Path.joinpath(
                self.planarity_project_root,
                "TestSupport",
                "tables",
            )
        if not self.test_table_output_dir.is_dir():
            raise ValueError(
                f"test_table_output_dir = '{self.test_table_output_dir}' "
                "does not correspond to a directory."
            )

    def _input_dir_contains_expected_files(
        self, order: int, input_dir: Path
    ) -> bool:
        """Determines if files in input_dir are named in the expected scheme

        Args:
            order: Files in input_dir are expected to pertain to graphs of the
                given order
            input_dir: Directory containing files to check

        Returns:
            boolean indicating whether (True) or not (False) all files match
                the expected naming scheme, and all expected files are present
        """
        infile_candidate_prefixes = sorted(
            ".".join(infile_candidate.name.split(".")[:2])
            for infile_candidate in input_dir.iterdir()
            if (self.canonical_files == ("canonical" in infile_candidate.name))
            if (self.makeg_files == ("makeg" in infile_candidate.name))
        )
        expected_infile_prefixes = sorted(
            f"n{order}.m{num_edges}"
            for num_edges in range(max_num_edges_for_order(order) + 1)
        )
        return infile_candidate_prefixes == expected_infile_prefixes

    def check_graph_dir_contents(self, orders: tuple[int, ...]) -> None:
        """Ensure graph_dir contains subdirectories for each order

        graph_dir must be a Path to parent directory containing subdirectories
        for each graph order, the contents of which are .g6 graph files, one
        for each edgecount from 0 to (N * (N - 1)) / 2

        Args:
            orders: The graph orders for which you wish to perform EDA and
                compile tables

        Raises:
            ValueError: If the graph_dir
                1. contains files,
                2. contains no subdirectories
                3. is missing a subdirectory for one of the desired orders
                Or if the files in the subdirectories are not named in
                the expected scheme

        Returns:
            Path object graph_dir
        """
        assert self.graph_dir is not None
        for dirpath, dirnames, filenames in walk(self.graph_dir):
            if len(filenames) > 0:
                raise ValueError(
                    f"graph_dir = '{self.graph_dir}' has unexpected "
                    "structure: should only contain subdirectories, one for "
                    f"each graph order in {orders}."
                )
            if len(dirnames) == 0:
                raise ValueError(
                    f"graph_dir = '{self.graph_dir}' has no subdirectories: "
                    "expected one subdirectory for each graph order in "
                    f"{orders}."
                )
            for order in orders:
                if str(order) not in dirnames:
                    raise ValueError(
                        f"graph_dir = '{self.graph_dir}' is missing "
                        f"subdirectory for graph order N={order}."
                    )
                graph_dir_for_order = Path.joinpath(
                    Path(dirpath).resolve(), f"{order}"
                )
                if not self._input_dir_contains_expected_files(
                    order, graph_dir_for_order
                ):
                    raise ValueError(
                        f"Graph dir for order '{graph_dir_for_order}' doesn't "
                        "contain the expected .g6 infiles (i.e. one per each "
                        f"num_edges for the given order {order})."
                    )
            # Don't recurse into subdirectories
            break

    def check_planarity_output_dir_contents(
        self,
        orders: tuple[int, ...],
        generate_tables_for_commands: tuple[str, ...],
    ) -> None:
        """Check planarity output dir has expected subdirectory structure

        planarity_output_dir should be a Path object corresponding to a parent
        directory containing subdirectories for each graph order, the contents
        of which are subdirectories for each graph algorithm extension. Each of
        these directories contains the results of running planarity Test All
        Graphs for the given command on all graphs of a given order and number
        of edges

        Args:
            orders: The graph orders for which you wish to perform EDA and
                compile tables
            generate_tables_for_commands: The graph algorithm commands for
                which you wish to perform EDA (if applicable) and generate
                tables

        Raises:
            ValueError: If the planarity_output_dir:
                1. contains files,
                2. contains no subdirectories
                3. is missing a subdirectory for one of the desired orders
                If, for each of the subdirectories of planarity_output_dir
                containing the results for a specific graph order, the
                directory:
                1. contains files,
                2. contains no subdirectories
                3. is missing a subdirectory for one of the graph algorithm
                command specifiers
                Or if the files in the sub-sub directories are not named in
                the expected scheme
        """
        assert self.planarity_output_dir is not None
        for dirpath, dirnames, filenames in walk(self.planarity_output_dir):
            if len(filenames) > 0:
                raise ValueError(
                    f"planarity_output_dir = '{self.planarity_output_dir}' "
                    "has unexpected structure: should only contain "
                    "subdirectories, one for each graph order in "
                    f"{orders}."
                )
            if len(dirnames) == 0:
                raise ValueError(
                    f"planarity_output_dir = '{self.planarity_output_dir}' "
                    "has no subdirectories: expected one subdirectory for "
                    f"each graph order in {orders}."
                )
            for order in orders:
                if str(order) not in dirnames:
                    raise ValueError(
                        f"planarity_output_dir = '{self.planarity_output_dir}'"
                        f" is missing subdirectory for graph order N={order}."
                    )
            for dirname in dirnames:
                if int(dirname) not in orders:
                    continue
                planarity_output_dir_for_order = Path.joinpath(
                    Path(dirpath).resolve(), dirname
                )
                for _, subdirs, subdir_filenames in walk(
                    planarity_output_dir_for_order
                ):
                    if len(subdir_filenames) > 0:
                        raise ValueError(
                            "Planarity output subdirectory "
                            f"'{planarity_output_dir_for_order}' should only "
                            "contain subdirectories."
                        )
                    if len(dirnames) == 0:
                        raise ValueError(
                            "Planarity output subdirectory "
                            f"'{planarity_output_dir_for_order}' has no "
                            "subdirectories: expected one subdirectory for "
                            "each command specifier in "
                            f"{generate_tables_for_commands}."
                        )
                    for command in generate_tables_for_commands:
                        if command not in subdirs:
                            raise ValueError(
                                "Planarity output subdirectory "
                                f"'{planarity_output_dir_for_order}' is "
                                "missing subdirectory for command "
                                f"'{command}'."
                            )
                        output_dir_for_order_and_command = Path.joinpath(
                            planarity_output_dir_for_order, f"{command}"
                        )
                        if not self._input_dir_contains_expected_files(
                            int(dirname),
                            output_dir_for_order_and_command,
                        ):
                            raise ValueError(
                                f"Planarity output dir for order and command "
                                f"'{output_dir_for_order_and_command}' "
                                "doesn't contain the expected planarity "
                                "output files (i.e. one per each "
                                f"num_edges for the given order {dirname})."
                            )
                    # Don't recurse into subdirectories
                    break
            # Don't recurse into subdirectories
            break


class TTGwithEDAOrchestrator:
    """Orchestrate test table generation with results of edge-deletion analysis

    Validate command-line parameters and, based on their values, instantiate
    MultiOrderTTGwithEDA or ResumableTTGwithEDA.
    """

    def __init__(
        self,
        path_config: TTGWithEDAPathConfig,
        orders: Optional[tuple[int, ...]] = None,
        edgecount_limit: Optional[int] = None,
        generate_tables_for_commands: Optional[tuple[str, ...]] = None,
    ) -> None:
        """Initializes TTGwithEDAOrchestrator instance

        Args:
            path_config: An instance of TTGWithEDAPathConfig that contains the
                validated and normalized Paths for planarity_path, graph_dir,
                planarity_output_dir, and test_table_output_dir, as well as the
                boolean flags representing whether we are performing the work
                on canonical_files and/or makeg_files.
            orders: Graph orders for which to perform edge-deletion analysis
                for chosen graph algorithm extensions and to tabulate results
                of running planarity Test All Graphs
            edgecount_limit: When generating table for single graph order and
                command specifier, indicates the maximum edgecount for which
                you wish to perform EDA; must be less than or equal to
                (N * (N - 1)) / 2, and if a resume-file exists, must be
                strictly greater than the highest previously processed edge-
                count
            generate_tables_for_commands: Command specifiers for which you wish
                to generate test tables; defaults to tuple returned by
                PLANARITY_ALGORITHM_SPECIFIERS()

        Raises:
            ValueError:
                - the specified graph orders are invalid
                - the command specifiers for which you wish to perform EDA (if
                applicable) and generate the respective test tables are invalid
                - the edgecount limit is invalid, in light of the chosen graph
                order(s) and command specifier(s)
                - the graph_dir does not have the correct structure
                - the planarity_output_dir does not have the expected structure
        """
        self.orders = self._validate_orders(orders)
        self.generate_tables_for_commands = self._validate_command_specifiers(
            generate_tables_for_commands
        )
        self.edgecount_limit = self._validate_edgecount_limit(edgecount_limit)
        path_config.check_graph_dir_contents(self.orders)
        path_config.check_planarity_output_dir_contents(
            self.orders, self.generate_tables_for_commands
        )
        self.path_config = path_config
        self.ttg_with_eda_driver: Optional[TTGwithEDA] = None

    def _validate_orders(
        self, orders: Optional[tuple[int, ...]]
    ) -> tuple[int, ...]:
        """Validating graph orders for which you wish to run EDA and do TTG

        Raises:
            ValueError:
                - orders tuple contains non-integer values
                - the largest graph order specified in a range > 9
        Returns:
            tuple containing user-specified graph order(s)
        """
        if orders is None or len(orders) == 0:
            orders = tuple(range(6, 10))  # orders defaults to (6, 7, 8, 9)
        if not all(isinstance(x, int) for x in orders):
            raise ValueError("Orders tuple contains non-integer values.")
        if len(orders) > 1 and max(orders) > 9:
            raise ValueError(
                "When specifying a range of orders, the maximum order must "
                "not exceed 9, otherwise the system will run out of memory."
            )
        return orders

    def _validate_command_specifiers(
        self, generate_tables_for_commands: Optional[tuple[str, ...]]
    ) -> tuple[str, ...]:
        """Validating commands for which you wish to generate test tables.

        Args:
            generate_tables_for_commands: tuple of str indicating the graph
                algorithm extensions for which you wish to generate tables

        Raises:
            ValueError:
                - self.orders is None or empty (falsy)
                - invalid command specifiers given
                - multiple command specifiers are given when graph order > 9

        Returns:
            tuple of valid command specifiers
        """
        if self.orders is None or len(self.orders) == 0:
            raise ValueError("Invalid self.orders tuple.")
        if (
            generate_tables_for_commands is None
            or len(generate_tables_for_commands) == 0
        ):
            generate_tables_for_commands = PLANARITY_ALGORITHM_SPECIFIERS()
        if not all(
            (c in PLANARITY_ALGORITHM_SPECIFIERS())
            for c in generate_tables_for_commands
        ):
            raise ValueError(
                "Invalid command specifier(s) given when indicating which "
                "algorithms for which you wish to generate test tables."
            )
        # NOTE: if (max(self.orders) > 9) holds, self.orders should only
        # contain one element, since self._validate_orders() would throw an
        # error if the user specified a range of graph orders with the max > 9
        if max(self.orders) > 9 and len(generate_tables_for_commands) > 1:
            raise ValueError(
                "When the specified graph order is greater than 9, must only "
                "choose one algorithm command specifier for which you wish to "
                "perform edge-deletion analysis and generate table."
            )
        return generate_tables_for_commands

    def _orders_and_commands_are_valid(self) -> bool:
        """Ensure orders and generate_tables_for_commands are nonempty"""
        if (
            self.orders is None
            or len(self.orders) == 0
            or self.generate_tables_for_commands is None
            or len(self.generate_tables_for_commands) == 0
        ):
            return False
        return True

    def _validate_edgecount_limit(
        self, edgecount_limit: Optional[int]
    ) -> Optional[int]:
        """Ensure edgecount limit is valid relative to other options

        Args:
            edgecount_limit: max edge-count up to which you wish to perform EDA

        Raises:
            ValueError:
                - self.orders or self.generate_tables_for_commands are somehow
                None or empty tuples (falsy)
                - edgecount_limit is given when multiple graph orders and/or
                commands are specified
                - edgecount_limit exceeds the maximum possible for the given
                graph order

        Returns:
            int edgecount_limit (for single order and command) or None (if
            not applicable, i.e. when running multiple orders/commands)
        """
        if not self._orders_and_commands_are_valid():
            raise ValueError(
                "Unable to validate edgecount limit: invalid self.orders or "
                "self.generate_tables_for_commands tuples."
            )
        if len(self.orders) > 1 or len(self.generate_tables_for_commands) > 1:
            if edgecount_limit is not None:
                raise ValueError(
                    "Cannot specify an edgecount limit when wanting to "
                    "perform EDA and generate tables for multiple graph "
                    "orders and/or graph algorithm commands."
                )
        else:
            assert self.orders is not None and len(self.orders) == 1
            edgecount_limit = self._normalize_edgecount_limit(
                self.orders[0], edgecount_limit
            )
        return edgecount_limit

    def _normalize_edgecount_limit(
        self, order: int, edgecount_limit: Optional[int]
    ) -> int:
        """For a given graph order, normalize and validate the edgecount_limit

        Args:
            order: The graph order against which you wish to check whether the
                edgecount_limit is valid
            edgecount_limit: either None (will be set to (N*(N-1))/2) or some
                int we wish to verify is within the accepted range

        Raises:
            ValueError:
                - invalid graph order supplied
                - edgecount_limit exceeds (N*(N-1))/2

        Returns:
            Either the original int edgecount_limit if it is within the
                accepted range, or (N*(N-1))/2) for the given order
        """
        if order < 1:
            raise ValueError(
                "Unable to normalize edgecount limit given invalid graph "
                f"order {order}"
            )
        max_edgecount_for_order = max_num_edges_for_order(order)
        edgecount_limit = (
            edgecount_limit
            if (edgecount_limit is not None and edgecount_limit > -1)
            else max_edgecount_for_order
        )
        if edgecount_limit > max_edgecount_for_order:
            raise ValueError(
                f"Given edgecount argument {edgecount_limit} exceeds the "
                f"maximum edgecount for order {order}; please "
                "choose an edgecount limit less than or equal to "
                f"{max_edgecount_for_order}."
            )
        return edgecount_limit

    def initialize_driver(self) -> None:
        """Initialize correct driver for TTG with EDA based on init parameters"""
        if not self._orders_and_commands_are_valid():
            raise ValueError(
                "Unable to initialize driver for test table generation with "
                "numInvalidOKs due to invalid order or command tuples."
            )
        if len(self.orders) > 1 or len(self.generate_tables_for_commands) > 1:
            self.ttg_with_eda_driver = MultiOrderOrCommandTTGwithEDA(
                path_config=self.path_config,
                orders=self.orders,
                generate_tables_for_commands=self.generate_tables_for_commands,
            )
        elif (
            len(self.orders) == 1
            and len(self.generate_tables_for_commands) == 1
        ):
            assert self.edgecount_limit is not None
            self.ttg_with_eda_driver = ResumableTTGwithEDA(
                path_config=self.path_config,
                order=self.orders[0],
                generate_tables_for_command=self.generate_tables_for_commands[
                    0
                ],
                edgecount_limit=self.edgecount_limit,
            )

    def generate_test_tables_with_invalidOKs(self) -> None:
        """Leverage polymorphism to invoke correct behaviour based on params"""
        assert self.ttg_with_eda_driver is not None
        self.ttg_with_eda_driver.determine_numInvalidOKs()
        self.ttg_with_eda_driver.generate_tables_with_numInvalidOKs()


class TTGwithEDA(ABC):
    """Abstract base class defining common behaviour of TTG with EDA"""

    @abstractmethod
    def __init__(
        self,
        path_config: TTGWithEDAPathConfig,
    ) -> None:
        self.path_config = path_config
        self._numInvalidOKs = {}

    @abstractmethod
    def determine_numInvalidOKs(self) -> None:
        """Override in subclass to define logic for running EDA"""

    def _determine_numInvalidOKs(
        self, order: int, num_edges: int, command: str
    ) -> None:
        """Set up correct infile name and call EDA

        Args:
            order: The graph order for which we wish to perform EDA
            num_edges: The number of edges of the graphs in the .g6 file
            command: The command specifier for which we wish to perform EDA
        """
        canonical_ext = (
            ".canonical" if self.path_config.canonical_files else ""
        )
        makeg_ext = ".makeg" if self.path_config.makeg_files else ""

        assert self.path_config.graph_dir is not None
        g6_infile_path = Path.joinpath(
            self.path_config.graph_dir,
            f"{order}",
            f"n{order}.m{num_edges}{makeg_ext}{canonical_ext}.g6",
        )
        self._call_edge_deletion_analysis(order, command, g6_infile_path)

    def _call_edge_deletion_analysis(
        self, order: int, command: str, infile_path: Path
    ) -> None:
        """For given command, get numInvalidOKs for each order and edge-count

        Args:
            order: The order of the graphs in the input file
            command: string indicating graph search algorithm command specifier
                for which we wish to determine the number of invalid OKs using
                the edge-deletion analysis tool.
            infile_path: The Path of the .g6 input file on which you wish to
                perform the edge-deletion analysis
        """
        assert self.path_config.planarity_executable_path is not None
        eda = EdgeDeletionAnalyzer(
            planarity_path=self.path_config.planarity_executable_path,
            infile_path=infile_path,
            output_dir=None,
            extension_choice=command,
        )
        start_proc_time = time.process_time_ns()
        start_time = time.perf_counter_ns()
        numInvalidOK_for_infile = eda.analyze_graphs()
        end_proc_time = time.process_time_ns()
        end_time = time.perf_counter_ns()
        proc_time_s = (end_proc_time - start_proc_time) / 1e9
        tot_time_s = (end_time - start_time) / 1e9

        infile_name = infile_path.name
        eda.logger.info(
            "NUMBER OF INVALID OKs in '%s' is %d; took %fs process time and "
            "%fs total time",
            infile_name,
            numInvalidOK_for_infile,
            proc_time_s,
            tot_time_s,
        )

        # NOTE: Since the json spec disallows int as keys, one must cast the
        # order to a str
        order_key = str(order)
        if not self._numInvalidOKs.get(order_key):
            self._numInvalidOKs[order_key] = {}
        if not self._numInvalidOKs[order_key].get(command):
            self._numInvalidOKs[order_key][command] = {}
        self._numInvalidOKs[order_key][command][infile_name] = {
            "numInvalidOK": numInvalidOK_for_infile,
            "proc_time_s": proc_time_s,
            "tot_time_s": tot_time_s,
        }

    @abstractmethod
    def generate_tables_with_numInvalidOKs(self) -> None:
        """Override in subclass to define logic for generating tables"""

    def _generate_table_with_numInvalidOKs_for_order_and_command(
        self, order: int, command: str
    ) -> None:
        """For an order and command, compile planarity output and EDA results

        Args:
            order: The graph order for which you wish to generate test table
                with numInvalidOKs
            command: The algorithm command specifier for which testing was done
        """
        assert self.path_config.planarity_output_dir is not None
        input_dir_for_order_and_command = Path.joinpath(
            self.path_config.planarity_output_dir, f"{order}", f"{command}"
        )
        assert self.path_config.test_table_output_dir is not None
        ttg = TestTableGenerator(
            test_table_input_dir=input_dir_for_order_and_command,
            test_table_output_dir=self.path_config.test_table_output_dir,
            canonical_files=self.path_config.canonical_files,
            makeg_files=self.path_config.makeg_files,
            edge_deletion_analysis_results=self._numInvalidOKs.get(
                str(order), {}
            ).get(command, {}),
        )
        ttg.get_order_and_command_from_input_dir()
        ttg.process_files()
        ttg.write_table_formatted_data_to_file()


class MultiOrderOrCommandTTGwithEDA(TTGwithEDA):
    """Handles when user specifies a range of orders and/or commands"""

    def __init__(
        self,
        path_config: TTGWithEDAPathConfig,
        orders: tuple[int, ...],
        generate_tables_for_commands: tuple[str, ...],
    ) -> None:
        super().__init__(path_config)
        self.orders = orders
        self.generate_tables_for_commands = generate_tables_for_commands

    def determine_numInvalidOKs(self) -> None:
        """Run EdgeDeletionAnalyzer on all .g6 input files for the given order"""
        perform_eda_for_commands = tuple(
            c
            for c in EDGE_DELETION_ANALYSIS_SPECIFIERS()
            if c in self.generate_tables_for_commands
        )

        for order in self.orders:
            for command in perform_eda_for_commands:
                for num_edges in range(max_num_edges_for_order(order) + 1):
                    self._determine_numInvalidOKs(order, num_edges, command)

    def generate_tables_with_numInvalidOKs(self) -> None:
        """Incorporate edge-deletion analysis results into Test Tables"""
        for order in self.orders:
            for command in self.generate_tables_for_commands:
                self._generate_table_with_numInvalidOKs_for_order_and_command(
                    order, command
                )


class ResumableTTGwithEDA(TTGwithEDA):
    """When a user specifies a single graph order and a single algorithm
    command specifier (one of (2, 3, 4) corresponding to K_{2, 3}, K_{3, 3}, or
    K_4 search), we are able to leverage a resume file to save state after
    processing each edge-count.
    """

    def __init__(
        self,
        path_config: TTGWithEDAPathConfig,
        order: int,
        generate_tables_for_command: str,
        edgecount_limit: int,
    ) -> None:
        super().__init__(path_config)
        self.order = order
        self.command = generate_tables_for_command
        self.edgecount_limit = edgecount_limit
        (
            self._resume_file_path,
            self.highest_processed_edgecount,
            self._numInvalidOKs,
        ) = self._initialize_numInvalidOKs()

    def _initialize_numInvalidOKs(
        self,
    ) -> tuple[Path, int, dict[int, dict[str, dict[str, dict]]]]:
        """Initialize numInvalidOKs based on parameterization and resume file

        Raises:
            TypeError: resume_file_path exists but corresponds to a directory
            ValueError: json.load() fails when trying to load the resume file

        Returns:
            Path to resume file for order and command, the highest processed
            edge-count, and the initialized numInvalidOKs dict. This dict
            either is empty if the resume file doesn't exist, or otherwise has
            been populated with the validated contents of the resume file.
        """
        numInvalidOKs = {}
        highest_processed_edgecount = 0
        resume_file_path = self._determine_resume_file_path()
        if resume_file_path.exists():
            if resume_file_path.is_dir():
                raise TypeError(
                    f"Path to resume file for order {self.order} and command "
                    f"{self.command} corresponds to a directory. Please "
                    f"delete '{resume_file_path}' and retry."
                )
            with open(resume_file_path, "r", encoding="utf-8") as resume_file:
                try:
                    resume_file_contents = json.load(
                        resume_file, object_pairs_hook=raise_on_duplicates
                    )
                except json.JSONDecodeError as invalid_resume_file_error:
                    raise ValueError(
                        "Unable to load resume file contents from "
                        f"path '{resume_file_path}'; please delete and "
                        "retry."
                    ) from invalid_resume_file_error
                highest_processed_edgecount = self._validate_resume_file(
                    resume_file_contents
                )
                numInvalidOKs = resume_file_contents
        return resume_file_path, highest_processed_edgecount, numInvalidOKs

    def _determine_resume_file_path(self) -> Path:
        """If command line parameters allow, what resume file path to use?"""
        resume_file_dir = Path.joinpath(
            self.path_config.planarity_project_root,
            "TestSupport",
            "results",
            "test_table_generation_with_numInvalidOK",
        )
        Path.mkdir(resume_file_dir, parents=True, exist_ok=True)

        return resume_file_dir / (f"N_{self.order}-C_{self.command}" ".json")

    def _validate_resume_file(
        self, resume_file_contents: dict[str, dict[str, dict[str, dict]]]
    ) -> int:
        """Determine if resume file contents are valid for specified params

        Args:
            resume_file_contents: the results of json.load() on the resume file
                for the current order and command

        Raises:
            ValueError: if the resume file contents don't match:
                {
                    <graph order N>:
                    {
                        "<command specifier C>":
                        {
                            <dict mapping infile name to numInvalidOK data
                            for that edge-count>
                        }
                    }
                }

        Returns:
            The highest_processed_edgecount determined when validating the dict
        """
        highest_processed_edgecount = 0
        if len(resume_file_contents) > 1:
            raise ValueError(
                f"Resume file '{self._resume_file_path}' is invalid; "
                "contains multiple top-level keys: "
                f"'{resume_file_contents.keys()}'. Should only contain one "
                "graph order."
            )
        if len(resume_file_contents) == 1:
            contents_for_order = resume_file_contents.get(str(self.order), {})
            if len(contents_for_order) > 1:
                raise ValueError(
                    f"Resume file '{self._resume_file_path}' is invalid; "
                    "contains multiple secondary keys: "
                    f"'{contents_for_order.keys()}'. Should only contain one "
                    "command specifier."
                )
            if len(contents_for_order) == 1:
                contents_for_command = contents_for_order.get(self.command, {})
                if contents_for_command:
                    highest_processed_edgecount = (
                        self._validate_numInvalidOK_data(contents_for_command)
                    )
        return highest_processed_edgecount

    def _validate_numInvalidOK_data(
        self, contents_for_order_and_command: dict[str, dict]
    ) -> int:
        """Check data for each previously processed edge-count in resume file

        Args:
            contents_for_order_and_command: dict; maps infile name to EDA data

        Raises:
            ValueError: If the contents for the given order and command don't
                match:
                    {
                        "<infile_name for M=0>":
                        {
                            "numInvalidOK": numInvalidOK,
                            "proc_time_s": proc_time_s,
                            "tot_time_s": tot_time_s
                        },
                        "<infile_name for M=1>":
                        {
                            "numInvalidOK": numInvalidOK,
                            "proc_time_s": proc_time_s,
                            "tot_time_s": tot_time_s
                        },
                        ...
                        "<infile_name for M=highest_processed_edgecount>":
                        {
                            "numInvalidOK": numInvalidOK,
                            "proc_time_s": proc_time_s,
                            "tot_time_s": tot_time_s
                        }
                    }

        Returns:
            The highest_processed_edgecount determined when validating the dict
        """
        highest_processed_edgecount = 0
        processed_edgecounts = []
        for infile_name in contents_for_order_and_command:
            match = re.match(
                r"n(?P<order>\d+)\.m(?P<edgecount>\d+)",
                infile_name,
            )
            if not match:
                raise ValueError(
                    "Invalid infile name in resume file: " f"'{infile_name}'."
                )
            order_from_infile = match.group("order")
            if not order_from_infile:
                raise ValueError(
                    "Invalid infile name in resume file: "
                    f"'{infile_name}' is missing graph order."
                )
            try:
                order_from_infile = int(order_from_infile)
            except TypeError as order_from_infile_conversion_error:
                raise ValueError(
                    "Unable to convert order extracted from "
                    "infile to int; received "
                    f"'{order_from_infile}'"
                ) from order_from_infile_conversion_error
            if order_from_infile != self.order:
                raise ValueError(
                    "Invalid infile name in resume file: "
                    f"'{infile_name}' corresponds to the wrong "
                    "graph order."
                )
            edgecount_from_infile = match.group("edgecount")
            if not edgecount_from_infile:
                raise ValueError(
                    "Invalid infile name in resume file: "
                    f"'{infile_name}' is missing edgecount."
                )
            try:
                edgecount_from_infile = int(edgecount_from_infile)
            except TypeError as edgecount_from_infile_conversion_error:
                raise ValueError(
                    "Unable to convert edgecount extracted from "
                    "infile to int; received "
                    f"'{edgecount_from_infile}'"
                ) from edgecount_from_infile_conversion_error
            if (
                edgecount_from_infile < 0
                or edgecount_from_infile > self.edgecount_limit
            ):
                raise ValueError(
                    "Invalid infile name in resume file: "
                    f"'{infile_name}' contains edgecount "
                    f"{edgecount_from_infile} which is outside of range "
                    f"[0, {self.edgecount_limit}]; either delete resume file "
                    "or adjust edge-count limit."
                )
            highest_processed_edgecount = max(
                highest_processed_edgecount, edgecount_from_infile
            )
            processed_edgecounts.append(edgecount_from_infile)
        processed_edgecounts.sort()
        for x, y in zip(
            processed_edgecounts,
            range(0, max(processed_edgecounts) + 1),
        ):
            if x != y:
                raise ValueError(
                    "Invalid resume file contents: missing data "
                    f"for edgecount {y}; delete the resume file "
                    "and retry."
                )
        if highest_processed_edgecount == self.edgecount_limit:
            response = input(
                "Highest processed edge-count is equal to specified edge-"
                "count limit; do you wish to proceed (yY)?\n"
                "\t(If not, press any other key to abort, then delete the "
                "existing resume file)\n"
            )
            if response.lower() != "y":
                sys.exit(0)
        return highest_processed_edgecount

    def determine_numInvalidOKs(self) -> None:
        """Define logic for running EDA while interacting with resume file"""
        starting_edgecount = (
            self.highest_processed_edgecount + 1
            if self.highest_processed_edgecount > 0
            else 0
        )
        for num_edges in range(starting_edgecount, self.edgecount_limit + 1):
            self._determine_numInvalidOKs(self.order, num_edges, self.command)
            assert self._resume_file_path is not None
            Path.mkdir(
                self._resume_file_path.parent, exist_ok=True, parents=True
            )
            with open(
                self._resume_file_path, "w", encoding="utf-8"
            ) as resume_file:
                json.dump(self._numInvalidOKs, resume_file, indent=4)

    def generate_tables_with_numInvalidOKs(self) -> None:
        """Define logic for generating table for single order and command"""
        self._generate_table_with_numInvalidOKs_for_order_and_command(
            order=self.order, command=self.command
        )
        Path.unlink(self._resume_file_path, missing_ok=True)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawTextHelpFormatter,
        usage="python %(prog)s [options]",
        description="Test Table Generation including numInvalidOKs\n\n"
        "For each specified graph order:\n"
        "1. For each edgecount from 0 to (N * (N - 1))/2, runs the "
        "EdgeDeletionAnalyzer for K_{2, 3} search, K_{3, 3} search, and K_4 "
        "search to determine the numInvalidOKs for those graph algorithm "
        "extensions.\n"
        "2. For each of the supported graph algorithms, Tabulates results "
        "from running planarity's Test All Graphs functionality, "
        "incorporating the numInvalidOKs if applicable.\n",
    )
    parser.add_argument(
        "-p",
        "--planaritypath",
        type=Path,
        required=False,
        help="Path to planarity executable. Defaults to:\n"
        "\t{planarity_project_root}/Release/planarity",
        metavar="PATH_TO_PLANARITY_EXECUTABLE",
    )
    parser.add_argument(
        "-n",
        "--orders",
        type=parse_range,
        required=False,
        help="Order(s) of graphs for which you wish to generate Test Tables; "
        f"for commands {EDGE_DELETION_ANALYSIS_SPECIFIERS()}, will include "
        "numInvalidOKs derived by edge-deletion analysis.",
        metavar="X[,Y]",
    )
    parser.add_argument(
        "-e",
        "--edgecountlimit",
        type=int,
        required=False,
        help="If only a single graph order and graph search algorithm are "
        "specified, perform EDA for edge-counts up to and including this "
        "value.",
        metavar="M",
    )
    parser.add_argument(
        "-c",
        "--gentablecommands",
        nargs="+",
        default=[],
        required=False,
        help="List of algorithm command specifiers for which you wish to "
        "generate test tables. Defaults to "
        f"{PLANARITY_ALGORITHM_SPECIFIERS()}",
    )
    parser.add_argument(
        "-g",
        "--graphdir",
        type=Path,
        required=False,
        help="Path to parent directory containing subdirectories for each "
        "graph order, the contents of which are assumed to be .g6 files (one "
        "for each number of edges from 0 to (N * (N - 1))/2). Defaults to:\n"
        "\tTestSupport/results/graph_generation_orchestrator/",
        metavar="PATH_TO_GRAPH_PARENT_DIR",
    )
    parser.add_argument(
        "-t",
        "--planarityoutputdir",
        type=Path,
        required=False,
        help="Path to parent directory containing subdirectories for each "
        "graph order, the contents of which are subdirectories for each graph "
        f"algorithm extension (i.e. {PLANARITY_ALGORITHM_SPECIFIERS()}). Each "
        "of these directories contains the results of running planarity Test "
        "All Graphs for the given command on all graphs of a given order and "
        "number of edges. Defaults to:\n"
        "\tTestSupport/results/planarity_testAllGraphs_orchestrator/\n"
        "The directories will have the form:\n"
        "\t{planarity_output_dir}/{order}/{command}/\n"
        "And will contain files with names of the form:\n"
        "\tn{order}.m{num_edges}(.makeg)?(.canonical)?.{command}.out.txt",
        metavar="PATH_TO_PLANARITY_OUTPUT",
    )
    parser.add_argument(
        "-o",
        "--outputdir",
        type=Path,
        default=None,
        metavar="OUTPUT_DIR_PATH",
        help="Parent directory under which subdirectory named after each"
        "{order} will be created for output results. If none provided, "
        "defaults to:\n"
        "\tTestSupport/tables/\n"
        "For each chosen order, output file paths have the form:\n"
        "\t{output_dir}/{order}/n{order}.mALL.{command}.out.txt",
    )
    parser.add_argument(
        "-l",
        "--canonicalfiles",
        action="store_true",
        help="Indicates .g6 input files are in canonical form",
    )
    parser.add_argument(
        "-m",
        "--makegfiles",
        action="store_true",
        help="Indicates .g6 input files were generated by makeg",
    )
    args = parser.parse_args()
    path_config_from_args = TTGWithEDAPathConfig(
        planarity_executable_path=args.planaritypath,
        graph_dir=args.graphdir,
        planarity_output_dir=args.planarityoutputdir,
        test_table_output_dir=args.outputdir,
        canonical_files=args.canonicalfiles,
        makeg_files=args.makegfiles,
    )
    eda_ttg_orchestrator = TTGwithEDAOrchestrator(
        path_config=path_config_from_args,
        orders=args.orders,
        edgecount_limit=args.edgecountlimit,
        generate_tables_for_commands=args.gentablecommands,
    )
    eda_ttg_orchestrator.initialize_driver()
    eda_ttg_orchestrator.generate_test_tables_with_invalidOKs()
