import io
import logging
import os
import re
import zipfile

import bleach
import magic
from defusedxml import ElementTree as DefusedET
from django.core.exceptions import ValidationError
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _

from django.conf import settings

from .clamav import scan_stream_optional
from .exceptions import MaliciousFile, MissingPackageManifest
from .registry import DEFAULT_MAX_SIZE_MB, FILE_TYPE_REGISTRY, MODULE_SIZE_LIMITS

logger = logging.getLogger(__name__)


SVG_ALLOWED_TAGS = {
    "svg", "g", "path", "circle", "rect", "line", "polyline", "polygon",
    "ellipse", "text", "tspan", "defs", "use", "title", "desc", "style",
    "linearGradient", "radialGradient", "stop", "clipPath", "mask",
    "pattern", "symbol", "marker", "image", "filter", "feGaussianBlur",
    "feOffset", "feMerge", "feMergeNode", "feColorMatrix", "feFlood",
    "feComposite", "feBlend",
}

# Attribute allowlist intentionally excludes any ``on*`` event handlers.
SVG_ALLOWED_ATTRS = {
    "*": [
        "id", "class", "style", "transform", "fill", "stroke", "stroke-width",
        "stroke-linecap", "stroke-linejoin", "stroke-dasharray", "stroke-opacity",
        "fill-opacity", "fill-rule", "opacity", "color", "viewBox", "preserveAspectRatio",
        "width", "height", "x", "y", "x1", "x2", "y1", "y2", "cx", "cy", "r", "rx", "ry",
        "d", "points", "offset", "stop-color", "stop-opacity", "gradientUnits",
        "gradientTransform", "spreadMethod", "patternUnits", "patternTransform",
        "clipPathUnits", "maskUnits", "filterUnits", "primitiveUnits",
        "in", "in2", "result", "stdDeviation", "dx", "dy", "values", "type",
        "operator", "k1", "k2", "k3", "k4", "mode", "flood-color", "flood-opacity",
        "font-family", "font-size", "font-weight", "font-style", "text-anchor",
        "dominant-baseline", "xmlns", "xmlns:xlink", "version",
    ],
    "use": ["href", "xlink:href"],
    "image": ["href", "xlink:href"],
    "a": ["href", "xlink:href"],
}

_DANGEROUS_HREF_PREFIXES = ("javascript:", "data:text/html", "vbscript:")

# Tags whose entire body must be removed (not just the wrapping element). bleach's
# ``strip=True`` only drops the tag itself and keeps inner text, which would let
# script payloads leak through as plain text. Pre-strip these elements first.
_KILL_TAGS_BODY = ("script", "foreignObject", "iframe", "object", "embed", "handler", "set", "animate", "animateMotion")
_KILL_BODY_RE = re.compile(
    r"<\s*(" + "|".join(_KILL_TAGS_BODY) + r")\b[^>]*>.*?<\s*/\s*\1\s*>",
    flags=re.IGNORECASE | re.DOTALL,
)
_KILL_SELF_CLOSING_RE = re.compile(
    r"<\s*(" + "|".join(_KILL_TAGS_BODY) + r")\b[^>]*/\s*>",
    flags=re.IGNORECASE,
)


def _href_filter(tag, name, value):
    """Block javascript:/vbscript: hrefs but allow #fragment and http(s) links."""
    if name in ("href", "xlink:href"):
        v = (value or "").strip().lower()
        if v.startswith(_DANGEROUS_HREF_PREFIXES):
            return False
    if name.lower().startswith("on"):
        return False
    allowed = SVG_ALLOWED_ATTRS.get(tag, []) + SVG_ALLOWED_ATTRS.get("*", [])
    return name in allowed


def _sanitise_svg(raw: str) -> str:
    """Sanitise SVG markup: defusedxml parse + bleach allowlist scrub.

    Raises ``MaliciousFile`` only when the input cannot be parsed as XML at all
    (e.g. binary masquerading as SVG). All other concerns — embedded script
    tags, ``on*`` event handlers, ``javascript:`` hrefs — are stripped silently
    so the upload still succeeds with safe markup.
    """
    if not raw or not raw.strip():
        return ""
    # Step 1 — run a defusedxml parse purely as an XXE / billion-laughs /
    # external-entity guard. Generic XML errors (unbound prefixes, malformed
    # markup) are NOT a security issue — bleach scrubs dangerous content
    # regardless. Only the explicit "forbidden construct" exceptions block
    # the upload.
    try:
        DefusedET.fromstring(raw.encode("utf-8") if isinstance(raw, str) else raw)
    except (DefusedET.EntitiesForbidden, DefusedET.DTDForbidden, DefusedET.ExternalReferenceForbidden) as exc:
        raise MaliciousFile("SVG contains forbidden XML constructs.") from exc
    except DefusedET.ParseError:
        # Loose markup: keep going. bleach.clean() handles HTML-fragment
        # markup and will strip anything that's not on the allowlist.
        pass

    # Step 2 — pre-strip dangerous element bodies. bleach's strip=True keeps
    # inner text of removed tags, so a leftover ``alert(1)`` text node would
    # survive. Remove the entire <script>...</script> region first.
    pre = _KILL_BODY_RE.sub("", raw)
    pre = _KILL_SELF_CLOSING_RE.sub("", pre)

    # Step 3 — bleach with SVG-tuned allowlist + custom attribute filter.
    cleaned = bleach.clean(
        pre,
        tags=SVG_ALLOWED_TAGS,
        attributes=_href_filter,
        protocols=["http", "https", "mailto"],
        strip=True,
        strip_comments=True,
    )
    return cleaned


