"""DrawingML <p:pic> -> SVG <image> conversion.

Reverse of svg_to_pptx convert_image.

DrawingML structure:
    <p:pic>
        <p:blipFill>
            <a:blip r:embed="rIdRaster">
                <a:extLst>...<asvg:svgBlip r:embed="rIdSvg"/>...</a:extLst>
            </a:blip>
            <a:srcRect l/t/r/b="1/100000"/>      (optional crop)
            <a:stretch><a:fillRect/></a:stretch> (default: fill the shape)
        </p:blipFill>
        <p:spPr>
            <a:xfrm/>
            <a:prstGeom prst="rect"/>            (usually rect; can be other)
        </p:spPr>
    </p:pic>

Strategy:
- Prefer the editable SVG relationship in asvg:svgBlip when Office also stores
  a raster compatibility preview on a:blip; retain the raster as fallback.
- Default (no srcRect, plain stretch) -> a single <image> filling the box,
  preserveAspectRatio="none".
- With srcRect, or with a single oversized tile that covers the frame -> wrap
  the <image> in a nested <svg viewBox> in the unit rectangle [0,1] x [0,1],
  with overflow hidden so cropping is expressed identically in browsers and
  PowerPoint.
- Repeating tile fills still use the legacy plain-image fallback; a repeated
  pattern cannot be represented by the project's native picture-crop subset.
- Image bytes are written through the result; the slide assembler decides
  the href format (external file vs base64).
"""

from __future__ import annotations

import base64
import hashlib
import io
import math
import mimetypes
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from xml.etree import ElementTree as ET

try:
    from PIL import Image, ImageEnhance
except ImportError:  # pragma: no cover - optional visual enhancement dependency
    Image = None
    ImageEnhance = None

from .emu_units import NS, Xfrm, emu_to_px, fmt_num, format_ooxml_alpha
from .ooxml_loader import OoxmlPackage, PartRef, blip_embed_relationship_ids


@dataclass(frozen=True)
class PictureDiagnostic:
    """Recoverable loss while converting one DrawingML picture."""

    code: str
    message: str
    fallback: str


@dataclass
class PictureResult:
    """Resolved picture: SVG element string + extracted media bytes."""

    svg: str = ""
    # Map of {filename: bytes} that the assembler should emit alongside
    # the SVG. Filename is the basename inside the package's media dir.
    media: dict[str, bytes] = field(default_factory=dict)
    diagnostics: tuple[PictureDiagnostic, ...] = ()


class MediaResolutionError(RuntimeError):
    """Raised when a PPTX media relationship cannot be reproduced as SVG."""


