import textwrap
import re
from click import style as color

DESCRIPTIONS = {
    "Append": "Used in a UNION to merge multiple record sets by appending them together.",
    "Limit": "Returns a specified number of rows from a record set.",
    "Sort": "Sorts a record set based on the specified sort key.",
    "Nested Loop": "Merges two record sets by looping through every record in the first set and trying to find a match in the second set. All matching records are returned.",
    "Merge Join": "Merges two record sets by first sorting them on a join key.",
    "Hash": "Generates a hash table from the records in the input recordset. Hash is used by Hash Join.",
    "Hash Join": "Joins to record sets by hashing one of them (using a Hash Scan).",
    "Aggregate": "Groups records together based on a GROUP BY or aggregate function (e.g. sum()).",
    "Hashaggregate": "Groups records together based on a GROUP BY or aggregate function (e.g. sum()). Hash Aggregate uses a hash to first organize the records by a key.",
    "Sequence Scan": "Finds relevant records by sequentially scanning the input record set. When reading from a table, Seq Scans (unlike Index Scans) perform a single read operation (only the table is read).",
    "Seq Scan": "Finds relevant records by sequentially scanning the input record set. When reading from a table, Seq Scans (unlike Index Scans) perform a single read operation (only the table is read).",
    "Index Scan": "Finds relevant records based on an Index. Index Scans perform 2 read operations: one to read the index and another to read the actual value from the table.",
    "Index Only Scan": "Finds relevant records based on an Index. Index Only Scans perform a single read operation from the index and do not read from the corresponding table.",
    "Bitmap Heap Scan": "Searches through the pages returned by the Bitmap Index Scan for relevant rows.",
    "Bitmap Index Scan": "Uses a Bitmap Index (index which uses 1 bit per page) to find all relevant pages. Results of this node are fed to the Bitmap Heap Scan.",
    "CTEScan": "Performs a sequential scan of Common Table Expression (CTE) query results. Note that results of a CTE are materialized (calculated and temporarily stored).",
    "ProjectSet": "ProjectSet appears when the SELECT or ORDER BY clause of the query.  They basically just execute the set-returning function(s) for each tuple until none of the functions return any more records.",
    "Result": "Returns result",
}


