"""
Drawing Primitives Library

Draw shapes, text, and characters on images with sophisticated text handling.

Text Features:
- Font loading from files or system fonts
- Text measurement and fitting
- Alignment (left/center/right, top/middle/bottom)
- Opacity for fade effects
- Multi-line text support
- Shadow and outline effects
"""
import numpy as np
import cv2
from PIL import Image, ImageDraw, ImageFont
import os
import glob as glob_module
from typing import Optional, Tuple, List, Union


# =============================================================================
# Font Management
# =============================================================================

# Font cache: (path, size) -> font object
_font_cache = {}

# Common system font directories
FONT_DIRS = [
    "/usr/share/fonts",
    "/usr/local/share/fonts",
    "~/.fonts",
    "~/.local/share/fonts",
    "/System/Library/Fonts",  # macOS
    "/Library/Fonts",  # macOS
    "C:/Windows/Fonts",  # Windows
]

# Default fonts to try (in order of preference)
DEFAULT_FONTS = [
    "DejaVuSans.ttf",
    "DejaVuSansMono.ttf",
    "Arial.ttf",
    "Helvetica.ttf",
    "FreeSans.ttf",
    "LiberationSans-Regular.ttf",
]


def _find_font_file(name: str) -> Optional[str]:
    """Find a font file by name in system directories."""
    # If it's already a full path
    if os.path.isfile(name):
        return name

    # Expand user paths
    expanded = os.path.expanduser(name)
    if os.path.isfile(expanded):
        return expanded

    # Search in font directories
    for font_dir in FONT_DIRS:
        font_dir = os.path.expanduser(font_dir)
        if not os.path.isdir(font_dir):
            continue

        # Direct match
        direct = os.path.join(font_dir, name)
        if os.path.isfile(direct):
            return direct

        # Recursive search
        for root, dirs, files in os.walk(font_dir):
            for f in files:
                if f.lower() == name.lower():
                    return os.path.join(root, f)
                # Also match without extension
                base = os.path.splitext(f)[0]
                if base.lower() == name.lower():
                    return os.path.join(root, f)

    return None


def _get_default_font(size: int = 24) -> ImageFont.FreeTypeFont:
    """Get a default font at the given size."""
    for font_name in DEFAULT_FONTS:
        path = _find_font_file(font_name)
        if path:
            try:
                return ImageFont.truetype(path, size)
            except:
                continue

    # Last resort: PIL default
    return ImageFont.load_default()


def prim_make_font(name_or_path: str, size: int = 24) -> ImageFont.FreeTypeFont:
    """
    Load a font by name or path.

    (make-font "Arial" 32)           ; system font by name
    (make-font "/path/to/font.ttf" 24)  ; font file path
    (make-font "DejaVuSans" 48)      ; searches common locations

    Returns a font object for use with text primitives.
    """
    size = int(size)

    # Check cache
    cache_key = (name_or_path, size)
    if cache_key in _font_cache:
        return _font_cache[cache_key]

    # Find the font file
    path = _find_font_file(name_or_path)
    if not path:
        raise FileNotFoundError(f"Font not found: {name_or_path}")

    # Load and cache
    font = ImageFont.truetype(path, size)
    _font_cache[cache_key] = font
    return font


def prim_list_fonts() -> List[str]:
    """
    List available system fonts.

    (list-fonts)  ; -> ("Arial.ttf" "DejaVuSans.ttf" ...)

    Returns list of font filenames found in system directories.
    """
    fonts = set()

    for font_dir in FONT_DIRS:
        font_dir = os.path.expanduser(font_dir)
        if not os.path.isdir(font_dir):
            continue

        for root, dirs, files in os.walk(font_dir):
            for f in files:
                if f.lower().endswith(('.ttf', '.otf', '.ttc')):
                    fonts.add(f)

    return sorted(fonts)


def prim_font_size(font: ImageFont.FreeTypeFont) -> int:
    """
    Get the size of a font.

    (font-size my-font)  ; -> 24
    """
    return font.size


# =============================================================================
# Text Measurement
# =============================================================================