def convert_blip_fill(
    blip_fill_elem: ET.Element,
    xfrm: Xfrm,
    slide_part: PartRef,
    pkg: OoxmlPackage,
    *,
    media_subdir: str = "assets",
    embed_inline: bool = False,
    asset_name_map: dict[str, str] | None = None,
    strict: bool = False,
) -> PictureResult:
    """Convert an <a:blipFill> element to SVG <image>.

    Handles image fill for both:
    - <p:pic><p:blipFill> (standard picture elements)
    - <p:sp><p:spPr><a:blipFill> (shape with image fill, e.g. Canva exports)
    """
    blip = blip_fill_elem.find("a:blip", NS)
    if blip is None:
        return PictureResult()

    relationship_ids = blip_embed_relationship_ids(blip)
    linked_rid = blip.attrib.get(f"{{{NS['r']}}}link")
    if not relationship_ids:
        if linked_rid:
            raise MediaResolutionError(
                "Linked image relationships are not supported; embed the image in PowerPoint first"
            )
        return PictureResult()

    target: str | None = None
    img_bytes: bytes | None = None
    failures: list[str] = []
    for rel_id in relationship_ids:
        candidate = slide_part.resolve_rel(rel_id)
        if not candidate:
            failures.append(f"{rel_id}: unresolved")
            continue
        candidate_bytes = pkg.read_media(candidate)
        if candidate_bytes is None:
            failures.append(f"{rel_id}: missing {candidate}")
            continue
        if Path(candidate).suffix.lower() == ".svg" and not _is_valid_svg_media(candidate_bytes):
            failures.append(f"{rel_id}: invalid SVG media {candidate}")
            continue
        target = candidate
        img_bytes = candidate_bytes
        break
    if target is None or img_bytes is None:
        details = "; ".join(failures)
        raise MediaResolutionError(
            f"No embedded image relationship can be read in {slide_part.path}: {details}"
        )

    filename = (asset_name_map or {}).get(target, pkg.media_filename(target))
    filename, img_bytes = _normalize_office_media(filename, img_bytes)
    tile_source_bytes = img_bytes
    diagnostics = list(_unsupported_blip_effect_diagnostics(blip))
    filename, img_bytes, effect_diagnostics = _apply_blip_image_effects(
        filename,
        img_bytes,
        blip,
    )
    diagnostics.extend(effect_diagnostics)
    opacity_attr, opacity_diagnostics = _blip_opacity_attr(blip)
    diagnostics.extend(opacity_diagnostics)
    if strict and diagnostics:
        details = "; ".join(item.message for item in diagnostics)
        raise ValueError(f"Cannot reproduce DrawingML picture effects: {details}")
    href = _build_href(filename, img_bytes, media_subdir, embed_inline)

    # srcRect: l/t/r/b in 1/100000ths (so 50000 = 50%).
    src_rect = blip_fill_elem.find("a:srcRect", NS)
    crop = _parse_src_rect(src_rect)

    # A tile larger than its frame is visually a crop, not a stretch. Preserve
    # that common PowerPoint background construction through the same nested
    # SVG transport used for srcRect. True repeated patterns retain the legacy
    # plain-image fallback because native export has no registered tile subset.
    tile = blip_fill_elem.find("a:tile", NS)
    if crop is None and tile is not None:
        crop = _single_cover_tile_crop(
            tile,
            blip_fill_elem,
            xfrm,
            tile_source_bytes,
        )

    if crop is None:
        # Plain unclipped image
        svg = (
            f'<image href="{href}" x="{fmt_num(xfrm.x)}" y="{fmt_num(xfrm.y)}" '
            f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
            f'preserveAspectRatio="none"{opacity_attr}/>'
        )
    else:
        # Crop expressed as a unit-rectangle viewBox on a nested <svg>.
        vb_l, vb_t, vb_w, vb_h = crop
        svg = (
            f'<svg x="{fmt_num(xfrm.x)}" y="{fmt_num(xfrm.y)}" '
            f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
            f'viewBox="{fmt_num(vb_l, 5)} {fmt_num(vb_t, 5)} '
            f'{fmt_num(vb_w, 5)} {fmt_num(vb_h, 5)}" '
            f'preserveAspectRatio="none" overflow="hidden">'
            f'<image href="{href}" x="0" y="0" width="1" height="1" '
            f'preserveAspectRatio="none"{opacity_attr}/>'
            f"</svg>"
        )

    media: dict[str, bytes] = {}
    if not embed_inline:
        media[filename] = img_bytes
    return PictureResult(
        svg=svg,
        media=media,
        diagnostics=tuple(diagnostics),
    )


def convert_picture(
    pic_elem: ET.Element,
    xfrm: Xfrm,
    slide_part: PartRef,
    pkg: OoxmlPackage,
    *,
    media_subdir: str = "assets",
    embed_inline: bool = False,
    asset_name_map: dict[str, str] | None = None,
    strict: bool = False,
) -> PictureResult:
    """Translate <p:pic> to SVG <image> (or nested <svg>+<image> for cropping)."""
    blip_fill = pic_elem.find("p:blipFill", NS)
    if blip_fill is None:
        return PictureResult()

    return convert_blip_fill(
        blip_fill, xfrm, slide_part, pkg,
        media_subdir=media_subdir,
        embed_inline=embed_inline,
        asset_name_map=asset_name_map,
        strict=strict,
    )


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _blip_opacity_attr(
    blip: ET.Element,
) -> tuple[str, tuple[PictureDiagnostic, ...]]:
    """Translate DrawingML fixed image alpha to an SVG opacity attribute."""
    alpha_effects = blip.findall("a:alphaModFix", NS)
    if not alpha_effects:
        return "", ()
    if len(alpha_effects) > 1:
        return "", (
            _effect_diagnostic(
                "duplicate a:alphaModFix effects cannot be reproduced safely"
            ),
        )
    alpha = alpha_effects[0]
    try:
        opacity = float(alpha.attrib.get("amt", "100000")) / 100000.0
    except ValueError:
        return "", (
            _effect_diagnostic(
                f"invalid a:alphaModFix amt={alpha.attrib.get('amt')!r}"
            ),
        )
    if not math.isfinite(opacity) or not 0.0 <= opacity <= 1.0:
        return "", (
            _effect_diagnostic(
                f"out-of-range a:alphaModFix amt={alpha.attrib.get('amt')!r}"
            ),
        )
    if opacity >= 1.0:
        return "", ()
    return f' opacity="{format_ooxml_alpha(opacity)}"', ()


