"""Render SVG images."""

import re
from contextlib import suppress
from math import cos, hypot, pi, radians, sin, sqrt
from xml.etree import ElementTree

from cssselect2 import ElementWrapper

from ..urls import get_url_attribute
from .bounding_box import (
    bounding_box, extend_bounding_box, is_valid_bounding_box)
from .css import parse_declarations, parse_stylesheets
from .defs import (
    apply_filters, clip_path, draw_gradient_or_pattern, paint_mask, use)
from .images import image, svg
from .path import path
from .shapes import circle, ellipse, line, polygon, polyline, rect
from .text import text
from .utils import (
    PointError, color, normalize, parse_url, preserve_ratio, size, transform)

TAGS = {
    'a': text,
    'circle': circle,
    'clipPath': clip_path,
    'ellipse': ellipse,
    'image': image,
    'line': line,
    'path': path,
    'polyline': polyline,
    'polygon': polygon,
    'rect': rect,
    'svg': svg,
    'text': text,
    'textPath': text,
    'tspan': text,
    'use': use,
}

NOT_INHERITED_ATTRIBUTES = frozenset((
    'clip',
    'clip-path',
    'filter',
    'height',
    'id',
    'mask',
    'opacity',
    'overflow',
    'rotate',
    'stop-color',
    'stop-opacity',
    'style',
    'transform',
    'transform-origin',
    'viewBox',
    'width',
    'x',
    'y',
    'dx',
    'dy',
    '{http://www.w3.org/1999/xlink}href',
    'href',
))

COLOR_ATTRIBUTES = frozenset((
    'fill',
    'flood-color',
    'lighting-color',
    'stop-color',
    'stroke',
))

DEF_TYPES = frozenset((
    'clipPath',
    'filter',
    'gradient',
    'image',
    'marker',
    'mask',
    'path',
    'pattern',
))