def prim_text_size(text: str, font=None, font_size: int = 24) -> Tuple[int, int]:
    """
    Measure text dimensions.

    (text-size "Hello" my-font)      ; -> (width height)
    (text-size "Hello" :font-size 32) ; -> (width height) with default font

    For multi-line text, returns total bounding box.
    """
    if font is None:
        font = _get_default_font(int(font_size))
    elif isinstance(font, (int, float)):
        font = _get_default_font(int(font))

    # Create temporary image for measurement
    img = Image.new('RGB', (1, 1))
    draw = ImageDraw.Draw(img)

    bbox = draw.textbbox((0, 0), str(text), font=font)
    width = bbox[2] - bbox[0]
    height = bbox[3] - bbox[1]

    return (width, height)


def prim_text_metrics(font=None, font_size: int = 24) -> dict:
    """
    Get font metrics.

    (text-metrics my-font)  ; -> {ascent: 20, descent: 5, height: 25}

    Useful for precise text layout.
    """
    if font is None:
        font = _get_default_font(int(font_size))
    elif isinstance(font, (int, float)):
        font = _get_default_font(int(font))

    ascent, descent = font.getmetrics()
    return {
        'ascent': ascent,
        'descent': descent,
        'height': ascent + descent,
        'size': font.size,
    }


def prim_fit_text_size(text: str, max_width: int, max_height: int,
                       font_name: str = None, min_size: int = 8,
                       max_size: int = 500) -> int:
    """
    Calculate font size to fit text within bounds.

    (fit-text-size "Hello World" 400 100)  ; -> 48
    (fit-text-size "Title" 800 200 :font-name "Arial")

    Returns the largest font size that fits within max_width x max_height.
    """
    max_width = int(max_width)
    max_height = int(max_height)
    min_size = int(min_size)
    max_size = int(max_size)
    text = str(text)

    # Binary search for optimal size
    best_size = min_size
    low, high = min_size, max_size

    while low <= high:
        mid = (low + high) // 2

        if font_name:
            try:
                font = prim_make_font(font_name, mid)
            except:
                font = _get_default_font(mid)
        else:
            font = _get_default_font(mid)

        w, h = prim_text_size(text, font)

        if w <= max_width and h <= max_height:
            best_size = mid
            low = mid + 1
        else:
            high = mid - 1

    return best_size


def prim_fit_font(text: str, max_width: int, max_height: int,
                  font_name: str = None, min_size: int = 8,
                  max_size: int = 500) -> ImageFont.FreeTypeFont:
    """
    Create a font sized to fit text within bounds.

    (fit-font "Hello World" 400 100)  ; -> font object
    (fit-font "Title" 800 200 :font-name "Arial")

    Returns a font object at the optimal size.
    """
    size = prim_fit_text_size(text, max_width, max_height,
                               font_name, min_size, max_size)

    if font_name:
        try:
            return prim_make_font(font_name, size)
        except:
            pass

    return _get_default_font(size)


# =============================================================================
# Text Drawing
# =============================================================================