class Visualizer:
    def __init__(self, terminal_width=100, color=True):
        self.color = color
        self.terminal_width = terminal_width
        self.string_lines = []

    def load(self, explain_dict):
        self.plan = explain_dict.pop("Plan")
        self.explain = explain_dict
        self.process_all()
        self.generate_lines()

    def process_all(self):
        self.plan = self.process_plan(self.plan)
        self.plan = self.calculate_outlier_nodes(self.plan)

    #
    def process_plan(self, plan):
        plan = self.calculate_planner_estimate(plan)
        plan = self.calculate_actuals(plan)
        self.calculate_maximums(plan)
        #
        for index in range(len(plan.get("Plans", []))):
            _plan = plan["Plans"][index]
            plan["Plans"][index] = self.process_plan(_plan)
        return plan

    def prefix_format(self, v):
        if self.color:
            return color(v, fg="bright_black")
        return v

    def tag_format(self, v):
        if self.color:
            return color(v, fg="white", bg="red")
        return v

    def muted_format(self, v):
        if self.color:
            return color(v, fg="bright_black")
        return v

    def bold_format(self, v):
        if self.color:
            return color(v, fg="white")
        return v

    def good_format(self, v):
        if self.color:
            return color(v, fg="green")
        return v

    def warning_format(self, v):
        if self.color:
            return color(v, fg="yellow")
        return v

    def critical_format(self, v):
        if self.color:
            return color(v, fg="red")
        return v

    def output_format(self, v):
        if self.color:
            return color(v, fg="cyan")
        return v

    def calculate_planner_estimate(self, plan):
        plan["Planner Row Estimate Factor"] = 0
        plan["Planner Row Estimate Direction"] = "Under"

        if plan["Plan Rows"] == plan["Actual Rows"]:
            return plan

        if plan["Plan Rows"] != 0:
            plan["Planner Row Estimate Factor"] = plan["Actual Rows"] / plan["Plan Rows"]

        if plan["Planner Row Estimate Factor"] < 10:
            plan["Planner Row Estimate Factor"] = 0
            plan["Planner Row Estimate Direction"] = "Over"
            if plan["Actual Rows"] != 0:
                plan["Planner Row Estimate Factor"] = plan["Plan Rows"] / plan["Actual Rows"]
        return plan

    #
    def calculate_actuals(self, plan):
        plan["Actual Duration"] = plan["Actual Total Time"]
        plan["Actual Cost"] = plan["Total Cost"]

        for child in plan.get("Plans", []):
            if child["Node Type"] != "CTEScan":
                plan["Actual Duration"] = plan["Actual Duration"] - child["Actual Total Time"]
                plan["Actual Cost"] = plan["Actual Cost"] - child["Total Cost"]

        if plan["Actual Cost"] < 0:
            plan["Actual Cost"] = 0

        plan["Actual Duration"] = plan["Actual Duration"] * plan["Actual Loops"]
        return plan

    def calculate_outlier_nodes(self, plan):
        plan["Costliest"] = plan["Actual Cost"] == self.explain["Max Cost"]
        plan["Largest"] = plan["Actual Rows"] == self.explain["Max Rows"]
        plan["Slowest"] = plan["Actual Duration"] == self.explain["Max Duration"]

        for index in range(len(plan.get("Plans", []))):
            _plan = plan["Plans"][index]
            plan["Plans"][index] = self.calculate_outlier_nodes(_plan)
        return plan

    def calculate_maximums(self, plan):
        if not self.explain.get("Max Rows"):
            self.explain["Max Rows"] = plan["Actual Rows"]
        elif self.explain.get("Max Rows") < plan["Actual Rows"]:
            self.explain["Max Rows"] = plan["Actual Rows"]

        if not self.explain.get("Max Cost"):
            self.explain["Max Cost"] = plan["Actual Cost"]
        elif self.explain.get("Max Cost") < plan["Actual Cost"]:
            self.explain["Max Cost"] = plan["Actual Cost"]

        if not self.explain.get("Max Duration"):
            self.explain["Max Duration"] = plan["Actual Duration"]
        elif self.explain.get("Max Duration") < plan["Actual Duration"]:
            self.explain["Max Duration"] = plan["Actual Duration"]

        if not self.explain.get("Total Cost"):
            self.explain["Total Cost"] = plan["Actual Cost"]
        elif self.explain.get("Total Cost") < plan["Actual Cost"]:
            self.explain["Total Cost"] = plan["Actual Cost"]

    #
    def duration_to_string(self, value):
        if value < 1:
            return self.good_format("<1 ms")
        elif value < 100:
            return self.good_format("%.2f ms" % value)
        elif value < 1000:
            return self.warning_format("%.2f ms" % value)
        elif value < 60000:
            return self.critical_format(
                "%.2f s" % (value / 1000.0),
            )
        else:
            return self.critical_format(
                "%.2f m" % (value / 60000.0),
            )

    # }
    #
    def format_details(self, plan):
        details = []

        if plan.get("Scan Direction"):
            details.append(plan["Scan Direction"])

        if plan.get("Strategy"):
            details.append(plan["Strategy"])

        if len(details) > 0:
            return self.muted_format(" [%s]" % ", ".join(details))

        return ""

    def format_tags(self, plan):
        tags = []

        if plan["Slowest"]:
            tags.append(self.tag_format("slowest"))
        if plan["Costliest"]:
            tags.append(self.tag_format("costliest"))
        if plan["Largest"]:
            tags.append(self.tag_format("largest"))
        if plan.get("Planner Row Estimate Factor", 0) >= 100:
            tags.append(self.tag_format("bad estimate"))

        return " ".join(tags)

    def get_terminator(self, index, plan):
        if index == 0:
            if len(plan.get("Plans", [])) == 0:
                return "⌡► "
            else:
                return "├►  "
        else:
            if len(plan.get("Plans", [])) == 0:
                return "   "
            else:
                return "│  "

    def wrap_string(self, line, width):
        if width == 0:
            return [line]
        return textwrap.wrap(line, width)

    def intcomma(self, value):
        sep = ","
        if not isinstance(value, str):
            value = int(value)

        orig = str(value)

        new = re.sub(r"^(-?\d+)(\d{3})", rf"\g<1>{sep}\g<2>", orig)
        if orig == new:
            return new
        else:
            return self.intcomma(new)

    def output_fn(self, current_prefix, string):
        return "%s%s" % (self.prefix_format(current_prefix), string)

    def create_lines(self, plan, prefix, depth, width, last_child):
        current_prefix = prefix
        self.string_lines.append(self.output_fn(current_prefix, self.prefix_format("│")))

        joint = "├"
        if last_child:
            joint = "└"
        #
        self.string_lines.append(
            self.output_fn(
                current_prefix,
                "%s %s%s %s"
                % (
                    self.prefix_format(joint + "─⌠"),
                    self.bold_format(plan["Node Type"]),
                    self.format_details(plan),
                    self.format_tags(plan),
                ),
            )
        )
        #
        if last_child:
            prefix += "  "
        else:
            prefix += "│ "

        current_prefix = prefix + "│ "

        cols = width - len(current_prefix)

        for line in self.wrap_string(
            DESCRIPTIONS.get(plan["Node Type"], "Not found : %s" % plan["Node Type"]),
            cols,
        ):
            self.string_lines.append(self.output_fn(current_prefix, "%s" % self.muted_format(line)))
        #
        if plan.get("Actual Duration"):
            self.string_lines.append(
                self.output_fn(
                    current_prefix,
                    "○ %s %s (%.0f%%)"
                    % (
                        "Duration:",
                        self.duration_to_string(plan["Actual Duration"]),
                        (plan["Actual Duration"] / self.explain["Execution Time"]) * 100,
                    ),
                )
            )

        self.string_lines.append(
            self.output_fn(
                current_prefix,
                "○ %s %s (%.0f%%)"
                % (
                    "Cost:",
                    self.intcomma(plan["Actual Cost"]),
                    (plan["Actual Cost"] / self.explain["Total Cost"]) * 100,
                ),
            )
        )

        self.string_lines.append(
            self.output_fn(
                current_prefix,
                "○ %s %s" % ("Rows:", self.intcomma(plan["Actual Rows"])),
            )
        )

        current_prefix = current_prefix + "  "

        if plan.get("Join Type"):
            self.string_lines.append(
                self.output_fn(
                    current_prefix,
                    "%s %s" % (plan["Join Type"], self.muted_format("join")),
                )
            )

        if plan.get("Relation Name"):
            self.string_lines.append(
                self.output_fn(
                    current_prefix,
                    "%s %s.%s"
                    % (
                        self.muted_format("on"),
                        plan.get("Schema", "unknown"),
                        plan["Relation Name"],
                    ),
                )
            )

        if plan.get("Index Name"):
            self.string_lines.append(
                self.output_fn(
                    current_prefix,
                    "%s %s" % (self.muted_format("using"), plan["Index Name"]),
                )
            )

        if plan.get("Index Condition"):
            self.string_lines.append(
                self.output_fn(
                    current_prefix,
                    "%s %s" % (self.muted_format("condition"), plan["Index Condition"]),
                )
            )

        if plan.get("Filter"):
            self.string_lines.append(
                self.output_fn(
                    current_prefix,
                    "%s %s %s"
                    % (
                        self.muted_format("filter"),
                        plan["Filter"],
                        self.muted_format("[-%s rows]" % self.intcomma(plan["Rows Removed by Filter"])),
                    ),
                )
            )

        if plan.get("Hash Condition"):
            self.string_lines.append(
                self.output_fn(
                    current_prefix,
                    "%s %s" % (self.muted_format("on"), plan["Hash Condition"]),
                )
            )

        if plan.get("CTE Name"):
            self.string_lines.append(self.output_fn(current_prefix, "CTE %s" % plan["CTE Name"]))

        if plan.get("Planner Row Estimate Factor") != 0:
            self.string_lines.append(
                self.output_fn(
                    current_prefix,
                    "%s %sestimated %s %.2fx"
                    % (
                        self.muted_format("rows"),
                        plan["Planner Row Estimate Direction"],
                        self.muted_format("by"),
                        plan["Planner Row Estimate Factor"],
                    ),
                )
            )

        current_prefix = prefix

        if len(plan.get("Output", [])) > 0:
            for index, line in enumerate(self.wrap_string(" + ".join(plan["Output"]), cols)):
                self.string_lines.append(
                    self.output_fn(
                        current_prefix,
                        self.prefix_format(self.get_terminator(index, plan)) + self.output_format(line),
                    )
                )

        for index, nested_plan in enumerate(plan.get("Plans", [])):
            self.create_lines(nested_plan, prefix, depth + 1, width, index == len(plan["Plans"]) - 1)

    def generate_lines(self):
        self.string_lines = [
            "○ Total Cost: %s" % self.intcomma(self.explain["Total Cost"]),
            "○ Planning Time: %s" % self.duration_to_string(self.explain["Planning Time"]),
            "○ Execution Time: %s" % self.duration_to_string(self.explain["Execution Time"]),
            self.prefix_format("┬"),
        ]
        self.create_lines(
            self.plan,
            "",
            0,
            self.terminal_width,
            len(self.plan.get("Plans", [])) == 1,
        )

    def get_list(self):
        return "\n".join(self.string_lines)

    def print(self):
        for lin in self.string_lines:
            print(lin)
