from argparse import ArgumentParser
import math
from pathlib import Path
import re
import shutil
import sys
from typing import List, Tuple
import tempfile

from IPython.utils import io

from solid2 import *
from viewscad import Renderer

HEX_SIDES = 6
HEX_DIAMETER = 25
# Apothem: The perpendicular distance from the center of a regular polygon
# to one of its sides.
HEX_APOTHEM = HEX_DIAMETER / math.sqrt(3)
HEX_SHAPE = circle(d=HEX_APOTHEM, _fn=HEX_SIDES)

# These two constants correlate specifically with the "ring back" STL files as
# the placement of the hexagons is fixed.
X_OFFSET = 11.2
Y_OFFSET = 12.8

# Height of the caps on the mesh fabric.
CAP_HEIGHT = 1.0

# There are only ring-backs for hex arrays from 2 to 9 hexagons along a side.
MIN_SIZE = 2
MAX_SIZE = 9

# This code assumes that the input SVG files are sized (see its "viewBox")
# for a fabric with 6 tiles along each edge.
SVG_EXPECTED_SIZE = 6

# Filename arguments to this script may have "@n.nn" appended to cause
# generation of two STLs, one for n.nn millimeters and the other for the
# remainder up to CAP_HEIGHT. For example: input_pic.svg@0.4 will create
# input_pic-1.stl for the first 0.4mm and input_pic-2.stl for the remaining
# layers.
SVG_ARG_REGEXP = re.compile(r'^(.+?)(@(\d*\.?\d*))?$')

# The 2.stl through 9.stl files which hold the array of interlinked rings are
# stored in here. The appropriate one will be copied to the target directory
# to go with the rest of the fabric's STL files.
RING_BACK_DIR = Path(__file__).parent / 'RingBacks'


def common_prefix(str1: str, str2: str) -> str:
    """
    Returns the longest common prefix between two strings.

    Args:
    - str1 (str): The first string to compare.
    - str2 (str): The second string to compare.

    Returns:
    - str: The longest common prefix of the two input strings.
    """
    for i, (char1, char2) in enumerate(zip(str1, str2)):
        if char1 != char2:
            return str1[:i]
    return str1 if len(str1) <= len(str2) else str2


def get_fabric_size(edge_hex_count: int) -> Tuple[float, float]:
    """
    Returns values that are closely tied to the matching ring-back STL files.

    Args:
    - edge_hex_count (int): Count of hexagons on the edge.

    Returns:
    - Tuple[float, float]: Computed size of fabric.
    """
    starting_size = (HEX_APOTHEM, HEX_DIAMETER / 2)
    return (starting_size[0] + (edge_hex_count - 1) * X_OFFSET * 2,
            starting_size[1] + (edge_hex_count - 1) * Y_OFFSET * 2)


# Maps hexagonal polar directions to cartesian offsets from the center point.
SIDE_OFFSETS = {
    0: (X_OFFSET, Y_OFFSET / 2),
    1: (0, Y_OFFSET),
    2: (-X_OFFSET, Y_OFFSET / 2),
    3: (-X_OFFSET, -Y_OFFSET / 2),
    4: (0, -Y_OFFSET),
    5: (X_OFFSET, -Y_OFFSET / 2),
}


def generate_hex_pattern(n: int) -> OpenSCADObject:
    """
    Generates a pattern of hexagonal shapes in a circular arrangement at
    radius n starting from a center tile.

    Args:
    - n (int): Defines the 'radius' of the pattern in terms of hexagonal tiles.

    Returns:
    - OpenSCADObject: Array of hexagons with n tiles per side.
    """
    if n == 0:
        return HEX_SHAPE

    # Starting point for first tile to be pla
    x = 0
    y = -Y_OFFSET * n

    hex_array = union()
    for side in range(HEX_SIDES):
        dx, dy = SIDE_OFFSETS[side]
        for _ in range(n):
            x += dx
            y += dy
            hex_array += HEX_SHAPE.translate(x, y, 0)

    return hex_array


