#!/usr/bin/env python3
"""
icon2img — shared engine for turning a 24x24 icon/logo SVG into a square
raster (or vector) image suitable for a profile picture / avatar.

This module is used by the thin CLI wrappers `lucide2img.py` and
`simpleicons2img.py`. Each wrapper only describes its icon *source*
(URL, how to recolor, how to slugify names) via an `IconSource` and then
calls `run(source)`.

Two recolor modes are supported because the two libraries draw differently:
  - "stroke": Lucide icons are outline strokes  -> we set the SVG `stroke`.
  - "fill":   Simple Icons are solid shapes      -> we set the SVG `fill`.
"""

from __future__ import annotations

import argparse
import io
import re
import urllib.error
import urllib.request
from dataclasses import dataclass

# --------------------------------------------------------------------------
# Formats
# --------------------------------------------------------------------------
# Rasterized via Pillow (name -> PIL format string).
# "svg" (vector passthrough) and "png" (cairosvg native) are handled specially.
PIL_FORMATS = {
    "jpg": "JPEG", "jpeg": "JPEG", "webp": "WEBP", "bmp": "BMP",
    "tiff": "TIFF", "tif": "TIFF", "gif": "GIF", "ico": "ICO",
}
NO_ALPHA = {"JPEG", "BMP"}  # formats that can't keep transparency
ALL_FORMATS = ["png", "svg"] + sorted(PIL_FORMATS)


# --------------------------------------------------------------------------
# Source description
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class IconSource:
    key: str                 # short id, e.g. "lucide"
    prog_name: str           # CLI program name, e.g. "lucide2img"
    description: str         # one-line CLI description
    url_template: str        # str with a single {name} placeholder
    render_mode: str         # "stroke" or "fill"
    browse_url: str          # where to find valid icon names
    icon_arg_help: str       # help text for the positional icon argument
    slug_style: str = "kebab"        # "kebab" (spaces->'-') or "compact" (strip spaces)
    default_stroke_width: float = 2.0
    not_found_hint: str = "check the name"


def normalize_name(name: str, style: str) -> str:
    n = name.strip().lower()
    if style == "compact":            # Simple Icons slugs remove spaces
        return re.sub(r"\s+", "", n)
    return re.sub(r"[\s_]+", "-", n)  # Lucide uses kebab-case


# --------------------------------------------------------------------------
# Fetch
# --------------------------------------------------------------------------
def fetch_svg(source: IconSource, name: str) -> str:
    url = source.url_template.format(name=name)
    req = urllib.request.Request(url, headers={"User-Agent": "icon2img/1.0"})
    try:
        with urllib.request.urlopen(req, timeout=20) as resp:
            return resp.read().decode("utf-8")
    except urllib.error.HTTPError as e:
        if e.code == 404:
            raise SystemExit(
                f"error: no {source.key} icon named '{name}'.\n"
                f"       {source.not_found_hint}: {source.browse_url}"
            )
        raise SystemExit(f"error: failed to fetch icon ({e.code} {e.reason})")
    except urllib.error.URLError as e:
        raise SystemExit(f"error: network problem fetching icon: {e.reason}")


# --------------------------------------------------------------------------
# SVG rewriting
# --------------------------------------------------------------------------
def _fmt(x: float) -> str:
    """Compact float formatting for SVG attributes."""
    return f"{x:.4f}".rstrip("0").rstrip(".")


def build_svg(source: IconSource, src: str, res: int, bg: str, fg: str,
              padding: float, radius: float, stroke_width: float | None) -> str:
    """Rewrite a raw 24x24 icon SVG into a padded, colored square canvas."""
    m = re.search(r"<svg\b([^>]*)>(.*)</svg>", src, re.DOTALL | re.IGNORECASE)
    if not m:
        raise SystemExit("error: fetched file did not look like an SVG")
    root_attrs, inner = m.group(1), m.group(2)

    # Recolor: any explicit currentColor becomes the requested foreground.
    inner = inner.replace("currentColor", fg)

    # Padding is expressed in the icon's 24-unit space by growing the viewBox.
    total = 24.0 / (1.0 - 2.0 * padding)
    margin = (total - 24.0) / 2.0
    view_box = f"{_fmt(-margin)} {_fmt(-margin)} {_fmt(total)} {_fmt(total)}"

    transparent_bg = bg.lower() in {"none", "transparent", ""}
    rect = ""
    if not transparent_bg:
        rx = _fmt(radius * total)
        rect = (
            f'<rect x="{_fmt(-margin)}" y="{_fmt(-margin)}" '
            f'width="{_fmt(total)}" height="{_fmt(total)}" '
            f'rx="{rx}" ry="{rx}" fill="{bg}" stroke="none"/>'
        )

    # Paint attributes differ per library.
    if source.render_mode == "stroke":
        if stroke_width is None:
            sw = re.search(r'stroke-width\s*=\s*"([^"]+)"', root_attrs)
            stroke_width = float(sw.group(1)) if sw else source.default_stroke_width
        paint = (f'fill="none" stroke="{fg}" stroke-width="{_fmt(stroke_width)}" '
                 f'stroke-linecap="round" stroke-linejoin="round"')
    else:  # "fill"
        paint = f'fill="{fg}" stroke="none"'

    return (
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{res}" height="{res}" '
        f'viewBox="{view_box}" {paint}>{rect}{inner}</svg>'
    )


