"""
Prettymaps - A minimal Python library to draw pretty maps from OpenStreetMap Data
Copyright (C) 2021 Marcelo Prates

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero 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 Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""

import re
import os
import json
import yaml
#import IPython
import pathlib
import warnings
import matplotlib
import shapely.ops
import numpy as np
import osmnx as ox
import pandas as pd
import geopandas as gp
import shapely.affinity
from copy import deepcopy
from .fetch import get_gdfs
from dataclasses import dataclass
from matplotlib import pyplot as plt
from matplotlib.colors import hex2color
from matplotlib.patches import Path, PathPatch
from typing import Optional, Union, Tuple, List, Dict, Any, Iterable
from shapely.geometry.base import BaseGeometry
from shapely.geometry import Point, LineString, MultiLineString, Polygon, MultiPolygon, GeometryCollection, box

#import vsketch


class Subplot:
    """
    Class implementing a prettymaps Subplot. Attributes:
    - query: prettymaps.plot() query
    - kwargs: dictionary of prettymaps.plot() parameters
    """

    def __init__(self, query, **kwargs):
        self.query = query
        self.kwargs = kwargs


@dataclass
class Plot:
    """
    Dataclass implementing a prettymaps Plot object. Attributes:
    - geodataframes: A dictionary of GeoDataFrames (one for each plot layer)
    - fig: A matplotlib figure
    - ax: A matplotlib axis object
    - background: Background layer (shapely object)
    """
    geodataframes: Dict[str, gp.GeoDataFrame]
    fig: matplotlib.figure.Figure
    ax: matplotlib.axes.Axes
    background: BaseGeometry


@dataclass
class Preset:
    """
    Dataclass implementing a prettymaps Preset object. Attributes:
    - params: dictionary of prettymaps.plot() parameters
    """
    params: dict

    '''
    def _ipython_display_(self):
        """
        Implements the _ipython_display_() function for the Preset class.
        'params' will be displayed as a Markdown table with annotated hex colors
        """

        def light_color(hexstring):
            rgb = np.array(hex2color(hexstring))
            return rgb.mean() > .5

        def annotate_colors(text):
            matches = re.findall(
                '#(?:\\d|[a-f]|[A-F]){6}|#(?:\\d|[a-f]|[A-F]){4}|#(?:\\d|[a-f]|[A-F]){3}', text)
            for match in matches:
                text = text.replace(
                    match,
                    f'<span style="background-color:{match}; color:{"#000" if light_color(match) else "#fff"}">{match}</span>'
                )
            return text

        params = pd.DataFrame(self.params)
        params = params.applymap(lambda x: annotate_colors(
            yaml.dump(x, default_flow_style=False).replace('\n', '<br>')))
        params.iloc[1:, 2:] = ''

        IPython.display.display(IPython.display.Markdown(params.to_markdown()))
    '''

def transform_gdfs(
    gdfs: Dict[str, gp.GeoDataFrame],
    x: float = 0,
    y: float = 0,
    scale_x: float = 1,
    scale_y: float = 1,
    rotation: float = 0
) -> Dict[str, gp.GeoDataFrame]:
    """
    Apply geometric transformations to dictionary of GeoDataFrames

    Args:
        gdfs (Dict[str, gp.GeoDataFrame]): Dictionary of GeoDataFrames
        x (float, optional): x-axis translation. Defaults to 0.
        y (float, optional): y-axis translation. Defaults to 0.
        scale_x (float, optional): x-axis scale. Defaults to 1.
        scale_y (float, optional): y-axis scale. Defaults to 1.
        rotation (float, optional): rotation angle (in radians). Defaults to 0.

    Returns:
        Dict[str, gp.GeoDataFrame]: dictionary of transformed GeoDataFrames
    """
    # Project geometries
    gdfs = {
        name: ox.project_gdf(gdf) if len(gdf) > 0 else gdf for name, gdf in gdfs.items()
    }
    # Create geometry collection from gdfs' geometries
    collection = GeometryCollection(
        [GeometryCollection(list(gdf.geometry)) for gdf in gdfs.values()]
    )
    # Translation, scale & rotation
    collection = shapely.affinity.translate(collection, x, y)
    collection = shapely.affinity.scale(
        collection, scale_x, scale_y)
    collection = shapely.affinity.rotate(collection, rotation)
    # Update geometries
    for i, layer in enumerate(gdfs):
        gdfs[layer].geometry = list(collection.geoms[i].geoms)
        # Reproject
        if len(gdfs[layer]) > 0:
            gdfs[layer] = ox.project_gdf(gdfs[layer], to_crs="EPSG:4326")

    return gdfs


def PolygonPatch(
    shape: BaseGeometry,
    **kwargs
) -> PathPatch:
    """_summary_

    Args:
        shape (BaseGeometry): Shapely geometry
        kwargs: parameters for matplotlib's PathPatch constructor

    Returns:
        PathPatch: matplotlib PatchPatch created from input shapely geometry
    """
    # Init vertices and codes lists
    vertices, codes = [], []
    for poly in shape.geoms if isinstance(shape, Iterable) else [shape]:
        # Get polygon's exterior and interiors
        exterior = np.array(poly.exterior.xy)
        interiors = [np.array(interior.xy) for interior in poly.interiors]
        # Append to vertices and codes lists
        vertices += [exterior] + interiors
        codes += list(
            map(
                # Ring coding
                lambda p: [Path.MOVETO]
                + [Path.LINETO] * (p.shape[1] - 2)
                + [Path.CLOSEPOLY],
                [exterior] + interiors,
            )
        )
    # Generate PathPatch
    return PathPatch(
        Path(np.concatenate(vertices, 1).T, np.concatenate(codes)), **kwargs
    )


def plot_gdf(
    layer: str,
    gdf: gp.GeoDataFrame,
    ax: matplotlib.axes.Axes,
    mode: str = 'matplotlib',
    #vsk: Optional[vsketch.SketchClass] = None,
    vsk=None,
    palette: Optional[List[str]] = None,
    width: Optional[Union[dict, float]] = None,
    union: bool = False,
    dilate_points: Optional[float] = None,
    dilate_lines: Optional[float] = None,
    **kwargs,
) -> None:
    """
    Plot a layer

    Args:
        layer (str): layer name
        gdf (gp.GeoDataFrame): GeoDataFrame
        ax (matplotlib.axes.Axes): matplotlib axis object
        mode (str): drawing mode. Options: 'matplotlib', 'vsketch'. Defaults to 'matplotlib'
        vsk (Optional[vsketch.SketchClass]): Vsketch object. Mandatory if mode == 'plotter'
        palette (Optional[List[str]], optional): Color palette. Defaults to None.
        width (Optional[Union[dict, float]], optional): Street widths. Either a dictionary or a float. Defaults to None.
        union (bool, optional): Whether to join geometries. Defaults to False.
        dilate_points (Optional[float], optional): Amount of dilation to be applied to point (1D) geometries. Defaults to None.
        dilate_lines (Optional[float], optional): Amount of dilation to be applied to line (2D) geometries. Defaults to None.

    Raises:
        Exception: _description_
    """

    # Get hatch and hatch_c parameter
    hatch_c = kwargs.pop("hatch_c") if "hatch_c" in kwargs else None

    # Convert GDF to shapely geometries
    geometries = gdf_to_shapely(
        layer, gdf, width, point_size=dilate_points, line_width=dilate_lines
    )
    geometries = geometries.geoms if isinstance(
        geometries, Iterable) else [geometries]

    # Unite geometries
    if union:
        geometries = shapely.ops.unary_union(geometries)

    if (palette is None) and ("fc" in kwargs) and (type(kwargs["fc"]) != str):
        palette = kwargs.pop("fc")

    # Plot shapes
    for shape in geometries:
        if mode == "matplotlib":
            if type(shape) in [Polygon, MultiPolygon]:
                # Plot main shape (without silhouette)
                ax.add_patch(
                    PolygonPatch(
                        shape,
                        lw=0,
                        ec=hatch_c
                        if hatch_c
                        else kwargs["ec"]
                        if "ec" in kwargs
                        else None,
                        fc=kwargs["fc"]
                        if "fc" in kwargs
                        else np.random.choice(palette)
                        if palette
                        else None,
                        **{
                            k: v
                            for k, v in kwargs.items()
                            if k not in ["lw", "ec", "fc"]
                        },
                    ),
                )
                # Plot just silhouette
                ax.add_patch(
                    PolygonPatch(
                        shape,
                        fill=False,
                        **{
                            k: v
                            for k, v in kwargs.items()
                            if k not in ["hatch", "fill"]
                        },
                    )
                )
            elif type(shape) == LineString:
                ax.plot(
                    *shape.xy,
                    c=kwargs["ec"] if "ec" in kwargs else None,
                    **{
                        k: v
                        for k, v in kwargs.items()
                        if k in ["lw", "lt", "dashes", "zorder"]
                    },
                )
            elif type(shape) == MultiLineString:
                for c in shape.geoms:
                    ax.plot(
                        *c.xy,
                        c=kwargs["ec"] if "ec" in kwargs else None,
                        **{
                            k: v
                            for k, v in kwargs.items()
                            if k in ["lw", "lt", "dashes", "zorder"]
                        },
                    )
        elif mode == "plotter":
            if ("draw" not in kwargs) or kwargs["draw"]:

                # Set stroke
                if "stroke" in kwargs:
                    vsk.stroke(kwargs["stroke"])
                else:
                    vsk.stroke(1)

                # Set pen width
                if "penWidth" in kwargs:
                    vsk.penWidth(kwargs["penWidth"])
                else:
                    vsk.penWidth(0.3)

                if "fill" in kwargs:
                    vsk.fill(kwargs["fill"])
                else:
                    vsk.noFill()

                vsk.geometry(shape)
        else:
            raise Exception(f"Unknown mode {mode}")

##########


def plot_legends(gdf, ax):

    for _, row in gdf.iterrows():
        name = row.name
        x, y = np.concatenate(row.geometry.centroid.xy)
        ax.text(x, y, name)
##########


def graph_to_shapely(
    gdf: gp.GeoDataFrame,
    width: float = 1.
) -> BaseGeometry:
    """
    Given a GeoDataFrame containing a graph (street newtork),
    convert them to shapely geometries by applying dilation given by 'width'

    Args:
        gdf (gp.GeoDataFrame): input GeoDataFrame containing graph (street network) geometries
        width (float, optional): Line geometries will be dilated by this amount. Defaults to 1..

    Returns:
        BaseGeometry: Shapely
    """

    def highway_to_width(highway):
        if (type(highway) == str) and (highway in width):
            return width[highway]
        elif isinstance(highway, Iterable):
            for h in highway:
                if h in width:
                    return width[h]
            return np.nan
        else:
            return np.nan

    # Annotate GeoDataFrame with the width for each highway type
    gdf["width"] = gdf.highway.map(
        highway_to_width) if type(width) == dict else width

    # Remove rows with inexistent width
    gdf.drop(gdf[gdf.width.isna()].index, inplace=True)

    with warnings.catch_warnings():
        # Supress shapely.errors.ShapelyDeprecationWarning
        warnings.simplefilter(
            "ignore", shapely.errors.ShapelyDeprecationWarning)
        if not all(gdf.width.isna()):
            # Dilate geometries based on their width
            gdf.geometry.update(
                gdf.apply(lambda row: row.geometry.buffer(row.width), axis=1)
            )

    return shapely.ops.unary_union(gdf.geometry)


def geometries_to_shapely(
    gdf: gp.GeoDataFrame,
    point_size: Optional[float] = None,
    line_width: Optional[float] = None
) -> GeometryCollection:
    """
    Convert geometries in GeoDataFrame to shapely format

    Args:
        gdf (gp.GeoDataFrame): Input GeoDataFrame
        point_size (Optional[float], optional): Point geometries (1D) will be dilated by this amount. Defaults to None.
        line_width (Optional[float], optional): Line geometries (2D) will be dilated by this amount. Defaults to None.

    Returns:
        GeometryCollection: Shapely geometries computed from GeoDataFrame geometries
    """

    geoms = gdf.geometry.tolist()
    collections = [x for x in geoms if type(x) == GeometryCollection]
    points = [x for x in geoms if type(x) == Point] + [
        y for x in collections for y in x.geoms if type(y) == Point
    ]
    lines = [x for x in geoms if type(x) in [LineString, MultiLineString]] + [
        y
        for x in collections
        for y in x.geoms
        if type(y) in [LineString, MultiLineString]
    ]
    polys = [x for x in geoms if type(x) in [Polygon, MultiPolygon]] + [
        y for x in collections for y in x.geoms if type(y) in [Polygon, MultiPolygon]
    ]

    # Convert points into circles with radius "point_size"
    if point_size:
        points = [x.buffer(point_size)
                  for x in points] if point_size > 0 else []
    if line_width:
        lines = [x.buffer(line_width) for x in lines] if line_width > 0 else []

    return GeometryCollection(list(points) + list(lines) + list(polys))


def gdf_to_shapely(
        layer: str,
        gdf: gp.GeoDataFrame,
        width: Optional[Union[dict, float]] = None,
        point_size: Optional[float] = None,
        line_width: Optional[float] = None,
        **kwargs
) -> GeometryCollection:
    """
    Convert a dict of GeoDataFrames to a dict of shapely geometries

    Args:
        layer (str): Layer name
        gdf (gp.GeoDataFrame): Input GeoDataFrame
        width (Optional[Union[dict, float]], optional): Street network width. Can be either a dictionary or a float. Defaults to None.
        point_size (Optional[float], optional): Point geometries (1D) will be dilated by this amount. Defaults to None.
        line_width (Optional[float], optional): Line geometries (2D) will be dilated by this amount. Defaults to None.

    Returns:
        GeometryCollection: Output GeoDataFrame
    """

    # Project gdf
    try:
        gdf = ox.project_gdf(gdf)
    except:
        pass

    if layer in ["streets", "railway", "waterway"]:
        geometries = graph_to_shapely(gdf, width)
    else:
        geometries = geometries_to_shapely(
            gdf, point_size=point_size, line_width=line_width
        )

    return geometries


def override_args(
        layers: dict,
        circle: Optional[bool],
        dilate: Optional[Union[float, bool]]
) -> dict:
    """
    Override arguments in layers' kwargs

    Args:
        layers (dict): prettymaps.plot() Layers parameters dict
        circle (Optional[bool]): prettymaps.plot() 'Circle' parameter
        dilate (Optional[Union[float, bool]]): prettymaps.plot() 'dilate' parameter

    Returns:
        dict: output dict
    """
    override_args = ["circle", "dilate"]
    for layer in layers:
        for arg in override_args:
            if arg not in layers[layer]:
                layers[layer][arg] = locals()[arg]
    return layers


def override_params(
        default_dict: dict,
        new_dict: dict
) -> dict:
    """
    Override parameters in 'default_dict' with additional parameters from 'new_dict'

    Args:
        default_dict (dict): Default dict to be overriden with 'new_dict' parameters
        new_dict (dict): New dict to override 'default_dict' parameters

    Returns:
        dict: default_dict overriden with new_dict parameters
    """

    final_dict = deepcopy(default_dict)

    for key in new_dict.keys():
        if type(new_dict[key]) == dict:
            if key in final_dict:
                final_dict[key] = override_params(
                    final_dict[key], new_dict[key])
            else:
                final_dict[key] = new_dict[key]
        else:
            final_dict[key] = new_dict[key]

    return final_dict


def create_background(
    gdfs: Dict[str, gp.GeoDataFrame],
    style: Dict[str, dict]
) -> Tuple[BaseGeometry, float, float, float, float, float, float]:
    """
    Create a background layer given a collection of GeoDataFrames

    Args:
        gdfs (Dict[str, gp.GeoDataFrame]): Dictionary of GeoDataFrames
        style (Dict[str, dict]): Dictionary of matplotlib style parameters

    Returns:
        Tuple[BaseGeometry, float, float, float, float, float, float]: background geometry, bounds, width and height
    """
    
    # Create background
    background_pad = 1.1
    if "background" in style and "pad" in style["background"]:
        background_pad = style["background"].pop("pad")

    background = shapely.affinity.scale(
        box(*
            shapely.ops.unary_union(ox.project_gdf(gdfs["perimeter"]).geometry).bounds),
        background_pad,
        background_pad,
    )

    if "background" in style and "dilate" in style["background"]:
        background = background.buffer(style['background'].pop("dilate"))

    # Get bounds
    xmin, ymin, xmax, ymax = background.bounds
    dx, dy = xmax - xmin, ymax - ymin

    return background, xmin, ymin, xmax, ymax, dx, dy


def draw_text(
    params: Dict[str, dict],
    background: BaseGeometry
) -> None:
    """
    Draw text with content and matplotlib style parameters specified by 'params' dictionary.
    params['text'] should contain the message to be drawn

    Args:
        params (Dict[str, dict]): matplotlib style parameters for drawing text. params['text'] should contain the message to be drawn.
        background (BaseGeometry): Background layer
    """
    # Override default osm_credit dict with provided parameters
    params = override_params(
        dict(
            text="\n".join([
                "data © OpenStreetMap contributors",
                "github.com/marceloprates/prettymaps"
            ]),
            x=0, y=1,
            horizontalalignment='left',
            verticalalignment='top',
            bbox=dict(boxstyle='square', fc='#fff', ec='#000'),
            fontfamily='Ubuntu Mono'
        ),
        params
    )
    x, y, text = [params.pop(k) for k in ['x', 'y', 'text']]

    # Get background bounds
    xmin, ymin, xmax, ymax = background.bounds

    x = np.interp([x], [0, 1], [xmin, xmax])[0]
    y = np.interp([y], [0, 1], [ymin, ymax])[0]

    plt.text(
        x, y, text,
        **params
    )


def presets_directory():
    return os.path.join(pathlib.Path(__file__).resolve().parent, 'presets')


def create_preset(
    name: str,
    layers: Optional[Dict[str, dict]] = None,
    style: Optional[Dict[str, dict]] = None,
    circle: Optional[bool] = None,
    radius: Optional[Union[float, bool]] = None,
    dilate: Optional[Union[float, bool]] = None,
) -> None:
    """
    Create a preset file and save it on the presets folder (prettymaps/presets/) under name 'name.json'

    Args:
        name (str): Preset name
        layers (Dict[str, dict], optional): prettymaps.plot() 'layers' parameter dict. Defaults to None.
        style (Dict[str, dict], optional): prettymaps.plot() 'style' parameter dict. Defaults to None.
        circle (Optional[bool], optional): prettymaps.plot() 'circle' parameter. Defaults to None.
        radius (Optional[Union[float, bool]], optional): prettymaps.plot() 'radius' parameter. Defaults to None.
        dilate (Optional[Union[float, bool]], optional): prettymaps.plot() 'dilate' parameter. Defaults to None.
    """

    # if not os.path.isdir('presets'):
    #    os.makedirs('presets')

    path = os.path.join(presets_directory(), f"{name}.json")
    with open(path, "w") as f:
        json.dump(
            {
                "layers": layers,
                "style": style,
                "circle": circle,
                "radius": radius,
                "dilate": dilate,
            },
            f,
            ensure_ascii=False,
        )


def read_preset(name: str) -> Dict[str, dict]:
    """
    Read a preset from the presets folder (prettymaps/presets/)

    Args:
        name (str): Preset name

    Returns:
        (Dict[str,dict]): parameters dictionary
    """
    path = os.path.join(presets_directory(), f"{name}.json")
    with open(path, "r") as f:
        # Load params from JSON file
        params = json.load(f)
    return params


def delete_preset(name: str) -> None:
    """
    Delete a preset from the presets folder (prettymaps/presets/)

    Args:
        name (str): Preset name
    """

    path = os.path.join(presets_directory(), f"{name}.json")
    if os.path.exists(path):
        os.remove(path)


def override_preset(
    name: str,
    layers: Dict[str, dict] = {},
    style: Dict[str, dict] = {},
    circle: Optional[float] = None,
    radius: Optional[Union[float, bool]] = None,
    dilate: Optional[Union[float, bool]] = None
) -> Tuple[dict, dict, Optional[float], Optional[Union[float, bool]], Optional[Union[float, bool]]]:
    """
    Read the preset file given by 'name' and override it with additional parameters

    Args:
        name (str): _description_
        layers (Dict[str, dict], optional): _description_. Defaults to {}.
        style (Dict[str, dict], optional): _description_. Defaults to {}.
        circle (Union[float, None], optional): _description_. Defaults to None.
        radius (Union[float, None], optional): _description_. Defaults to None.
        dilate (Union[float, None], optional): _description_. Defaults to None.

    Returns:
        Tuple[dict, dict, Optional[float], Optional[Union[float, bool]], Optional[Union[float, bool]]]: Preset parameters overriden by additional provided parameters
    """

    params = read_preset(name)

    # Override preset with kwargs
    if "layers" in params:
        layers = override_params(params["layers"], layers)
    if "style" in params:
        style = override_params(params["style"], style)
    if circle is None and "circle" in params:
        circle = params["circle"]
    if radius is None and "radius" in params:
        radius = params["radius"]
    if dilate is None and "dilate" in params:
        dilate = params["dilate"]

    # Delete layers marked as 'False' in the parameter dict
    for layer in [key for key in layers.keys() if layers[key] == False]:
        del layers[layer]

    # Return overriden presets
    return layers, style, circle, radius, dilate


def manage_presets(
    load_preset: Optional[str],
    save_preset: bool,
    update_preset: Optional[str],
    layers: Dict[str, dict],
    style: Dict[str, dict],
    circle: Optional[bool],
    radius: Optional[Union[float, bool]],
    dilate: Optional[Union[float, bool]]
) -> Tuple[dict, dict, Optional[float], Optional[Union[float, bool]], Optional[Union[float, bool]]]:
    """_summary_

    Args:
        load_preset (Optional[str]): Load preset named 'load_preset', if provided
        save_preset (Optional[str]): Save preset to file named 'save_preset', if provided
        update_preset (Optional[str]): Load, update and save preset named 'update_preset', if provided
        layers (Dict[str, dict]): prettymaps.plot() 'layers' parameter dict
        style (Dict[str, dict]): prettymaps.plot() 'style' parameter dict
        circle (Optional[bool]): prettymaps.plot() 'circle' parameter
        radius (Optional[Union[float, bool]]): prettymaps.plot() 'radius' parameter
        dilate (Optional[Union[float, bool]]): prettymaps.plot() 'dilate' parameter

    Returns:
        Tuple[dict, dict, Optional[float], Optional[Union[float, bool]], Optional[Union[float, bool]]]: Updated layers, style, circle, radius, dilate parameters
    """

    # Update preset mode: load a preset, update it with additional parameters and update the JSON file
    if update_preset:
        load_preset = save_preset = True

    # Load preset (if provided)
    if load_preset:
        layers, style, circle, radius, dilate = override_preset(
            load_preset,
            layers, style, circle, radius, dilate
        )

    # Save parameters as preset
    if save_preset:
        create_preset(
            save_preset,
            layers=layers,
            style=style,
            circle=circle,
            radius=radius,
            dilate=dilate,
        )

    return layers, style, circle, radius, dilate


def presets():
    presets = [file.split('.')[0] for file in os.listdir(
        presets_directory()) if file.endswith('.json')]
    presets = sorted(presets)
    presets = pd.DataFrame({
        'preset': presets,
        'params': list(map(read_preset, presets))
    })

    #print('Available presets:')
    # for i, preset in enumerate(presets):
    #    print(f'{i+1}. {preset}')

    return pd.DataFrame(presets)


def preset(name):
    with open(os.path.join(presets_directory(), f"{name}.json"), "r") as f:
        # Load params from JSON file
        params = json.load(f)
        return Preset(params)


# Plot


def plot(
    # Your query. Example:
    # - "Porto Alegre"
    # - (-30.0324999, -51.2303767) (lat/long coordinates)
    # - You can also provide a custom GeoDataFrame boundary as input
    query: Union[str, Tuple[float, float], gp.GeoDataFrame],
    # Which OpenStreetMap layers to plot
    # Example: {'building': {'tags': {'building': True}}, 'streets': {'width': 2}}
    # Run prettymaps.presets() for more examples
    layers={},
    # Matplotlib params for drawing each layer
    style={},
    # Whether to load params from preset
    preset='default',
    # Whether to save preset
    save_preset=None,
    # Whether to load and update preset with additional parameters
    update_preset=None,
    # Custom postprocessing function on layers
    postprocessing=None,
    # Circular boundary? Default: square
    circle=None,
    # Radius for circular or square boundary
    radius=None,
    # Dilate boundary by this much
    dilate=None,
    # Whether to save result
    save_as=None,
    # Figure parameters
    fig=None,
    ax=None,
    title=None,
    figsize=(12, 12),
    constrained_layout=True,
    # Credit message parameters
    credit={},
    # Mode ('matplotlib' or 'plotter')
    mode='matplotlib',
    # Multiplot mode
    multiplot=False,
    # Whether to display matplotlib
    show=True,
    # Transform (translation, scale, rotation) parameters
    x=0,
    y=0,
    scale_x=1,
    scale_y=1,
    rotation=0,
):
    """

    Draw a map from OpenStreetMap data.

    Parameters
    ----------
    query : string
        The address to geocode and use as the central point around which to get the geometries
    backup : dict
        (Optional) feed the output from a previous 'plot()' run to save time
    postprocessing: function
        (Optional) Apply a postprocessing step to the 'layers' dict
    radius
        (Optional) If not None, draw the map centered around the address with this radius (in meters)
    layers: dict
        Specify the name of each layer and the OpenStreetMap tags to fetch
    style: dict
        Drawing params for each layer (matplotlib params such as 'fc', 'ec', 'fill', etc.)
    osm_credit: dict
        OSM Caption parameters
    figsize: Tuple
        (Optional) Width and Height (in inches) for the Matplotlib figure. Defaults to (10, 10)
    ax: axes
        Matplotlib axes
    title: String
        (Optional) Title for the Matplotlib figure
    vsketch: Vsketch
        (Optional) Vsketch object for pen plotting
    x: float
        (Optional) Horizontal displacement
    y: float
        (Optional) Vertical displacement
    scale_x: float
        (Optional) Horizontal scale factor
    scale_y: float
        (Optional) Vertical scale factor
    rotation: float
        (Optional) Rotation in angles (0-360)

    Returns
    -------
    layers: dict
        Dictionary of layers (each layer is a Shapely MultiPolygon)

    Notes
    -----

    """

    # 1. Manage presets
    layers, style, circle, radius, dilate = manage_presets(
        preset, save_preset, update_preset,
        layers, style, circle, radius, dilate
    )

    # 2. Init matplotlib figure
    if (mode == "matplotlib") and (ax is None):
        fig = plt.figure(figsize=figsize)
        ax = plt.subplot(111, aspect='equal')

    # 3. Override arguments in layers' kwargs dict
    layers = override_args(layers, circle, dilate)

    # 4. Fetch geodataframes
    gdfs = get_gdfs(query, layers, radius, dilate, -rotation)

    # 5. Apply transformations to GeoDataFrames (translation, scale, rotation)
    gdfs = transform_gdfs(gdfs, x, y, scale_x, scale_y, rotation)

    # 6. Apply a postprocessing function to the GeoDataFrames, if provided
    if postprocessing:
        gdfs = postprocessing(gdfs)

    # 7. Create background GeoDataFrame and get (x,y) bounds
    background, xmin, ymin, xmax, ymax, dx, dy = create_background(gdfs, style)

    # 8. Draw layers
    if mode == "plotter":
        # 8.1. Draw layers in plotter (vsketch) mode
        '''
        class Sketch(vsketch.SketchClass):

            def draw(self, vsk: vsketch.Vsketch):

                vsk.size("a4", landscape=True)

                for layer in gdfs:
                    if layer in layers:
                        plot_gdf(
                            layer,
                            gdfs[layer],
                            ax,
                            width=layers[layer]["width"]
                            if "width" in layers[layer]
                            else None,
                            mode=mode,
                            vsk=vsk,
                            **(style[layer] if layer in style else {}),
                        )

                if save_as:
                    vsk.save(save_as)

            def finalize(self, vsk: vsketch.Vsketch):
                vsk.vpype("linemerge linesimplify reloop linesort")

        sketch = Sketch()
        sketch.display()
        '''
    elif mode == "matplotlib":
        # 8.2. Draw layers in matplotlib mode
        for layer in gdfs:
            if layer in layers:
                plot_gdf(
                    layer,
                    gdfs[layer],
                    ax,
                    width=layers[layer]["width"] if "width" in layers[layer] else None,
                    **(style[layer] if layer in style else {}),
                )
    else:
        raise Exception(f'Unknown mode {mode}')

    # 9. Draw background
    if "background" in style:
        zorder = style["background"].pop(
            "zorder") if "zorder" in style["background"] else -1
        ax.add_patch(PolygonPatch(
            background, **{k:v for k,v in style["background"].items() if k != 'dilate'}, zorder=zorder))

    # 10. Draw credit message
    if (mode == "matplotlib") and (credit != False) and (not multiplot):
        draw_text(credit, background)

    # 11. Ajust figure and create PIL Image
    if mode == "matplotlib":
        # Adjust axis
        ax.axis("off")
        ax.axis("equal")
        ax.autoscale()
        # Adjust padding
        plt.subplots_adjust(
            left=0, bottom=0, right=1, top=1, wspace=0, hspace=0
        )
        # Save result
        if save_as:
            plt.savefig(save_as)
        if not show:
            plt.close()

    return Plot(gdfs, fig, ax, background)


def multiplot(*subplots, figsize=None, credit={}, **kwargs):

    fig = plt.figure(figsize=figsize)
    ax = plt.subplot(111, aspect='equal')

    mode = "plotter" if "plotter" in kwargs and kwargs["plotter"] else "matplotlib"

    subplots_results = [
        plot(
            subplot.query,
            ax=ax,
            multiplot=True,
            **override_params(subplot.kwargs, {k: v for k, v in kwargs.items() if k != 'load_preset' or 'load_preset' not in subplot.kwargs})
        )
        for subplot in subplots
    ]

    if mode == "matplotlib":
        ax.axis("off")
        ax.axis("equal")
        ax.autoscale()
        plt.subplots_adjust(
            left=0, bottom=0, right=1, top=1, wspace=0, hspace=0
        )
        if 'show' in kwargs and not kwargs['show']:
            plt.close()

        if credit != False:
            backgrounds = [result.background for result in subplots_results]
            global_background = box(
                *shapely.ops.unary_union(backgrounds).bounds)
            draw_text(credit, global_background)
