#!/usr/pkg/bin/python3.12
# coding=utf-8
#  nicechart.py
#
#  Copyright 2011-2016
#
#  Christoph Sterz
#  Florian Weber
#  Maren Hachmann
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software
#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#  MA 02110-1301, USA.
#
# pylint: disable=attribute-defined-outside-init

# TODO / Ideas:
# allow negative values for bar charts
# show values for stacked bar charts
# don't create a new layer for each chart, but a normal group
# correct bar height for stacked bars (it's only half as high as it should be, double)
# adjust position of heading
# use aliasing workaround for stacked bars (e.g. let the rectangles overlap)

# The extension creates one chart for a single value column in one go,
# e.g. chart all temperatures for all months of the year 1978 into one chart.
# (for this, select column 0 for labels and column 1 for values).
# "1978" etc. can be used as heading (Need not be numeric. If not used delete the heading line.)
# Month names can be used as labels
# Values can be shown, in addition to labels (doesn't work with stacked bar charts)
# Values can contain commas as decimal separator, as long as delimiter isn't comma
# Negative values are not yet supported.

# See tests/data/nicechart_01.csv for example data

import re
import csv
import math

from argparse import ArgumentTypeError

import inkex
from inkex.utils import filename_arg
from inkex import Filter, TextElement, Circle, Rectangle
from inkex.paths import Move, line
from inkex.localization import inkex_gettext as _

# www.sapdesignguild.org/goodies/diagram_guidelines/color_palettes.html#mss
COLOUR_TABLE = {
    "red": [
        "#460101",
        "#980101",
        "#d40000",
        "#f44800",
        "#fb8b00",
        "#eec73e",
        "#d9bb7a",
        "#fdd99b",
    ],
    "blue": ["#000442", "#0F1781", "#252FB7", "#3A45E1", "#656DDE", "#8A91EC"],
    "gray": [
        "#222222",
        "#444444",
        "#666666",
        "#888888",
        "#aaaaaa",
        "#cccccc",
        "#eeeeee",
    ],
    "contrast": ["#0000FF", "#FF0000", "#00FF00", "#CF9100", "#FF00FF", "#00FFFF"],
    "sap": [
        "#f8d753",
        "#5c9746",
        "#3e75a7",
        "#7a653e",
        "#e1662a",
        "#74796f",
        "#c4384f",
        "#fff8a3",
        "#a9cc8f",
        "#b2c8d9",
        "#bea37a",
        "#f3aa79",
        "#b5b5a9",
        "#e6a5a5",
    ],
}