def prim_text(img: np.ndarray, text: str,
              x: int = None, y: int = None,
              width: int = None, height: int = None,
              font=None, font_size: int = 24, font_name: str = None,
              color=None, opacity: float = 1.0,
              align: str = "left", valign: str = "top",
              fit: bool = False,
              shadow: bool = False, shadow_color=None, shadow_offset: int = 2,
              outline: bool = False, outline_color=None, outline_width: int = 1,
              line_spacing: float = 1.2) -> np.ndarray:
    """
    Draw text with alignment, opacity, and effects.

    Basic usage:
        (text frame "Hello" :x 100 :y 50)

    Centered in frame:
        (text frame "Title" :align "center" :valign "middle")

    Fit to box:
        (text frame "Big Text" :x 50 :y 50 :width 400 :height 100 :fit true)

    With fade (for animations):
        (text frame "Fading" :x 100 :y 100 :opacity 0.5)

    With effects:
        (text frame "Shadow" :x 100 :y 100 :shadow true)
        (text frame "Outline" :x 100 :y 100 :outline true :outline-color (0 0 0))

    Args:
        img: Input frame
        text: Text to draw
        x, y: Position (if not specified, uses alignment in full frame)
        width, height: Bounding box (for fit and alignment within box)
        font: Font object from make-font
        font_size: Size if no font specified
        font_name: Font name to load
        color: RGB tuple (default white)
        opacity: 0.0 (invisible) to 1.0 (opaque) for fading
        align: "left", "center", "right"
        valign: "top", "middle", "bottom"
        fit: If true, auto-size font to fit in box
        shadow: Draw drop shadow
        shadow_color: Shadow color (default black)
        shadow_offset: Shadow offset in pixels
        outline: Draw text outline
        outline_color: Outline color (default black)
        outline_width: Outline thickness
        line_spacing: Multiplier for line height (for multi-line)

    Returns:
        Frame with text drawn
    """
    h, w = img.shape[:2]
    text = str(text)

    # Default colors
    if color is None:
        color = (255, 255, 255)
    else:
        color = tuple(int(c) for c in color)

    if shadow_color is None:
        shadow_color = (0, 0, 0)
    else:
        shadow_color = tuple(int(c) for c in shadow_color)

    if outline_color is None:
        outline_color = (0, 0, 0)
    else:
        outline_color = tuple(int(c) for c in outline_color)

    # Determine bounding box
    if x is None:
        x = 0
        if width is None:
            width = w
    if y is None:
        y = 0
        if height is None:
            height = h

    x, y = int(x), int(y)
    box_width = int(width) if width else w - x
    box_height = int(height) if height else h - y

    # Get or create font
    if font is None:
        if fit:
            font = prim_fit_font(text, box_width, box_height, font_name)
        elif font_name:
            try:
                font = prim_make_font(font_name, int(font_size))
            except:
                font = _get_default_font(int(font_size))
        else:
            font = _get_default_font(int(font_size))

    # Measure text
    text_w, text_h = prim_text_size(text, font)

    # Calculate position based on alignment
    if align == "center":
        draw_x = x + (box_width - text_w) // 2
    elif align == "right":
        draw_x = x + box_width - text_w
    else:  # left
        draw_x = x

    if valign == "middle":
        draw_y = y + (box_height - text_h) // 2
    elif valign == "bottom":
        draw_y = y + box_height - text_h
    else:  # top
        draw_y = y

    # Create RGBA image for compositing with opacity
    pil_img = Image.fromarray(img).convert('RGBA')

    # Create text layer with transparency
    text_layer = Image.new('RGBA', (w, h), (0, 0, 0, 0))
    draw = ImageDraw.Draw(text_layer)

    # Draw shadow first (if enabled)
    if shadow:
        shadow_x = draw_x + shadow_offset
        shadow_y = draw_y + shadow_offset
        shadow_rgba = shadow_color + (int(255 * opacity * 0.5),)
        draw.text((shadow_x, shadow_y), text, fill=shadow_rgba, font=font)

    # Draw outline (if enabled)
    if outline:
        outline_rgba = outline_color + (int(255 * opacity),)
        ow = int(outline_width)
        for dx in range(-ow, ow + 1):
            for dy in range(-ow, ow + 1):
                if dx != 0 or dy != 0:
                    draw.text((draw_x + dx, draw_y + dy), text,
                             fill=outline_rgba, font=font)

    # Draw main text
    text_rgba = color + (int(255 * opacity),)
    draw.text((draw_x, draw_y), text, fill=text_rgba, font=font)

    # Composite
    result = Image.alpha_composite(pil_img, text_layer)
    return np.array(result.convert('RGB'))