# --------------------------------------------------------------------------
# Render
# --------------------------------------------------------------------------
def render(svg: str, res: int, fmt: str, out_path: str, bg: str) -> None:
    if fmt == "svg":
        with open(out_path, "w", encoding="utf-8") as f:
            f.write(svg)
        return

    import cairosvg  # imported lazily so `svg` output needs no cairo

    png_bytes = cairosvg.svg2png(
        bytestring=svg.encode("utf-8"), output_width=res, output_height=res
    )

    if fmt == "png":
        with open(out_path, "wb") as f:
            f.write(png_bytes)
        return

    from PIL import Image

    img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
    pil_fmt = PIL_FORMATS[fmt]

    if pil_fmt in NO_ALPHA:
        from PIL import ImageColor
        flat_bg = "white" if bg.lower() in {"none", "transparent", ""} else bg
        canvas = Image.new("RGB", img.size, ImageColor.getrgb(flat_bg))
        canvas.paste(img, mask=img.split()[3])
        canvas.save(out_path, pil_fmt)
    else:
        img.save(out_path, pil_fmt)


# --------------------------------------------------------------------------
# CLI plumbing
# --------------------------------------------------------------------------
def resolve_format(args_format: str | None, out_path: str | None) -> str:
    if args_format:
        fmt = args_format.lower().lstrip(".")
    elif out_path and "." in out_path.rsplit("/", 1)[-1]:
        fmt = out_path.rsplit(".", 1)[-1].lower()
    else:
        fmt = "png"
    if fmt not in ALL_FORMATS:
        raise SystemExit(
            f"error: unsupported format '{fmt}'. choose from: {', '.join(ALL_FORMATS)}"
        )
    return fmt


def _positive_int(v: str) -> int:
    n = int(v)
    if n <= 0:
        raise argparse.ArgumentTypeError("resolution must be a positive integer")
    return n


def _unit_fraction(lo: float, hi: float):
    def _check(v: str) -> float:
        f = float(v)
        if not (lo <= f <= hi):
            raise argparse.ArgumentTypeError(f"value must be between {lo} and {hi}")
        return f
    return _check


def build_parser(source: IconSource) -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog=source.prog_name,
        description=source.description,
        epilog=f"Browse icon names at {source.browse_url}",
    )
    p.add_argument("icon", help=source.icon_arg_help)
    p.add_argument("-o", "--output", help="output file path (default: <icon>.<format>)")
    p.add_argument("-r", "--resolution", type=_positive_int, default=512,
                   help="square size in pixels (default: 512)")
    p.add_argument("--bg", default="white",
                   help="background color: name, #hex, or 'transparent' (default: white)")
    p.add_argument("--fg", default="black",
                   help="foreground/icon color: name or #hex (default: black)")
    p.add_argument("-f", "--format", choices=ALL_FORMATS,
                   help="output format (default: png, or inferred from -o extension)")
    p.add_argument("--padding", type=_unit_fraction(0.0, 0.45), default=0.18,
                   help="empty margin around the icon, 0-0.45 (default: 0.18)")
    p.add_argument("--radius", type=_unit_fraction(0.0, 0.5), default=0.0,
                   help="corner rounding of background, 0=square .5=circle (default: 0)")
    # Only meaningful for stroke-based icons (Lucide).
    if source.render_mode == "stroke":
        p.add_argument("--stroke-width", type=float, default=None,
                       help=f"override line thickness "
                            f"(default is {source.default_stroke_width})")
    return p


def run(source: IconSource, argv: list[str] | None = None) -> None:
    args = build_parser(source).parse_args(argv)
    stroke_width = getattr(args, "stroke_width", None)

    fmt = resolve_format(args.format, args.output)
    name = normalize_name(args.icon, source.slug_style)
    out_path = args.output or f"{name}.{fmt}"

    src = fetch_svg(source, name)
    svg = build_svg(source, src, args.resolution, args.bg, args.fg,
                    args.padding, args.radius, stroke_width)
    render(svg, args.resolution, fmt, out_path, args.bg)

    print(f"saved {out_path}  ({args.resolution}x{args.resolution} {fmt}, "
          f"{source.key}='{name}', bg={args.bg}, fg={args.fg})")