def _effect_diagnostic(message: str) -> PictureDiagnostic:
    return PictureDiagnostic(
        code="image-effect-omitted",
        message=message,
        fallback="retain the source image and omit only this image effect",
    )


def _unsupported_blip_effect_diagnostics(
    blip: ET.Element,
) -> tuple[PictureDiagnostic, ...]:
    """Report direct a:blip effects outside the implemented subset."""
    supported_tags = {
        f"{{{NS['a']}}}lum",
        f"{{{NS['a']}}}alphaModFix",
        f"{{{NS['a']}}}extLst",
    }
    unsupported = sorted({
        child.tag.rsplit("}", 1)[-1]
        for child in blip
        if child.tag not in supported_tags
    })
    diagnostics = (
        [
            _effect_diagnostic(
                "unsupported direct a:blip effect(s): "
                + ", ".join(f"a:{name}" for name in unsupported)
            )
        ]
        if unsupported
        else []
    )
    if len(blip.findall("a:lum", NS)) > 1:
        diagnostics.append(
            _effect_diagnostic(
                "duplicate a:lum effects cannot be reproduced safely"
            )
        )
    return tuple(diagnostics)

_OFFICE_VECTOR_EXTS = {".emf", ".wmf"}


def _is_valid_svg_media(data: bytes) -> bool:
    """Return whether one media part is a namespaced SVG document."""
    try:
        root = ET.fromstring(data)
    except ET.ParseError:
        return False
    return root.tag == "{http://www.w3.org/2000/svg}svg"


def _normalize_office_media(filename: str, img_bytes: bytes) -> tuple[str, bytes]:
    """Convert Office-only vector image formats to browser-renderable PNG.

    PPTX can contain EMF/WMF assets that PowerPoint renders natively but SVG
    viewers generally do not. Keep the original asset in the manifest layer;
    the SVG view uses a PNG preview when the local system can make one.
    """
    suffix = Path(filename).suffix.lower()
    if suffix not in _OFFICE_VECTOR_EXTS:
        return filename, img_bytes

    converted = _convert_office_vector_to_png(filename, img_bytes)
    if converted is None:
        return filename, img_bytes
    stem = Path(filename).stem
    return f"{stem}_preview.png", converted