def prim_text_box(img: np.ndarray, text: str,
                  x: int, y: int, width: int, height: int,
                  font=None, font_size: int = 24, font_name: str = None,
                  color=None, opacity: float = 1.0,
                  align: str = "center", valign: str = "middle",
                  fit: bool = True,
                  padding: int = 0,
                  background=None, background_opacity: float = 0.5,
                  **kwargs) -> np.ndarray:
    """
    Draw text fitted within a box, optionally with background.

    (text-box frame "Title" 50 50 400 100)
    (text-box frame "Subtitle" 50 160 400 50
              :background (0 0 0) :background-opacity 0.7)

    Convenience wrapper around text() for common box-with-text pattern.
    """
    x, y = int(x), int(y)
    width, height = int(width), int(height)
    padding = int(padding)

    result = img.copy()

    # Draw background if specified
    if background is not None:
        bg_color = tuple(int(c) for c in background)

        # Create background with opacity
        pil_img = Image.fromarray(result).convert('RGBA')
        bg_layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
        bg_draw = ImageDraw.Draw(bg_layer)
        bg_rgba = bg_color + (int(255 * background_opacity),)
        bg_draw.rectangle([x, y, x + width, y + height], fill=bg_rgba)
        result = np.array(Image.alpha_composite(pil_img, bg_layer).convert('RGB'))

    # Draw text within padded box
    return prim_text(result, text,
                     x=x + padding, y=y + padding,
                     width=width - 2 * padding, height=height - 2 * padding,
                     font=font, font_size=font_size, font_name=font_name,
                     color=color, opacity=opacity,
                     align=align, valign=valign, fit=fit,
                     **kwargs)


# =============================================================================
# Legacy text functions (keep for compatibility)
# =============================================================================

def prim_draw_char(img, char, x, y, font_size=16, color=None):
    """Draw a single character at (x, y). Legacy function."""
    return prim_text(img, str(char), x=int(x), y=int(y),
                    font_size=int(font_size), color=color)


def prim_draw_text(img, text, x, y, font_size=16, color=None):
    """Draw text string at (x, y). Legacy function."""
    return prim_text(img, str(text), x=int(x), y=int(y),
                    font_size=int(font_size), color=color)


# =============================================================================
# Shape Drawing
# =============================================================================

def prim_fill_rect(img, x, y, w, h, color=None, opacity: float = 1.0):
    """
    Fill a rectangle with color.

    (fill-rect frame 10 10 100 50 (255 0 0))
    (fill-rect frame 10 10 100 50 (255 0 0) :opacity 0.5)
    """
    if color is None:
        color = [255, 255, 255]

    x, y, w, h = int(x), int(y), int(w), int(h)

    if opacity >= 1.0:
        result = img.copy()
        result[y:y+h, x:x+w] = color
        return result

    # With opacity, use alpha compositing
    pil_img = Image.fromarray(img).convert('RGBA')
    layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)
    fill_rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
    draw.rectangle([x, y, x + w, y + h], fill=fill_rgba)
    result = Image.alpha_composite(pil_img, layer)
    return np.array(result.convert('RGB'))


def prim_draw_rect(img, x, y, w, h, color=None, thickness=1, opacity: float = 1.0):
    """Draw rectangle outline."""
    if color is None:
        color = [255, 255, 255]

    if opacity >= 1.0:
        result = img.copy()
        cv2.rectangle(result, (int(x), int(y)), (int(x+w), int(y+h)),
                      tuple(int(c) for c in color), int(thickness))
        return result

    # With opacity
    pil_img = Image.fromarray(img).convert('RGBA')
    layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)
    outline_rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
    draw.rectangle([int(x), int(y), int(x+w), int(y+h)],
                   outline=outline_rgba, width=int(thickness))
    result = Image.alpha_composite(pil_img, layer)
    return np.array(result.convert('RGB'))


def prim_draw_line(img, x1, y1, x2, y2, color=None, thickness=1, opacity: float = 1.0):
    """Draw a line from (x1, y1) to (x2, y2)."""
    if color is None:
        color = [255, 255, 255]

    if opacity >= 1.0:
        result = img.copy()
        cv2.line(result, (int(x1), int(y1)), (int(x2), int(y2)),
                 tuple(int(c) for c in color), int(thickness))
        return result

    # With opacity
    pil_img = Image.fromarray(img).convert('RGBA')
    layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)
    line_rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
    draw.line([(int(x1), int(y1)), (int(x2), int(y2))],
              fill=line_rgba, width=int(thickness))
    result = Image.alpha_composite(pil_img, layer)
    return np.array(result.convert('RGB'))