class NiceChart(inkex.GenerateExtension):
    """
    Inkscape extension that can draw pie charts and bar charts
    (stacked, single, horizontally or vertically)
    with optional drop shadow, from a csv file or from pasted text
    """

    container_layer = True

    @property
    def container_label(self):
        """Layer title/label"""
        return _("Chart-Layer: {}").format(self.options.what)

    def add_arguments(self, pars):
        pars.add_argument("--tab")
        pars.add_argument("--encoding", default="utf-8")
        pars.add_argument(
            "-w",
            "--what",
            default="apples:3,bananas:5,oranges:10,pears:4",
            help="Chart Values",
        )
        pars.add_argument(
            "-t",
            "--type",
            type=self.arg_method("render"),
            default=self.render_bar,
            help="Chart Type",
        )
        pars.add_argument(
            "-b", "--blur", type=inkex.Boolean, default=False, help="Blur Type"
        )
        pars.add_argument("-f", "--filename", type=filename_arg, help="Name of File")
        pars.add_argument("-i", "--input_type", default="file", help="Chart Type")
        pars.add_argument("-d", "--delimiter", default=";", help="delimiter")
        pars.add_argument("-c", "--colors", default="default", help="color-scheme")
        pars.add_argument("--colors_override", help="color-scheme-override")
        pars.add_argument(
            "--reverse_colors",
            type=inkex.Boolean,
            default=False,
            help="reverse color-scheme",
        )
        pars.add_argument(
            "-k", "--col_key", type=int, default=0, help="column that contains the keys"
        )
        pars.add_argument(
            "-v",
            "--col_val",
            type=int,
            default=1,
            help="column that contains the values",
        )
        pars.add_argument(
            "--headings",
            type=inkex.Boolean,
            default=False,
            help="first line of the CSV file consists of headings for the columns",
        )
        pars.add_argument(
            "-r",
            "--rotate",
            type=inkex.Boolean,
            default=False,
            help="Draw barchart horizontally",
        )
        pars.add_argument(
            "-W", "--bar-width", type=int, default=10, help="width of bars"
        )
        pars.add_argument(
            "-p", "--pie-radius", type=int, default=100, help="radius of pie-charts"
        )
        pars.add_argument(
            "-H", "--bar-height", type=int, default=100, help="height of bars"
        )
        pars.add_argument(
            "-O", "--bar-offset", type=int, default=5, help="distance between bars"
        )
        pars.add_argument("--stroke-width", type=float, default=1.0)
        pars.add_argument(
            "-o",
            "--text-offset",
            type=int,
            default=5,
            help="distance between bar and descriptions",
        )
        pars.add_argument(
            "--heading-offset",
            type=int,
            default=50,
            help="distance between chart and chart title",
        )
        pars.add_argument(
            "--segment-overlap",
            type=inkex.Boolean,
            default=False,
            help="Remove aliasing effects by letting pie chart segments overlap",
        )
        pars.add_argument(
            "-F", "--font", default="sans-serif", help="font of description"
        )
        pars.add_argument(
            "-S", "--font-size", type=int, default=10, help="font size of description"
        )
        pars.add_argument(
            "-C", "--font-color", default="#000000", help="font color of description"
        )

        pars.add_argument(
            "-V",
            "--show_values",
            type=inkex.Boolean,
            default=False,
            help="Show values in chart",
        )

    def get_data(self):
        """Process the data"""
        col_key = self.options.col_key
        col_val = self.options.col_val

        def process_value(val):
            """Confirm the values from files or direct"""
            val = float(val)
            if val < 0:
                raise inkex.AbortExtension(
                    _("Negative values are currently not supported!")
                )
            return val

        if self.options.input_type == "file":
            if self.options.filename is None:
                raise inkex.AbortExtension(_("Filename not specified!"))

            # Future: use encoding when opening the file here (if ever needed)
            with open(self.options.filename, "r") as fhl:
                reader = csv.reader(fhl, delimiter=self.options.delimiter)
                title = col_val

                if self.options.headings:
                    header = next(reader)
                    title = header[col_val]

                values = [
                    (line[col_key], process_value(line[col_val])) for line in reader
                ]
                return (title,) + tuple(zip(*values))

        elif self.options.input_type == "direct_input":
            (keys, values) = zip(
                *[l.split(":", 1) for l in self.options.what.split(",")]
            )
            return ("Direct Input", keys, [process_value(val) for val in values])

        raise inkex.AbortExtension(_("Unknown input type"))

    def get_blur(self):
        """Add blur to the svg and return if needed"""
        if self.options.blur:
            defs = self.svg.defs
            # Create new Filter
            filt = defs.add(Filter(height="3", width="3", x="-0.5", y="-0.5"))
            # Append Gaussian Blur to that Filter
            filt.add_primitive("feGaussianBlur", stdDeviation="1.1")
            return inkex.Style(filter=filt.get_id(as_url=2))
        return inkex.Style()

    def get_color(self):
        """Get the next available color"""
        if not hasattr(self, "_colors"):
            # Generate list of available colours
            if self.options.colors_override:
                colors = self.options.colors_override.strip()
            else:
                colors = self.options.colors

            if colors[0].isalpha():
                colors = COLOUR_TABLE.get(colors.lower(), COLOUR_TABLE["red"])

            else:
                colors = re.findall("(#[0-9a-fA-F]{6})", colors)
                # to be sure we create a fallback:
                if not colors:
                    colors = COLOUR_TABLE["red"]

            if self.options.reverse_colors:
                colors.reverse()
            # Cache the list of colours for later use
            self._colors = colors
            self._color_index = 0

        color = self._colors[self._color_index]
        # Increase index to the next available color
        self._color_index = (self._color_index + 1) % len(self._colors)
        return color

    def generate(self):
        """Generates a nice looking chart into SVG document."""

        # Process the data from a file or text box
        (self.title, keys, values) = self.get_data()
        if not values:
            raise inkex.AbortExtension(_("No data to render into a chart."))

        # Get the page attributes:
        self.width = self.svg.unittouu(self.svg.get("width"))
        self.height = self.svg.unittouu(self.svg.attrib["height"])
        self.fontoff = float(self.options.font_size) / 3

        # Check if a drop shadow should be drawn:
        self.blur = self.get_blur()

        # Draw the right type of chart
        for elem in self.options.type(keys, values):
            yield elem

    def draw_header(self, heading_x):
        """Draw an optional header text"""
        if self.options.headings and self.title:
            headingtext = self.draw_text(self.title, 4, anchor="end")
            headingtext.set("y", str(self.height / 2 + self.options.heading_offset))
            headingtext.set("x", str(heading_x))
            return headingtext
        return None

    def render_bar(self, keys, values):
        """Draw bar chart"""
        bar_height = self.options.bar_height
        bar_width = self.options.bar_width
        bar_offset = self.options.bar_offset

        # Normalize the bars to the largest value
        value_max = max(list(values) + [0.0])

        # Draw Single bars with their shadows
        for cnt, value in enumerate(values):
            # Draw each bar a set amount offset
            offset = cnt * (bar_width + bar_offset)
            bar_value = (value / value_max) * bar_height

            # Calculate the location of the bar
            x = self.width / 2 + offset
            y = self.height / 2 - int(bar_value)
            width = bar_width
            height = int(bar_value)

            if self.options.rotate:
                # Rotate the bar and align to the left
                x, y, width, height = y, x, height, width
                x += width

            for elem in self.draw_rectangle(x, y, width, height):
                yield elem

            # If keys are given, create text elements
            if keys:
                text = self.draw_text(keys[cnt], anchor="end")
                if not self.options.rotate:  # =vertical
                    text.set("transform", "rotate(-90)")
                    # y after rotation:
                    text.set("x", "-" + str(self.height / 2 + self.options.text_offset))
                    # x after rotation:
                    text.set(
                        "y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)
                    )
                else:  # =horizontal
                    text.set(
                        "y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)
                    )
                    text.set("x", str(self.height / 2 - self.options.text_offset))

                yield text

            if self.options.show_values:
                vtext = self.draw_text(int(value))
                if not self.options.rotate:  # =vertical
                    vtext.set("transform", "rotate(-90)")
                    # y after rotation:
                    vtext.set(
                        "x",
                        "-" + str(self.height / 2 + value - self.options.text_offset),
                    )
                    # x after rotation:
                    vtext.set(
                        "y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)
                    )
                else:  # =horizontal
                    vtext.set(
                        "y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)
                    )
                    vtext.set(
                        "x", str(self.height / 2 + value + self.options.text_offset)
                    )
                yield vtext

        yield self.draw_header(self.width / 2)

    def draw_rectangle(self, x, y, width, height):
        """Draw a rectangle bar with optional shadow"""
        if self.blur:
            shadow = Rectangle(
                x=str(x + 1), y=str(y + 1), width=str(width), height=str(height)
            )
            shadow.style = self.blur
            yield shadow

        rect = Rectangle(x=str(x), y=str(y), width=str(width), height=str(height))
        rect.set("style", "fill:" + self.get_color())
        yield rect

    def draw_text(self, text, add_size=0, anchor="start", **kwargs):
        """Draw a textual label"""
        vtext = TextElement(**kwargs)
        vtext.style = {
            "fill": self.options.font_color,
            "font-family": self.options.font,
            "font-size": str(self.options.font_size + add_size) + "px",
            "font-style": "normal",
            "font-variant": "normal",
            "font-weight": "normal",
            "font-stretch": "normal",
            "-inkscape-font-specification": "Bitstream Charter",
            "text-align": anchor,
            "text-anchor": anchor,
        }
        vtext.text = str(text)
        return vtext

    def render_pie_abs(self, keys, values):
        """Draw a pie chart, with absolute values"""
        # pie_abs = True
        for elem in self.render_pie(keys, values, True):
            yield elem

    def render_pie(self, keys, values, pie_abs=False):
        """Draw pie chart"""
        pie_radius = self.options.pie_radius

        # Iterate all values to draw the different slices
        color = 0
        x = float(self.width) / 2
        y = float(self.height) / 2

        # Create the shadow first (if it should be created):
        if self.blur:
            shadow = Circle(cx=str(x), cy=str(y))
            shadow.set("r", str(pie_radius))
            shadow.style = self.blur + inkex.Style(fill="#000000")
            yield shadow

        # Add a grey background circle with a light stroke
        background = Circle(cx=str(x), cy=str(y))
        background.set("r", str(pie_radius))
        background.set("style", "stroke:#ececec;fill:#f9f9f9")
        yield background

        # create value sum in order to divide the slices
        try:
            valuesum = sum(values)
        except ValueError:
            valuesum = 0

        if pie_abs:
            valuesum = 100

        # Set an offsetangle
        offset = 0

        # Draw single slices
        for cnt, value in enumerate(values):
            # Calculate the PI-angles for start and end
            angle = (2 * 3.141592) / valuesum * float(value)
            start = offset
            end = offset + angle

            # proper overlapping
            if self.options.segment_overlap:
                if cnt != len(values) - 1:
                    end += 0.09  # add a 5° overlap
                if cnt == 0:
                    start -= (
                        0.09  # let the first element overlap into the other direction
                    )

            # then add the slice
            pieslice = inkex.PathElement.arc(
                [x, y], pie_radius, pie_radius, start=start, end=end
            )
            pieslice.set(
                "style", "fill:" + self.get_color() + ";stroke:none;fill-opacity:1"
            )
            ang = angle / 2 + offset

            # If text is given, draw short paths and add the text
            if keys:
                elem = inkex.PathElement()
                elem.path = [
                    Move(
                        (self.width / 2) + pie_radius * math.cos(ang),
                        (self.height / 2) + pie_radius * math.sin(ang),
                    ),
                    line(
                        (self.options.text_offset - 2) * math.cos(ang),
                        (self.options.text_offset - 2) * math.sin(ang),
                    ),
                ]

                elem.style = {
                    "fill": "none",
                    "stroke": self.options.font_color,
                    "stroke-width": self.options.stroke_width,
                    "stroke-linecap": "butt",
                }
                yield elem

                label = keys[cnt]
                if self.options.show_values:
                    label += " ({}{})".format(str(value), ("", "%")[pie_abs])

                # check if it is right or left of the Pie
                anchor = "start" if math.cos(ang) > 0 else "end"
                text = self.draw_text(label, anchor=anchor)

                off = pie_radius + self.options.text_offset
                text.set("x", (self.width / 2) + off * math.cos(ang))
                text.set("y", (self.height / 2) + off * math.sin(ang) + self.fontoff)
                yield text

            # increase the rotation-offset and the colorcycle-position
            offset = offset + angle
            color = (color + 1) % 8

            # append the objects to the extension-layer
            yield pieslice

        yield self.draw_header(self.width / 2 - pie_radius)

    def render_stbar(self, keys, values):
        """Draw stacked bar chart"""

        # Iterate over all values to draw the different slices
        color = 0

        # create value sum in order to divide the bars
        try:
            valuesum = sum(values)
        except ValueError:
            valuesum = 0.0

        # Init offset
        offset = 0
        width = self.options.bar_width
        height = self.options.bar_height
        x = self.width / 2
        y = self.height / 2

        if self.blur:
            if self.options.rotate:
                width, height = height, width
                shy = y
            else:
                shy = str(y - self.options.bar_height)

            # Create rectangle element
            shadow = Rectangle(
                x=str(x),
                y=str(shy),
                width=str(width),
                height=str(height),
            )

            # Set shadow blur (connect to filter object in xml path)
            shadow.style = self.blur
            yield shadow

        # Draw Single bars
        for cnt, value in enumerate(values):
            # Calculate the individual heights normalized on 100units
            normedvalue = (self.options.bar_height / valuesum) * float(value)

            # Create rectangle element
            rect = Rectangle()

            # Set chart position to center of document.
            if not self.options.rotate:
                rect.set("x", str(self.width / 2))
                rect.set("y", str(self.height / 2 - offset - normedvalue))
                rect.set("width", str(self.options.bar_width))
                rect.set("height", str(normedvalue))
            else:
                rect.set("x", str(self.width / 2 + offset))
                rect.set("y", str(self.height / 2))
                rect.set("height", str(self.options.bar_width))
                rect.set("width", str(normedvalue))

            rect.set("style", "fill:" + self.get_color())

            # If text is given, draw short paths and add the text
            # TODO: apply overlap workaround for visible gaps in between
            if keys:
                if not self.options.rotate:
                    x1 = (self.width + self.options.bar_width) / 2
                    y1 = y - offset - (normedvalue / 2)
                    x2 = self.options.bar_width / 2 + self.options.text_offset
                    y2 = 0
                    txt = (
                        self.width / 2
                        + self.options.bar_width
                        + self.options.text_offset
                        + 1
                    )
                    tyt = y - offset + self.fontoff - (normedvalue / 2)
                else:
                    x1 = x + offset + normedvalue / 2
                    y1 = y + self.options.bar_width / 2
                    x2 = 0
                    y2 = (
                        self.options.bar_width / 2
                        + (self.options.font_size * cnt)
                        + self.options.text_offset
                    )
                    txt = x + offset + normedvalue / 2 - self.fontoff
                    tyt = (
                        (y)
                        + self.options.bar_width
                        + (self.options.font_size * (cnt + 1))
                        + self.options.text_offset
                    )

                elem = inkex.PathElement()
                elem.path = [Move(x1, y1), line(x2, y2)]
                elem.style = {
                    "fill": "none",
                    "stroke": self.options.font_color,
                    "stroke-width": self.options.stroke_width,
                    "stroke-linecap": "butt",
                }
                yield elem
                yield self.draw_text(keys[cnt], x=str(txt), y=str(tyt))

            # Increase Offset and Color
            offset = offset + normedvalue
            color = (color + 1) % 8

            # Draw rectangle
            yield rect

        yield self.draw_header(self.width / 2 + offset + normedvalue)


if __name__ == "__main__":
    NiceChart().run()