class FileSizeValidator:
    def __init__(self, max_mb: float):
        self.max_mb = max_mb
        self.max_bytes = int(max_mb * 1024 * 1024)

    def __call__(self, file):
        if file.size > self.max_bytes:
            raise ValidationError(
                _("File size %(size).1f MB exceeds the %(max).0f MB limit.")
                % {"size": file.size / (1024 * 1024), "max": self.max_mb}
            )


class MagicBytesValidator:
    ELEARNING_EXTENSIONS = {".scorm", ".h5p"}
    SCORM_MANIFEST = "imsmanifest.xml"
    H5P_MANIFEST = "h5p.json"

    def __init__(self, extension: str, expected_mimes: list[str]):
        self.extension = extension.lower()
        self.expected_mimes = expected_mimes

    def __call__(self, file):
        file.seek(0)
        detected = magic.from_buffer(file.read(2048), mime=True)
        file.seek(0)

        if self.extension in self.ELEARNING_EXTENSIONS:
            if detected not in ("application/zip", "application/x-zip-compressed"):
                raise MaliciousFile(f"Expected zip archive for {self.extension}, got {detected}.")
            self._validate_elearning_manifest(file)
            return

        if self.extension == ".svg":
            raw_bytes = file.read()
            file.seek(0)
            content = raw_bytes.decode("utf-8", errors="ignore")
            cleaned = _sanitise_svg(content)
            # Replace the in-memory file contents with the sanitised markup so
            # downstream storage receives safe bytes.
            cleaned_bytes = cleaned.encode("utf-8")
            try:
                file.truncate(0)
                file.seek(0)
                file.write(cleaned_bytes)
                file.seek(0)
            except (AttributeError, OSError, io.UnsupportedOperation):
                # Some upload backends expose read-only buffers; swap to a
                # fresh BytesIO that callers will see on the next read.
                if hasattr(file, "file"):
                    try:
                        file.file = io.BytesIO(cleaned_bytes)
                    except Exception:  # noqa: BLE001 — best-effort
                        logger.warning("Could not rewrite sanitised SVG bytes onto upload object")
            return

        if detected not in self.expected_mimes:
            if detected == "text/plain" and any(m.startswith("text/") for m in self.expected_mimes):
                return
            raise MaliciousFile(
                _("Detected content type '%(detected)s' does not match %(ext)s.")
                % {"detected": detected, "ext": self.extension}
            )

    def _validate_elearning_manifest(self, file):
        required = self.SCORM_MANIFEST if self.extension == ".scorm" else self.H5P_MANIFEST
        try:
            with zipfile.ZipFile(file, "r") as zf:
                names = [n.lower() for n in zf.namelist()]
                if not any(required in name for name in names):
                    raise MissingPackageManifest(f"Package missing required manifest: {required}.")
        except zipfile.BadZipFile as exc:
            raise MaliciousFile("File claims to be zip but could not be opened.") from exc
        finally:
            file.seek(0)


@deconstructible
class FileValidator:
    def __init__(self, module: str, allowed_categories: list[str] | None = None):
        self.module = module
        self.allowed_categories = allowed_categories

    def __eq__(self, other):
        return (
            isinstance(other, FileValidator)
            and self.module == other.module
            and self.allowed_categories == other.allowed_categories
        )

    def __hash__(self):
        return hash(
            (
                self.__class__.__qualname__,
                self.module,
                tuple(self.allowed_categories) if self.allowed_categories is not None else None,
            )
        )

    def __call__(self, file):
        # Use a throwaway name (not ``_``) so we don't shadow the
        # ``gettext_lazy as _`` alias used by the ValidationError messages
        # below — Python unpacking would clobber it within this scope.
        _stem, ext = os.path.splitext(file.name.lower())
        if file.name.lower().endswith(".tar.gz"):
            ext = ".tar.gz"

        config = FILE_TYPE_REGISTRY.get(ext)
        if config is None:
            raise ValidationError(_("File type '%(ext)s' is not supported.") % {"ext": ext})

        if self.allowed_categories and config.category not in self.allowed_categories:
            raise ValidationError(
                _("%(category)s files are not accepted here. Allowed: %(allowed)s.")
                % {"category": config.category, "allowed": ", ".join(self.allowed_categories)}
            )

        module_max = MODULE_SIZE_LIMITS.get(self.module, DEFAULT_MAX_SIZE_MB)
        FileSizeValidator(min(module_max, config.max_size_mb))(file)

        try:
            MagicBytesValidator(ext, config.mime_types)(file)
        except (MaliciousFile, MissingPackageManifest) as exc:
            raise ValidationError(str(exc)) from exc

        if (
            getattr(settings, "CLAMD_ENABLED", True)
            and getattr(settings, "CLAMD_TCP_HOST", "")
        ):
            scan_stream_optional(file)