def _convert_office_vector_to_png(filename: str, img_bytes: bytes) -> bytes | None:
    magick = shutil.which("magick")
    if not magick:
        return None
    suffix = Path(filename).suffix.lower() or ".bin"
    with tempfile.TemporaryDirectory() as tmp:
        tmp_dir = Path(tmp)
        src = tmp_dir / f"source{suffix}"
        dst = tmp_dir / "preview.png"
        src.write_bytes(img_bytes)
        try:
            subprocess.run(
                [magick, str(src), str(dst)],
                check=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        except (OSError, subprocess.CalledProcessError):
            return None
        if not dst.exists():
            return None
        return dst.read_bytes()

def _parse_src_rect(elem: ET.Element | None) -> tuple[float, float, float, float] | None:
    """Convert <a:srcRect l t r b="1/100000"/> to (x, y, w, h) in unit space."""
    if elem is None:
        return None
    if not (elem.attrib.keys() & {"l", "t", "r", "b"}):
        return None
    l = _pct_attr(elem, "l")
    t = _pct_attr(elem, "t")
    r = _pct_attr(elem, "r")
    b = _pct_attr(elem, "b")
    # All zero -> equivalent to no crop
    if l == 0 and t == 0 and r == 0 and b == 0:
        return None
    vb_x = l
    vb_y = t
    vb_w = max(0.0, 1.0 - l - r)
    vb_h = max(0.0, 1.0 - t - b)
    if vb_w <= 0 or vb_h <= 0:
        return None
    return vb_x, vb_y, vb_w, vb_h


def _single_cover_tile_crop(
    tile: ET.Element,
    blip_fill: ET.Element,
    xfrm: Xfrm,
    img_bytes: bytes,
) -> tuple[float, float, float, float] | None:
    """Map one oversized DrawingML tile to a unit-image crop window.

    The closed SVG/PPTX crop transport cannot express a repeating pattern. It
    can, however, reproduce a tile when the aligned first tile alone covers the
    complete frame. This is how PowerPoint commonly stores full-page bitmap
    backgrounds without distorting their aspect ratio.
    """
    if Image is None or xfrm.w <= 0 or xfrm.h <= 0:
        return None
    if tile.attrib.get("flip", "none") != "none":
        return None

    natural_size = _image_size_at_96_dpi(img_bytes, blip_fill)
    if natural_size is None:
        return None
    natural_w, natural_h = natural_size
    scale_x = _pct_attr_default(tile, "sx", 1.0)
    scale_y = _pct_attr_default(tile, "sy", 1.0)
    tile_w = natural_w * scale_x
    tile_h = natural_h * scale_y
    if tile_w <= 0 or tile_h <= 0:
        return None

    align_x, align_y = _tile_alignment(tile.attrib.get("algn", "tl"))
    tile_x = (
        xfrm.x
        + (xfrm.w - tile_w) * align_x
        + emu_to_px(tile.attrib.get("tx"))
    )
    tile_y = (
        xfrm.y
        + (xfrm.h - tile_h) * align_y
        + emu_to_px(tile.attrib.get("ty"))
    )

    tolerance = 1e-4
    if (
        tile_x > xfrm.x + tolerance
        or tile_y > xfrm.y + tolerance
        or tile_x + tile_w < xfrm.x + xfrm.w - tolerance
        or tile_y + tile_h < xfrm.y + xfrm.h - tolerance
    ):
        return None

    crop = (
        (xfrm.x - tile_x) / tile_w,
        (xfrm.y - tile_y) / tile_h,
        xfrm.w / tile_w,
        xfrm.h / tile_h,
    )
    if all(
        abs(actual - expected) <= 1e-7
        for actual, expected in zip(crop, (0.0, 0.0, 1.0, 1.0))
    ):
        return None
    return crop


def _image_size_at_96_dpi(
    img_bytes: bytes,
    blip_fill: ET.Element,
) -> tuple[float, float] | None:
    """Return the bitmap's physical size in the SVG canvas' 96-DPI pixels."""
    try:
        with Image.open(io.BytesIO(img_bytes)) as image:
            pixel_w, pixel_h = image.size
            embedded_dpi = image.info.get("dpi")
    except (OSError, ValueError):
        return None

    configured_dpi = _positive_float(blip_fill.attrib.get("dpi"))
    if configured_dpi is not None:
        dpi_x = dpi_y = configured_dpi
    else:
        dpi_x, dpi_y = _dpi_pair(embedded_dpi)
    return pixel_w * 96.0 / dpi_x, pixel_h * 96.0 / dpi_y


def _dpi_pair(value: object) -> tuple[float, float]:
    if isinstance(value, (tuple, list)) and len(value) >= 2:
        dpi_x = _positive_float(value[0]) or 96.0
        dpi_y = _positive_float(value[1]) or 96.0
        return dpi_x, dpi_y
    dpi = _positive_float(value) or 96.0
    return dpi, dpi


def _positive_float(value: object) -> float | None:
    try:
        parsed = float(value)
    except (TypeError, ValueError):
        return None
    return parsed if parsed > 0 else None


def _pct_attr_default(elem: ET.Element, name: str, default: float) -> float:
    value = elem.attrib.get(name)
    if value is None:
        return default
    try:
        return float(value) / 100000.0
    except ValueError:
        return default


def _tile_alignment(value: str) -> tuple[float, float]:
    alignments = {
        "tl": (0.0, 0.0),
        "t": (0.5, 0.0),
        "tr": (1.0, 0.0),
        "l": (0.0, 0.5),
        "ctr": (0.5, 0.5),
        "r": (1.0, 0.5),
        "bl": (0.0, 1.0),
        "b": (0.5, 1.0),
        "br": (1.0, 1.0),
    }
    return alignments.get(value, alignments["tl"])


def _apply_blip_image_effects(
    filename: str,
    img_bytes: bytes,
    blip: ET.Element,
) -> tuple[str, bytes, tuple[PictureDiagnostic, ...]]:
    """Bake supported DrawingML blip effects into extracted image bytes.

    Brightness and contrast are pixel operations, not picture-shape
    shadow/glow effects, so preserve them in the extracted bitmap.
    """
    lum_effects = blip.findall("a:lum", NS)
    if not lum_effects:
        return filename, img_bytes, ()
    if len(lum_effects) > 1:
        return filename, img_bytes, ()
    lum = lum_effects[0]

    values: dict[str, float | None] = {}
    for name in ("bright", "contrast"):
        raw_value = lum.attrib.get(name)
        if raw_value is None:
            values[name] = None
            continue
        try:
            value = float(raw_value) / 100000.0
        except ValueError:
            return filename, img_bytes, (
                _effect_diagnostic(f"invalid a:lum {name}={raw_value!r}"),
            )
        if not math.isfinite(value) or not -1.0 <= value <= 1.0:
            return filename, img_bytes, (
                _effect_diagnostic(
                    f"out-of-range a:lum {name}={raw_value!r}"
                ),
            )
        values[name] = value
    bright = values["bright"]
    contrast = values["contrast"]
    if bright is None and contrast is None:
        return filename, img_bytes, ()
    if Image is None or ImageEnhance is None:
        return filename, img_bytes, (
            _effect_diagnostic(
                "a:lum requires Pillow, but Pillow is unavailable"
            ),
        )

    try:
        with Image.open(io.BytesIO(img_bytes)) as source_image:
            if getattr(source_image, "is_animated", False):
                return filename, img_bytes, (
                    _effect_diagnostic(
                        "a:lum on an animated image would flatten its frames"
                    ),
                )
            output_format = (
                source_image.format or _pil_format_from_filename(filename)
            )
            image = source_image.copy()
        if image.mode not in ("RGB", "RGBA"):
            image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
        if bright is not None:
            image = ImageEnhance.Brightness(image).enhance(max(0.0, 1.0 + bright))
        if contrast is not None:
            image = ImageEnhance.Contrast(image).enhance(max(0.0, 1.0 + contrast))

        out = io.BytesIO()
        save_format = output_format or "PNG"
        save_kwargs = {"quality": 95} if save_format.upper() in {"JPEG", "JPG"} else {}
        image.save(out, format=save_format, **save_kwargs)
        effect_key = f"lum-{bright}-{contrast}".encode("ascii")
        digest = hashlib.sha1(effect_key).hexdigest()[:8]
        return (
            _effect_filename(filename, digest, save_format),
            out.getvalue(),
            (),
        )
    except (KeyError, OSError, ValueError) as exc:
        return filename, img_bytes, (
            _effect_diagnostic(f"a:lum could not be rendered: {exc}"),
        )


def _pil_format_from_filename(filename: str) -> str | None:
    ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
    if ext in {"jpg", "jpeg"}:
        return "JPEG"
    if ext == "png":
        return "PNG"
    if ext == "gif":
        return "GIF"
    if ext == "webp":
        return "WEBP"
    return None


def _effect_filename(filename: str, digest: str, image_format: str) -> str:
    stem, sep, ext = filename.rpartition(".")
    if not sep:
        ext = (image_format or "png").lower()
        stem = filename
    if ext.lower() == "jpg":
        ext = "jpeg"
    return f"{stem}_fx_{digest}.{ext}"


def _pct_attr(elem: ET.Element, name: str) -> float:
    val = elem.attrib.get(name)
    if val is None:
        return 0.0
    try:
        return float(val) / 100000.0
    except ValueError:
        return 0.0


def _build_href(filename: str, img_bytes: bytes, subdir: str, embed: bool) -> str:
    """Build an <image href=...> value (relative path or data URI).

    The path is relative to the SVG file's location. The slide assembler writes
    SVGs to <output>/svg/, so media files in <output>/<subdir>/ resolve via
    a leading "../".
    """
    if embed:
        mime = (
            mimetypes.guess_type(filename)[0]
            or _sniff_mime(img_bytes)
            or "application/octet-stream"
        )
        encoded = base64.b64encode(img_bytes).decode("ascii")
        return f"data:{mime};base64,{encoded}"
    rel = f"../{subdir}/{filename}" if subdir else f"../{filename}"
    return rel


def _sniff_mime(data: bytes) -> str | None:
    """Best-effort MIME sniffing for embedded images."""
    if data.startswith(b"\x89PNG\r\n\x1a\n"):
        return "image/png"
    if data.startswith(b"\xff\xd8\xff"):
        return "image/jpeg"
    if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
        return "image/gif"
    if data.startswith(b"<svg") or data.startswith(b"<?xml"):
        return "image/svg+xml"
    if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
        return "image/webp"
    return None