class Node:
    """An SVG document node."""

    def __init__(self, wrapper, style):
        self._wrapper = wrapper
        self._etree_node = wrapper.etree_element
        self._style = style

        self.attrib = wrapper.etree_element.attrib.copy()

        self.vertices = []
        self.bounding_box = None

    def copy(self):
        """Create a deep copy of the node as it was when first created."""
        return Node(self._wrapper, self._style)

    def get(self, key, default=None):
        """Get attribute."""
        return self.attrib.get(key, default)

    @property
    def tag(self):
        """XML tag name with no namespace."""
        return self._etree_node.tag.split('}', 1)[-1]

    @property
    def text(self):
        """XML node text."""
        return self._etree_node.text

    @property
    def tail(self):
        """Text after the XML node."""
        return self._etree_node.tail

    def __iter__(self):
        """Yield node children, handling cascade."""
        for wrapper in self._wrapper:
            child = Node(wrapper, self._style)

            # Cascade
            for key, value in self.attrib.items():
                if key not in NOT_INHERITED_ATTRIBUTES:
                    if key not in child.attrib:
                        child.attrib[key] = value

            # Apply style attribute
            style_attr = child.get('style')
            if style_attr:
                normal_attr, important_attr = parse_declarations(style_attr)
            else:
                normal_attr, important_attr = [], []
            normal_matcher, important_matcher = self._style
            normal = [rule[-1] for rule in normal_matcher.match(wrapper)]
            important = [rule[-1] for rule in important_matcher.match(wrapper)]
            declarations_lists = (
                normal, [normal_attr], important, [important_attr])
            for declarations_list in declarations_lists:
                for declarations in declarations_list:
                    for name, value in declarations:
                        child.attrib[name] = value.strip()

            # Replace 'currentColor' value
            for key in COLOR_ATTRIBUTES:
                if child.get(key) == 'currentColor':
                    child.attrib[key] = child.get('color', 'black')

            # Handle 'inherit' values
            for key, value in child.attrib.copy().items():
                if value == 'inherit':
                    value = self.get(key)
                    if value is None:
                        del child.attrib[key]
                    else:
                        child.attrib[key] = value

            # Fix text in text tags
            if child.tag in ('text', 'textPath', 'a'):
                children, _ = child.text_children(
                    wrapper, trailing_space=True, text_root=True)
                child._wrapper.etree_children = [
                    child._etree_node for child in children]

            yield child

    def get_viewbox(self):
        """Get node viewBox as a tuple of floats."""
        viewbox = self.get('viewBox')
        if viewbox:
            return tuple(
                float(number) for number in normalize(viewbox).split())

    def get_href(self, base_url):
        """Get the href attribute, with or without a namespace."""
        for attr_name in ('{http://www.w3.org/1999/xlink}href', 'href'):
            url = get_url_attribute(
                self, attr_name, base_url, allow_relative=True)
            if url:
                return url

    def del_href(self):
        """Remove the href attributes, with or without a namespace."""
        for attr_name in ('{http://www.w3.org/1999/xlink}href', 'href'):
            self.attrib.pop(attr_name, None)

    @staticmethod
    def process_whitespace(string, preserve):
        """Replace newlines by spaces, and merge spaces if not preserved."""
        # TODO: should be merged with build.process_whitespace
        if not string:
            return ''
        if preserve:
            return re.sub('[\n\r\t]', ' ', string)
        else:
            string = re.sub('[\n\r]', '', string)
            string = re.sub('\t', ' ', string)
            return re.sub(' +', ' ', string)

    def get_child(self, id_):
        """Get a child with given id in the whole child tree."""
        for child in self:
            if child.get('id') == id_:
                return child
            grandchild = child.get_child(id_)
            if grandchild:
                return grandchild

    def text_children(self, element, trailing_space, text_root=False):
        """Handle text node by fixing whitespaces and flattening tails."""
        children = []
        space = '{http://www.w3.org/XML/1998/namespace}space'
        preserve = self.get(space) == 'preserve'
        self._etree_node.text = self.process_whitespace(
            element.etree_element.text, preserve)
        if trailing_space and not preserve:
            self._etree_node.text = self.text.lstrip(' ')

        original_rotate = [
            float(i) for i in
            normalize(self.get('rotate')).strip().split(' ') if i]
        rotate = original_rotate.copy()
        if original_rotate:
            self.pop_rotation(original_rotate, rotate)
        if self.text:
            trailing_space = self.text.endswith(' ')
        for child_element in element.iter_children():
            child = child_element.etree_element
            if child.tag in ('{http://www.w3.org/2000/svg}tref', 'tref'):
                child_node = Node(child_element, self._style)
                child_node._etree_node.tag = 'tspan'
                # Retrieve the referenced node and get its flattened text
                # and remove the node children.
                child = child_node._etree_node
                child._etree_node.text = child.flatten()
                child_element = ElementWrapper.from_xml_root(child)
            else:
                child_node = Node(child_element, self._style)
            child_preserve = child_node.get(space) == 'preserve'
            child_node._etree_node.text = self.process_whitespace(
                child.text, child_preserve)
            child_node.children, trailing_space = child_node.text_children(
                child_element, trailing_space)
            trailing_space = child_node.text.endswith(' ')
            if original_rotate and 'rotate' not in child_node:
                child_node.pop_rotation(original_rotate, rotate)
            children.append(child_node)
            if child.tail:
                anonymous_etree = ElementTree.Element(
                    '{http://www.w3.org/2000/svg}tspan')
                anonymous = Node(
                    ElementWrapper.from_xml_root(anonymous_etree), self._style)
                anonymous._etree_node.text = self.process_whitespace(
                    child.tail, preserve)
                if original_rotate:
                    anonymous.pop_rotation(original_rotate, rotate)
                if trailing_space and not preserve:
                    anonymous._etree_node.text = anonymous.text.lstrip(' ')
                if anonymous.text:
                    trailing_space = anonymous.text.endswith(' ')
                children.append(anonymous)

        if text_root and not children and not preserve:
            self._etree_node.text = self.text.rstrip(' ')

        return children, trailing_space

    def flatten(self):
        """Flatten text in node and in its children."""
        flattened_text = [self.text or '']
        for child in list(self):
            flattened_text.append(child.flatten())
            flattened_text.append(child.tail or '')
            self.remove(child)
        return ''.join(flattened_text)

    def pop_rotation(self, original_rotate, rotate):
        """Merge nested letter rotations."""
        self.attrib['rotate'] = ' '.join(
            str(rotate.pop(0) if rotate else original_rotate[-1])
            for i in range(len(self.text)))

    def override_iter(self, iterator):
        """Override node’s children iterator."""
        # As special methods are bound to classes and not instances, we have to
        # create and assign a new type.
        self.__class__ = type(
            'Node', (Node,), {'__iter__': lambda _: iterator})