def get_caps_for_fabric(n: int) -> OpenSCADObject:
    """
    Generates a pattern of hexagonal pieces tiled around a center piece. This
    creates a fabric of tiles with n tiles along each edge.

    Args:
    - n (int): Number of tiles along each side to include in the pattern.

    Returns:
    - OpenSCADObject: A hexagonal array of hexagonal tiles.
    """
    all_caps = union()
    for layer in range(n):
        all_caps += generate_hex_pattern(layer)

    return all_caps


def get_shape_from_svg(svg_path: Path,
                       fabric_size: int,
                       fixed_size: bool) -> OpenSCADObject:
    """
    Imports a shape from an SVG file and scales it to fit for a design with
    fabric_size tiles along each edge.

    Args:
    - svg_path (Path): Path to the SVG file.
    - fabric_size (int): Target fabric size in terms of tiles per edge.

    Returns:
    - OpenSCADObject: This SVG's shape prepared to fit the expected size.
    """

    # This code assumes that the input SVG files are sized (see "viewBox")
    # for a fabric with 6 tiles along each edge. This scales the resulting
    # graphic accordingly.
    mask_x, mask_y = get_fabric_size(SVG_EXPECTED_SIZE)
    shape = import_(svg_path.absolute().as_posix()) & square(mask_x, mask_y)
    target_size_x, target_size_y = get_fabric_size(fabric_size)

    svg_shape = shape.left(mask_x / 2).back(mask_y / 2)
    return (
        svg_shape if fixed_size
        else svg_shape.scale((target_size_x / mask_x, target_size_y / mask_y))
    )


# Using a global just saves on redundant instantiations.
renderer = Renderer(draw_grids=False)
renderer.tmp_dir = tempfile.mkdtemp()


def render_to_stl(obj: OpenSCADObject, output_path: str) -> None:
    """
    Renders the given object to STL format and writes it to the specified path.

    Args:
    - obj (OpenSCADObject): The object to be rendered.
    - output_path (str): The path where the rendered STL file will be written.

    Side Effects:
    - Writes an STL file to the given `output_path`.
    """
    with io.capture_output() as _:
        renderer.render(obj, outfile=output_path)


def render_shape_to_stl(shape: OpenSCADObject,
                        stl_path: Path,
                        shaved_mm: float = 0) -> None:
    """
    Renders a 3D object from a 2D shape by extruding it and outputs the
    resulting STL file(s). Can generate two stacked STL files based on the
    `shaved_mm` value.

    Args:
    - shape (OpenSCADObject): The 2D shape to be rendered.
    - stl_path (Path): The path where the rendered STL file will be written.
    - shaved_mm (float, optional): Height to be rendered into the first STL
    part; the remaining height will be written to the second. Defaults to 0.

    Side Effects:
    - Writes one or two STL files to the given `stl_path`, potentially
      appending "-1" or "-2" to the base filename of the input file.
    """
    if shaved_mm:
        render_to_stl(shape.linear_extrude(shaved_mm),
                      stl_path.with_stem(stl_path.stem + '-1').as_posix())

    render_to_stl(shape.linear_extrude(CAP_HEIGHT - shaved_mm).up(shaved_mm),
                  stl_path.with_stem(
                      stl_path.stem + ('-2' if shaved_mm else '')
                  ).as_posix())


def parse_filename_arg(filename_arg: str) -> Tuple[str, float]:
    """
    Parses the given filename argument to extract the base filename and a
    potential thickness value.

    Filename arguments to this function may have "@n.nn" appended. This
    indicates that two STLs should be generated: one for n.nn millimeters
    and the other for the remainder up to CAP_HEIGHT.

    For example:
    "input_pic.svg@0.4" will be parsed as ("input_pic.svg", 0.4).

    Args:
    - filename_arg (str): The filename string which may have an optional
      "@<number>" suffix.

    Returns:
    - tuple: A tuple containing the base filename and the thickness value.
      If no thickness value is found, 0.0 is returned.
    """
    filename_match = SVG_ARG_REGEXP.match(filename_arg)
    if filename_match:
        thickness_value = filename_match.group(3)
        return (
            filename_match.group(1),
            float(thickness_value) if thickness_value else 0.0
        )
    else:
        return filename_arg, 0.0