def prim_draw_circle(img, cx, cy, radius, color=None, thickness=1,
                     fill=False, opacity: float = 1.0):
    """Draw a circle."""
    if color is None:
        color = [255, 255, 255]

    if opacity >= 1.0:
        result = img.copy()
        t = -1 if fill else int(thickness)
        cv2.circle(result, (int(cx), int(cy)), int(radius),
                   tuple(int(c) for c in color), t)
        return result

    # With opacity
    pil_img = Image.fromarray(img).convert('RGBA')
    layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)
    cx, cy, r = int(cx), int(cy), int(radius)
    rgba = tuple(int(c) for c in color) + (int(255 * opacity),)

    if fill:
        draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=rgba)
    else:
        draw.ellipse([cx - r, cy - r, cx + r, cy + r],
                     outline=rgba, width=int(thickness))

    result = Image.alpha_composite(pil_img, layer)
    return np.array(result.convert('RGB'))


def prim_draw_ellipse(img, cx, cy, rx, ry, angle=0, color=None,
                      thickness=1, fill=False, opacity: float = 1.0):
    """Draw an ellipse."""
    if color is None:
        color = [255, 255, 255]

    if opacity >= 1.0:
        result = img.copy()
        t = -1 if fill else int(thickness)
        cv2.ellipse(result, (int(cx), int(cy)), (int(rx), int(ry)),
                    float(angle), 0, 360, tuple(int(c) for c in color), t)
        return result

    # With opacity (note: PIL doesn't support rotated ellipses easily)
    # Fall back to cv2 on a separate layer
    layer = np.zeros((img.shape[0], img.shape[1], 4), dtype=np.uint8)
    t = -1 if fill else int(thickness)
    rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
    cv2.ellipse(layer, (int(cx), int(cy)), (int(rx), int(ry)),
                float(angle), 0, 360, rgba, t)

    pil_img = Image.fromarray(img).convert('RGBA')
    pil_layer = Image.fromarray(layer)
    result = Image.alpha_composite(pil_img, pil_layer)
    return np.array(result.convert('RGB'))


def prim_draw_polygon(img, points, color=None, thickness=1,
                      fill=False, opacity: float = 1.0):
    """Draw a polygon from list of [x, y] points."""
    if color is None:
        color = [255, 255, 255]

    if opacity >= 1.0:
        result = img.copy()
        pts = np.array(points, dtype=np.int32).reshape((-1, 1, 2))
        if fill:
            cv2.fillPoly(result, [pts], tuple(int(c) for c in color))
        else:
            cv2.polylines(result, [pts], True,
                         tuple(int(c) for c in color), int(thickness))
        return result

    # With opacity
    pil_img = Image.fromarray(img).convert('RGBA')
    layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)

    pts_flat = [(int(p[0]), int(p[1])) for p in points]
    rgba = tuple(int(c) for c in color) + (int(255 * opacity),)

    if fill:
        draw.polygon(pts_flat, fill=rgba)
    else:
        draw.polygon(pts_flat, outline=rgba, width=int(thickness))

    result = Image.alpha_composite(pil_img, layer)
    return np.array(result.convert('RGB'))


# =============================================================================
# PRIMITIVES Export
# =============================================================================

PRIMITIVES = {
    # Font management
    'make-font': prim_make_font,
    'list-fonts': prim_list_fonts,
    'font-size': prim_font_size,

    # Text measurement
    'text-size': prim_text_size,
    'text-metrics': prim_text_metrics,
    'fit-text-size': prim_fit_text_size,
    'fit-font': prim_fit_font,

    # Text drawing
    'text': prim_text,
    'text-box': prim_text_box,

    # Legacy text (compatibility)
    'draw-char': prim_draw_char,
    'draw-text': prim_draw_text,

    # Rectangles
    'fill-rect': prim_fill_rect,
    'draw-rect': prim_draw_rect,

    # Lines and shapes
    'draw-line': prim_draw_line,
    'draw-circle': prim_draw_circle,
    'draw-ellipse': prim_draw_ellipse,
    'draw-polygon': prim_draw_polygon,
}