class SVG:
    """An SVG document."""

    def __init__(self, tree, url):
        wrapper = ElementWrapper.from_xml_root(tree)
        style = parse_stylesheets(wrapper, url)
        self.tree = Node(wrapper, style)
        self.url = url

        # Replace 'currentColor' value
        for key in COLOR_ATTRIBUTES:
            if self.tree.get(key) == 'currentColor':
                self.tree.attrib[key] = self.tree.get('color', 'black')

        self.filters = {}
        self.gradients = {}
        self.images = {}
        self.markers = {}
        self.masks = {}
        self.patterns = {}
        self.paths = {}

        self.use_cache = {}

        self.cursor_position = [0, 0]
        self.cursor_d_position = [0, 0]
        self.text_path_width = 0

        self.parse_defs(self.tree)
        self.inherit_defs()

    def get_intrinsic_size(self, font_size):
        """Get intrinsic size of the image."""
        intrinsic_width = self.tree.get('width', '100%')
        if '%' in intrinsic_width:
            intrinsic_width = None
        else:
            intrinsic_width = size(intrinsic_width, font_size)

        intrinsic_height = self.tree.get('height', '100%')
        if '%' in intrinsic_height:
            intrinsic_height = None
        else:
            intrinsic_height = size(intrinsic_height, font_size)

        return intrinsic_width, intrinsic_height

    def get_viewbox(self):
        """Get document viewBox as a tuple of floats."""
        return self.tree.get_viewbox()

    def point(self, x, y, font_size):
        """Compute size of an x/y or width/height couple."""
        return (
            size(x, font_size, self.inner_width),
            size(y, font_size, self.inner_height))

    def length(self, length, font_size):
        """Compute size of an arbirtary attribute."""
        return size(length, font_size, self.inner_diagonal)

    def draw(self, stream, concrete_width, concrete_height, base_url,
             url_fetcher, context):
        """Draw image on a stream."""
        self.stream = stream

        self.concrete_width = concrete_width
        self.concrete_height = concrete_height
        self.normalized_diagonal = (
            hypot(concrete_width, concrete_height) / sqrt(2))

        viewbox = self.get_viewbox()
        if viewbox:
            self.inner_width, self.inner_height = viewbox[2], viewbox[3]
        else:
            self.inner_width = self.concrete_width
            self.inner_height = self.concrete_height
        self.inner_diagonal = (
            hypot(self.inner_width, self.inner_height) / sqrt(2))

        self.base_url = base_url
        self.url_fetcher = url_fetcher
        self.context = context

        self.draw_node(self.tree, size('12pt'))

    def draw_node(self, node, font_size, fill_stroke=True):
        """Draw a node."""
        if node.tag == 'defs':
            return

        # Update font size
        font_size = size(node.get('font-size', '1em'), font_size, font_size)

        if fill_stroke:
            self.stream.push_state()

        # Apply filters
        filter_ = self.filters.get(parse_url(node.get('filter')).fragment)
        if filter_:
            apply_filters(self, node, filter_, font_size)

        # Apply transform attribute
        self.transform(node.get('transform'), font_size)

        # Create substream for opacity
        opacity = float(node.get('opacity', 1))
        if fill_stroke and 0 <= opacity < 1:
            original_stream = self.stream
            box = self.calculate_bounding_box(node, font_size)
            if not is_valid_bounding_box(box):
                box = (0, 0, self.inner_width, self.inner_height)
            self.stream = self.stream.add_group(*box)

        # Clip
        clip_path = parse_url(node.get('clip-path')).fragment
        if clip_path and clip_path in self.paths:
            old_ctm = self.stream.ctm
            clip_path = self.paths[clip_path]
            if clip_path.get('clipPathUnits') == 'objectBoundingBox':
                x, y = self.point(node.get('x'), node.get('y'), font_size)
                width, height = self.point(
                    node.get('width'), node.get('height'), font_size)
                self.stream.transform(a=width, d=height, e=x, f=y)
            original_tag = clip_path._etree_node.tag
            clip_path._etree_node.tag = 'g'
            self.draw_node(clip_path, font_size, fill_stroke=False)
            clip_path._etree_node.tag = original_tag
            # At least set the clipping area to an empty path, so that it’s
            # totally clipped when the clipping path is empty.
            self.stream.rectangle(0, 0, 0, 0)
            self.stream.clip()
            self.stream.end()
            new_ctm = self.stream.ctm
            if new_ctm.determinant:
                self.stream.transform(*(old_ctm @ new_ctm.invert).values)

        # Manage display and visibility
        display = node.get('display') != 'none'
        visible = display and (node.get('visibility') != 'hidden')

        # Handle text anchor
        text_anchor = node.get('text-anchor')
        if node.tag == 'text' and text_anchor in ('middle', 'end'):
            group = self.stream.add_group(0, 0, 0, 0)  # BBox set after drawing
            original_stream, self.stream = self.stream, group

        # Draw node
        if visible and node.tag in TAGS:
            with suppress(PointError):
                TAGS[node.tag](self, node, font_size)

        # Draw node children
        if display and node.tag not in DEF_TYPES:
            for child in node:
                self.draw_node(child, font_size, fill_stroke)
                if node.tag in ('text', 'tspan'):
                    if not is_valid_bounding_box(child.text_bounding_box):
                        continue
                    x1, y1 = child.text_bounding_box[:2]
                    x2 = x1 + child.text_bounding_box[2]
                    y2 = y1 + child.text_bounding_box[3]
                    node.text_bounding_box = extend_bounding_box(
                        node.text_bounding_box, ((x1, y1), (x2, y2)))

        # Handle text anchor
        if node.tag == 'text' and text_anchor in ('middle', 'end'):
            group_id = self.stream.id
            self.stream = original_stream
            self.stream.push_state()
            if is_valid_bounding_box(node.text_bounding_box):
                x, y, width, height = node.text_bounding_box
                group.extra['BBox'][:] = (x, y, x + width, y + height)
                x_align = width / 2 if text_anchor == 'middle' else width
                self.stream.transform(e=-x_align)
            self.stream.draw_x_object(group_id)
            self.stream.pop_state()

        # Apply mask
        mask = self.masks.get(parse_url(node.get('mask')).fragment)
        if mask:
            paint_mask(self, node, mask, opacity)

        # Fill and stroke
        if fill_stroke:
            self.fill_stroke(node, font_size)

        # Draw markers
        self.draw_markers(node, font_size, fill_stroke)

        # Apply opacity stream and restore original stream
        if fill_stroke and 0 <= opacity < 1:
            group_id = self.stream.id
            self.stream = original_stream
            self.stream.set_alpha(opacity, stroke=True, fill=True)
            self.stream.draw_x_object(group_id)

        # Clean text tag
        if node.tag == 'text':
            self.cursor_position = [0, 0]
            self.cursor_d_position = [0, 0]
            self.text_path_width = 0

        if fill_stroke:
            self.stream.pop_state()

    def draw_markers(self, node, font_size, fill_stroke):
        """Draw markers defined in a node."""
        if not node.vertices:
            return

        markers = {}
        common_marker = parse_url(node.get('marker')).fragment
        for position in ('start', 'mid', 'end'):
            attribute = f'marker-{position}'
            if attribute in node.attrib:
                markers[position] = parse_url(node.attrib[attribute]).fragment
            else:
                markers[position] = common_marker

        angle1, angle2 = None, None
        position = 'start'

        while node.vertices:
            # Calculate position and angle
            point = node.vertices.pop(0)
            angles = node.vertices.pop(0) if node.vertices else None
            if angles:
                if position == 'start':
                    angle = pi - angles[0]
                else:
                    angle = (angle2 + pi - angles[0]) / 2
                angle1, angle2 = angles
            else:
                angle = angle2
                position = 'end'

            # Draw marker
            marker = markers[position]
            if not marker:
                position = 'mid' if angles else 'start'
                continue

            marker_node = self.markers.get(marker)

            # Calculate position, scale and clipping
            translate_x, translate_y = self.point(
                marker_node.get('refX'), marker_node.get('refY'),
                font_size)
            if 'viewBox' in marker_node.attrib:
                marker_width, marker_height = self.point(
                    marker_node.get('markerWidth', 3),
                    marker_node.get('markerHeight', 3),
                    font_size)
                scale_x, scale_y, _, _ = preserve_ratio(
                    self, marker_node, font_size, marker_width, marker_height)

                clip_x, clip_y, viewbox_width, viewbox_height = (
                    marker_node.get_viewbox())

                align = marker_node.get(
                    'preserveAspectRatio', 'xMidYMid').split(' ')[0]
                if align == 'none':
                    x_position = y_position = 'min'
                else:
                    x_position = align[1:4].lower()
                    y_position = align[5:].lower()

                if x_position == 'mid':
                    clip_x += (viewbox_width - marker_width / scale_x) / 2
                elif x_position == 'max':
                    clip_x += viewbox_width - marker_width / scale_x

                if y_position == 'mid':
                    clip_y += (
                        viewbox_height - marker_height / scale_y) / 2
                elif y_position == 'max':
                    clip_y += viewbox_height - marker_height / scale_y

                clip_box = (
                    clip_x, clip_y,
                    marker_width / scale_x, marker_height / scale_y)
            else:
                marker_width, marker_height = self.point(
                    marker_node.get('markerWidth', 3),
                    marker_node.get('markerHeight', 3),
                    font_size)
                box = self.calculate_bounding_box(marker_node, font_size)
                if is_valid_bounding_box(box):
                    scale_x = scale_y = min(
                        marker_width / box[2], marker_height / box[3])
                    translate_x /= scale_x
                    translate_y /= scale_y
                else:
                    scale_x = scale_y = 1
                clip_box = None

            # Scale
            if marker_node.get('markerUnits') != 'userSpaceOnUse':
                scale = self.length(node.get('stroke-width', 1), font_size)
                scale_x *= scale
                scale_y *= scale

            # Override angle
            node_angle = marker_node.get('orient', 0)
            if node_angle not in ('auto', 'auto-start-reverse'):
                angle = radians(float(node_angle))
            elif node_angle == 'auto-start-reverse' and position == 'start':
                angle += radians(180)

            # Draw marker path
            for child in marker_node:
                self.stream.push_state()

                self.stream.transform(
                    scale_x * cos(angle), scale_x * sin(angle),
                    -scale_y * sin(angle), scale_y * cos(angle),
                    *point)
                self.stream.transform(e=-translate_x, f=-translate_y)

                overflow = marker_node.get('overflow', 'hidden')
                if clip_box and overflow in ('hidden', 'scroll'):
                    self.stream.rectangle(*clip_box)
                    self.stream.clip()
                    self.stream.end()

                self.draw_node(child, font_size, fill_stroke)
                self.stream.pop_state()

            position = 'mid' if angles else 'start'

    @staticmethod
    def get_paint(value):
        """Get paint fill or stroke attribute with a color or a URL."""
        if not value or value == 'none':
            return None, None

        value = value.strip()
        match = re.compile(r'(url\(.+\)) *(.*)').search(value)
        if match:
            source = parse_url(match.group(1)).fragment
            color = match.group(2) or None
        else:
            source = None
            color = value or None

        return source, color

    def fill_stroke(self, node, font_size, text=False):
        """Paint fill and stroke for a node."""
        if node.tag in ('text', 'textPath', 'a') and not text:
            return

        # Get fill data
        fill_source, fill_color = self.get_paint(node.get('fill', 'black'))
        fill_opacity = float(node.get('fill-opacity', 1))
        fill_drawn = draw_gradient_or_pattern(
            self, node, fill_source, font_size, fill_opacity, stroke=False)
        if fill_color and not fill_drawn:
            red, green, blue, alpha = color(fill_color)
            self.stream.set_color_rgb(red, green, blue)
            self.stream.set_alpha(alpha * fill_opacity)
        fill = fill_color or fill_drawn

        # Get stroke data
        stroke_source, stroke_color = self.get_paint(node.get('stroke'))
        stroke_opacity = float(node.get('stroke-opacity', 1))
        stroke_drawn = draw_gradient_or_pattern(
            self, node, stroke_source, font_size, stroke_opacity, stroke=True)
        if stroke_color and not stroke_drawn:
            red, green, blue, alpha = color(stroke_color)
            self.stream.set_color_rgb(red, green, blue, stroke=True)
            self.stream.set_alpha(alpha * stroke_opacity, stroke=True)
        stroke = stroke_color or stroke_drawn
        stroke_width = self.length(node.get('stroke-width', '1px'), font_size)
        if stroke_width:
            self.stream.set_line_width(stroke_width)
        else:
            stroke = None

        # Apply dash array
        dash_array = tuple(
            self.length(value, font_size) for value in
            normalize(node.get('stroke-dasharray')).split() if value != 'none')
        dash_condition = (
            dash_array and
            not all(value == 0 for value in dash_array) and
            not any(value < 0 for value in dash_array))
        if dash_condition:
            offset = self.length(node.get('stroke-dashoffset'), font_size)
            if offset < 0:
                sum_dashes = sum(float(value) for value in dash_array)
                offset = sum_dashes - abs(offset) % sum_dashes
            self.stream.set_dash(dash_array, offset)

        # Apply line cap
        line_cap = node.get('stroke-linecap', 'butt')
        if line_cap == 'round':
            line_cap = 1
        elif line_cap == 'square':
            line_cap = 2
        else:
            line_cap = 0
        self.stream.set_line_cap(line_cap)

        # Apply line join
        line_join = node.get('stroke-linejoin', 'miter')
        if line_join == 'round':
            line_join = 1
        elif line_join == 'bevel':
            line_join = 2
        else:
            line_join = 0
        self.stream.set_line_join(line_join)

        # Apply miter limit
        miter_limit = float(node.get('stroke-miterlimit', 4))
        if miter_limit < 0:
            miter_limit = 4
        self.stream.set_miter_limit(miter_limit)

        # Fill and stroke
        even_odd = node.get('fill-rule') == 'evenodd'
        if text:
            if stroke and fill:
                text_rendering = 2
            elif stroke:
                text_rendering = 1
            elif fill:
                text_rendering = 0
            else:
                text_rendering = 3
            self.stream.set_text_rendering(text_rendering)
        else:
            if fill and stroke:
                self.stream.fill_and_stroke(even_odd)
            elif stroke:
                self.stream.stroke()
            elif fill:
                self.stream.fill(even_odd)
            else:
                self.stream.end()

    def transform(self, transform_string, font_size):
        """Apply a transformation string to the node."""
        if not transform_string:
            return

        matrix = transform(transform_string, font_size, self.inner_diagonal)
        if matrix.determinant:
            self.stream.transform(*matrix.values)

    def parse_defs(self, node):
        """Parse defs included in a tree."""
        for def_type in DEF_TYPES:
            if def_type in node.tag.lower() and 'id' in node.attrib:
                getattr(self, f'{def_type}s')[node.attrib['id']] = node
        for child in node:
            self.parse_defs(child)

    def inherit_defs(self):
        """Handle inheritance of different defined elements lists."""
        for defs in (self.gradients, self.patterns):
            for element in defs.values():
                self.inherit_element(element, defs)

    def inherit_element(self, element, defs):
        """Recursively handle inheritance of defined element."""
        href = element.get_href(self.url)
        if not href:
            return
        element.del_href()
        parent = defs.get(parse_url(href).fragment)
        if not parent:
            return
        self.inherit_element(parent, defs)
        for key, value in parent.attrib.items():
            if key not in element.attrib:
                element.attrib[key] = value
        if next(iter(element), None) is None:
            element.override_iter(parent.__iter__())

    def calculate_bounding_box(self, node, font_size, stroke=True):
        """Calculate the bounding box of a node."""
        if stroke or node.bounding_box is None:
            box = bounding_box(self, node, font_size, stroke)
            if is_valid_bounding_box(box) and 0 not in box[2:]:
                if stroke:
                    return box
                node.bounding_box = box
        return node.bounding_box


class Pattern(SVG):
    """SVG node applied as a pattern."""
    def __init__(self, tree, svg):
        super().__init__(tree._etree_node, svg.url)
        self.svg = svg
        self.tree = tree

    def draw_node(self, node, font_size, fill_stroke=True):
        # Store the original tree in self.tree when calling draw(), so that we
        # can reach defs outside the pattern
        if node == self.tree:
            self.tree = self.svg.tree
        super().draw_node(node, font_size, fill_stroke=True)