def process_svg_files(fabric_size: int,
                      svg_files: List[str],
                      fixed_size: bool,
                      tag_output_files: bool) -> None:
    """
    Processes a list of SVG files, rendering them into separate STL files
    for the various parts of the image being processed for a fidget fabric.
    """
    base_path = Path(svg_files[0])
    base_filename = base_path.stem
    default_dir = base_path.parent

    # Contains 2D shapes of the fabric's caps. As SVGs are processed,
    # their resulting objects are subtracted to avoid overlaps in STLs.
    caps = get_caps_for_fabric(fabric_size)
    for svg_file in svg_files:
        filename, shaved_mm = parse_filename_arg(svg_file)
        svg_path = Path(filename)

        # Update base_filename to maintain the common prefix among SVG files.
        base_filename = common_prefix(base_filename, svg_path.stem)

        output_file = svg_path.with_suffix('.stl')

        if tag_output_files:
            output_file = (
                output_file.with_stem(f'{fabric_size}-{output_file.stem}')
            )
        print(f"Rendering {output_file.as_posix()}...")

        svg_shape = get_shape_from_svg(svg_path,
                                       fabric_size,
                                       fixed_size) & caps
        caps -= svg_shape

        render_shape_to_stl(svg_shape, output_file, shaved_mm)

    # Render the remaining parts as background.
    prefix = f'{base_filename}{"-" if base_filename else ""}'
    if tag_output_files:
        prefix = f'{fabric_size}-{prefix}'
    output_file = default_dir / f'{prefix}background.stl'
    print(f"Rendering {output_file.as_posix()}")
    render_shape_to_stl(caps, output_file)

    try:
        rings_path = default_dir / f'{prefix}rings.stl'
        shutil.copyfile(RING_BACK_DIR / f'{fabric_size}.stl', rings_path)
    except Exception as e:
        print(f"Error while copying ring back STL: {e}", file=sys.stderr)


def main() -> None:
    parser = ArgumentParser(
        prog="Fidget fabric maker",
        description="Slice up hex tiles by provided SVGs to be used to make "
                    "fidget fabrics with multicolor designs.",
    )
    parser.add_argument(
        'size', type=int, nargs=1,
        help="The size of the fabric. It should be an integer value within "
             f"the range [{MIN_SIZE}, {MAX_SIZE}].")
    parser.add_argument(
        'svg_files', type=str, nargs='+',
        help="List of SVG file paths. Each SVG file defines a design to "
             "slice up hex tiles. Filenames can be appended with '@n.nn' to "
             "specify a thickness in millimeters for splitting the part into "
             "two stacked pieces.")
    parser.add_argument(
        '--fixed', '-f', default=False, action='store_true',
        help="If specified, svg_files will not be scaled from the expected "
             "input size (6) allowing larger fields of view for larger "
             "fabrics."
    )
    parser.add_argument(
        '--tag', '-t', default=False, action='store_true',
        help="If specified, all output files will be prefixed by 'n-' where "
             "n is the chosen size in order to keep multiple renderings "
             "together."
    )
    args = parser.parse_args()

    fabric_size = args.size[0]
    if not MIN_SIZE <= fabric_size <= MAX_SIZE:
        print(f"Fabric size {fabric_size} is not valid; "
              f"it must be within {MIN_SIZE} and {MAX_SIZE}",
              file=sys.stderr)
        sys.exit(1)

    process_svg_files(fabric_size, args.svg_files, args.fixed, args.tag)


if __name__ == '__main__':
    main()
