import colorsys
import json
import logging
import os
import re
import time
import urllib.request
from collections import defaultdict
from threading import Lock

from odoo import http
from odoo.http import request

_logger = logging.getLogger(__name__)

# Rate limiting per widget key
_widget_rate_cache = defaultdict(list)
_widget_rate_lock = Lock()
_WIDGET_RATE_LIMIT = 60
_WIDGET_RATE_WINDOW = 60

# Impression dedup: (config_id, ip) -> last timestamp
_impression_cache = {}
_impression_lock = Lock()
_IMPRESSION_DEDUP_SECONDS = 300

# Google Fonts mapping: selection key → CSS font-family + import URL
_FONT_MAP = {
    "open-sans": ("'Open Sans', sans-serif", "Open+Sans:wght@400;600;700"),
    "roboto": ("'Roboto', sans-serif", "Roboto:wght@400;500;700"),
    "lato": ("'Lato', sans-serif", "Lato:wght@400;700"),
    "montserrat": ("'Montserrat', sans-serif", "Montserrat:wght@400;600;700"),
    "raleway": ("'Raleway', sans-serif", "Raleway:wght@400;600;700"),
    "ubuntu": ("'Ubuntu', sans-serif", "Ubuntu:wght@400;500;700"),
    "merriweather": ("'Merriweather', serif", "Merriweather:wght@400;700"),
    "playfair": ("'Playfair Display', serif", "Playfair+Display:wght@400;700"),
    "pt-sans": ("'PT Sans', sans-serif", "PT+Sans:wght@400;700"),
    "noto-sans": ("'Noto Sans', sans-serif", "Noto+Sans:wght@400;600;700"),
    "source-sans": ("'Source Sans Pro', sans-serif", "Source+Sans+Pro:wght@400;600;700"),
}


def _check_widget_rate_limit(widget_key):
    """Rate limit per widget key. Returns True if allowed."""
    now = time.time()
    with _widget_rate_lock:
        _widget_rate_cache[widget_key] = [
            t for t in _widget_rate_cache[widget_key]
            if now - t < _WIDGET_RATE_WINDOW
        ]
        if len(_widget_rate_cache[widget_key]) >= _WIDGET_RATE_LIMIT:
            return False
        _widget_rate_cache[widget_key].append(now)
        return True


def _should_record_impression(config_id, visitor_ip):
    """Debounce impressions: 1 per config+IP per 5 minutes."""
    now = time.time()
    cache_key = (config_id, visitor_ip)
    with _impression_lock:
        last = _impression_cache.get(cache_key, 0)
        if now - last < _IMPRESSION_DEDUP_SECONDS:
            return False
        _impression_cache[cache_key] = now
        if len(_impression_cache) > 5000:
            cutoff = now - _IMPRESSION_DEDUP_SECONDS
            stale = [k for k, v in _impression_cache.items() if v < cutoff]
            for k in stale:
                del _impression_cache[k]
        return True


# =========================================================================
# Color palette utilities (pure Python, no external deps)
# =========================================================================

def _hex_to_hsl(hex_color):
    """Convert #RRGGBB to (h, s, l) where h in [0,360], s/l in [0,1]."""
    hex_color = hex_color.lstrip("#")
    if len(hex_color) != 6:
        hex_color = "c0392b"  # fallback
    r, g, b = (int(hex_color[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
    h, l, s = colorsys.rgb_to_hls(r, g, b)
    return h * 360, s, l


def _hsl_to_hex(h, s, l):
    """Convert (h [0-360], s [0-1], l [0-1]) to #RRGGBB."""
    s = max(0, min(1, s))
    l = max(0, min(1, l))
    r, g, b = colorsys.hls_to_rgb(h / 360.0, l, s)
    return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"


def generate_palette(hex_color):
    """Generate 9 semantic color shades from a base hex color."""
    h, s, l = _hex_to_hsl(hex_color)
    return {
        "bg": _hsl_to_hex(h, s * 0.08, 0.97),
        "panel_header": _hsl_to_hex(h, s * 0.25, 0.90),
        "border": _hsl_to_hex(h, s * 0.15, 0.82),
        "link": _hsl_to_hex(h, s * 0.75, 0.42),
        "navbar": _hsl_to_hex(h, s * 0.85, 0.28),
        "button": hex_color,
        "heading": _hsl_to_hex(h, s * 0.65, 0.22),
        "text": _hsl_to_hex(h, s * 0.40, 0.18),
        "text_secondary": _hsl_to_hex(h, s * 0.25, 0.45),
    }


def _sanitize_hex(value):
    """Validate and return a hex color, or None if invalid."""
    if not value:
        return None
    value = value.strip()
    if re.match(r"^#[0-9a-fA-F]{6}$", value):
        return value
    return None


def _css_escape(value):
    """Escape a string for safe use in CSS values."""
    if not value:
        return ""
    # Remove anything that could break out of CSS context
    return re.sub(r'[;\{\}\<\>\"\'\\]', '', str(value))


_SCOPE = ".kj-widget-scope"


def _scope_css(css_text, scope=_SCOPE):
    """Prefix all CSS selectors with the scope class.

    Handles: regular selectors, comma-separated selectors, @media,
    @keyframes, body/html/:root replacement.
    """
    result = []
    in_at_rule = 0  # nesting depth of @keyframes / @font-face

    for line in css_text.split("\n"):
        stripped = line.strip()

        # Track @keyframes / @font-face blocks (don't scope their contents)
        if stripped.startswith("@keyframes") or stripped.startswith("@font-face"):
            in_at_rule += 1
            result.append(line)
            continue
        if in_at_rule > 0:
            if "{" in stripped:
                in_at_rule += stripped.count("{") - stripped.count("}")
            elif "}" in stripped:
                in_at_rule -= stripped.count("}")
            result.append(line)
            continue

        # @import / @media / @supports — pass through, don't scope the at-rule itself
        if stripped.startswith("@"):
            result.append(line)
            continue

        # Lines with selectors (contain { but aren't just })
        if "{" in stripped and not stripped.startswith("}"):
            # Extract selector part (before {)
            idx = stripped.index("{")
            selector_part = stripped[:idx]
            rest = stripped[idx:]

            # Split comma-separated selectors
            selectors = [s.strip() for s in selector_part.split(",")]
            scoped = []
            for sel in selectors:
                if not sel:
                    continue
                if sel in ("body", "html"):
                    scoped.append(scope)
                elif sel.startswith(":root"):
                    scoped.append(scope + sel.replace(":root", ""))
                elif sel.startswith("from") or sel.startswith("to") or sel[0:1].isdigit():
                    scoped.append(sel)  # @keyframes percentages
                else:
                    scoped.append(scope + " " + sel)

            result.append(", ".join(scoped) + " " + rest)
        else:
            result.append(line)

    return "\n".join(result)


class AffiliateWidgetAPI(http.Controller):
    """Public API endpoints for the affiliate embeddable widget."""

    @http.route(
        "/api/widget/kj-widget.js",
        type="http",
        auth="public",
        methods=["GET"],
        cors="*",
        csrf=False,
    )
    def serve_widget_js(self, **kwargs):
        """Serve the embeddable widget JavaScript file."""
        js_path = os.path.join(
            os.path.dirname(os.path.dirname(__file__)),
            "static", "src", "js", "kj_widget.js",
        )
        try:
            with open(js_path, "r", encoding="utf-8") as f:
                js_content = f.read()
        except FileNotFoundError:
            return request.make_response("// Widget not found", status=404)

        base_url = request.env["ir.config_parameter"].sudo().get_param("web.base.url")
        js_content = js_content.replace("__KJ_BASE_URL__", base_url)

        return request.make_response(
            js_content,
            headers=[
                ("Content-Type", "application/javascript; charset=utf-8"),
                ("Cache-Control", "public, max-age=3600"),
            ],
        )

    @http.route(
        "/api/widget/config/<string:widget_key>",
        type="http",
        auth="public",
        methods=["GET"],
        cors="*",
        csrf=False,
    )
    def get_widget_config(self, widget_key, **kwargs):
        """Return full widget configuration as JSON. Cached 5 min.

        This allows the JS to fetch config at runtime using only the key,
        so affiliates never need to re-embed when they change settings.
        """
        config = (
            request.env["affiliate.widget.config"]
            .sudo()
            .search(
                [("widget_key", "=", widget_key), ("is_active", "=", True)],
                limit=1,
            )
        )
        if not config:
            return request.make_json_response(
                {"error": "Invalid or inactive widget key"}, status=403,
            )

        base_url = (
            request.env["ir.config_parameter"]
            .sudo()
            .get_param("web.base.url")
        )

        # Build config dict — all settings the JS needs
        data = {
            "key": config.widget_key,
            "mode": config.widget_mode or "overlay",
            "lang": config.language or "de",
            "page": config.landing_page or "/job-offers",
            "base_url": base_url,
            # Overlay settings
            "position": config.position or "bottom-right",
            "color": config.primary_color or "#c0392b",
            "text_color": config.text_color or "#ffffff",
            "btn_style": config.button_style or "pill",
            "overlay_radius": config.overlay_radius or "16",
            "text": config.button_text or "Karriere-Jura",
            "button": config.show_button,
            "logo": config.show_logo,
            "toolbar_height": config.toolbar_height or "44",
            "backdrop": config.backdrop_opacity or "60",
            "border": config.overlay_border,
            "border_color": config.border_color or "#d0d0d0",
            "bg_image": config.bg_image_url or "",
            "bg_opacity": config.bg_image_opacity or "20",
            "nav": config.nav_layout or "hidden",
            "nav_bg": config.nav_bg_color or "#1b2a4a",
            "nav_fc": config.nav_font_color or "#ffffff",
            "footer": config.show_footer,
            "footer_text": config.footer_text or "Powered by Karriere-Jura",
            "footer_bg": config.footer_bg_color or "#f5f5f5",
            "footer_fc": config.footer_font_color or "#999999",
            # Inline settings
            "width": config.inline_width or "100%",
            "height": config.inline_height or "800px",
            "auto_resize": config.inline_auto_resize,
            "border_radius": config.inline_border_radius or "0",
            "topbar": config.inline_show_topbar,
            "show_header": config.inline_show_header,
            "show_logo": config.inline_show_logo,
            "custom_logo_url": config.inline_custom_logo_url or "",
            "show_menu": config.inline_show_menu,
            "menu_layout": config.inline_menu_layout or "horizontal",
            "show_menu_stellenangebote": config.inline_show_menu_stellenangebote,
            "show_menu_firmenprofile": config.inline_show_menu_firmenprofile,
            "show_menu_karriere_tipps": config.inline_show_menu_karriere_tipps,
            "show_menu_actions": config.inline_show_menu_actions,
            # Box settings
            "box_count": config.box_job_count or 5,
            "box_layout": config.box_layout or "vertical",
            "box_target": config.box_link_target or "_blank",
            "box_title": config.box_title or "",
            "box_header_bg": config.box_header_bg or "#c0392b",
            "box_header_text": config.box_header_text or "#ffffff",
            "box_header_fs": config.box_header_font_size or 14,
            "box_body_bg": config.box_body_bg or "#ffffff",
            "box_body_fs": config.box_body_font_size or 12,
            "box_link_color": config.box_link_color or "#0645AD",
            "box_border": config.box_show_border,
            "box_border_color": config.box_border_color or "#dddddd",
            "box_footer_bg": config.box_footer_bg or "#f5f5f5",
            "box_footer_text": config.box_footer_text or "#999999",
            "box_width": config.box_width or "300px",
            "box_height": config.box_height or "auto",
        }

        return request.make_response(
            json.dumps(data),
            headers=[
                ("Content-Type", "application/json; charset=utf-8"),
                ("Cache-Control", "public, max-age=300"),
            ],
        )

    # =================================================================
    # Page Proxy — fetch KJ page HTML with CSS scoping for inline mode
    # =================================================================

    # CSS scoping cache: {cache_key: (scoped_css_str, timestamp)}
    _css_scope_cache = {}
    _CSS_SCOPE_TTL = 300  # 5 minutes

    @http.route(
        "/api/widget/page",
        type="http",
        auth="public",
        methods=["GET"],
        cors="*",
        csrf=False,
    )
    def serve_page(self, path="/job-offers", ref="", **kw):
        """Render a KJ page and return JSON with scoped CSS + HTML.

        The CSS is prefixed with .kj-widget-scope so it won't leak
        into the affiliate's page styles.
        """
        if not ref:
            return request.make_json_response(
                {"error": "Missing ref parameter"}, status=400,
            )

        config = (
            request.env["affiliate.widget.config"]
            .sudo()
            .search(
                [("widget_key", "=", ref), ("is_active", "=", True)],
                limit=1,
            )
        )
        if not config:
            return request.make_json_response(
                {"error": "Invalid widget key"}, status=403,
            )

        # Allow specific auth pages, block backend admin routes
        allowed_web = [
            "/web/login", "/web/signup", "/web/reset_password",
            "/web/recruiter/signup", "/web/applicant/signup",
            "/web/advertiser/signup", "/web/affiliate/signup",
        ]
        blocked = ["/web/", "/admin", "/odoo/", "/api/"]
        if any(path.startswith(p) for p in blocked):
            if not any(path.startswith(a) for a in allowed_web):
                path = "/job-offers"

        # Ensure path starts with /
        if not path.startswith("/"):
            path = "/" + path

        # Internal fetch — same server
        base_url = (
            request.env["ir.config_parameter"]
            .sudo()
            .get_param("web.base.url")
        )
        sep = "&" if "?" in path else "?"
        # Use internal URL (inside Docker: 127.0.0.1:8069) not web.base.url
        # which points to external nginx port not reachable from inside container
        internal_base = "http://127.0.0.1:8069"
        internal_url = f"{internal_base}{path}{sep}embed=1&ref={ref}"

        try:
            req_obj = urllib.request.Request(
                internal_url,
                headers={"User-Agent": "KJWidgetProxy/1.0"},
            )
            with urllib.request.urlopen(req_obj, timeout=15) as resp:
                html = resp.read().decode("utf-8")
        except Exception as e:
            _logger.error(f"Widget page proxy failed for {internal_url}: {e}")
            return request.make_json_response(
                {"error": "Failed to load page"}, status=502,
            )

        # Parse the HTML document
        from html.parser import HTMLParser as _HP
        body_html, css_links, inline_styles, scripts, title = (
            self._parse_page_html(html, base_url)
        )

        # Scope CSS (cached)
        scoped_css = self._get_scoped_css(css_links, inline_styles, base_url)

        result = {
            "html": body_html,
            "css": scoped_css,
            "scripts": scripts,
            "title": title,
            "base_url": base_url,
        }

        return request.make_response(
            json.dumps(result),
            headers=[
                ("Content-Type", "application/json; charset=utf-8"),
                ("Cache-Control", "public, max-age=300"),
            ],
        )

    def _parse_page_html(self, html, base_url):
        """Parse full HTML document, extract body content, CSS links, scripts."""
        # Use a simple approach: split on known tags
        import re as _re

        # Extract <title>
        title_match = _re.search(r"<title[^>]*>(.*?)</title>", html, _re.S)
        title = title_match.group(1).strip() if title_match else ""

        # Extract CSS <link> hrefs
        css_links = []
        for m in _re.finditer(
            r'<link[^>]+rel=["\']stylesheet["\'][^>]+href=["\']([^"\']+)["\']',
            html,
        ):
            href = m.group(1)
            if href.startswith("/"):
                href = base_url + href
            css_links.append(href)
        # Also check href before rel
        for m in _re.finditer(
            r'<link[^>]+href=["\']([^"\']+)["\'][^>]+rel=["\']stylesheet["\']',
            html,
        ):
            href = m.group(1)
            if href.startswith("/"):
                href = base_url + href
            if href not in css_links:
                css_links.append(href)

        # Extract inline <style> blocks
        inline_styles = []
        for m in _re.finditer(r"<style[^>]*>(.*?)</style>", html, _re.S):
            inline_styles.append(m.group(1))

        # Extract <script> srcs (skip inline scripts)
        scripts = []
        for m in _re.finditer(
            r'<script[^>]+src=["\']([^"\']+)["\']', html,
        ):
            src = m.group(1)
            if src.startswith("/"):
                src = base_url + src
            scripts.append(src)

        # Extract <body> content
        body_match = _re.search(
            r"<body[^>]*>(.*)</body>", html, _re.S | _re.I,
        )
        body_html = body_match.group(1) if body_match else html

        return body_html, css_links, inline_styles, scripts, title

    def _get_scoped_css(self, css_links, inline_styles, base_url):
        """Fetch CSS files and scope all selectors under .kj-widget-scope."""
        now = time.time()

        # Cache key based on the CSS links (they're versioned via Odoo assets)
        cache_key = "|".join(sorted(css_links))
        cached = self._css_scope_cache.get(cache_key)
        if cached and (now - cached[1]) < self._CSS_SCOPE_TTL:
            # Add inline styles (they change per page, can't cache)
            extra = "\n".join(
                _scope_css(s) for s in inline_styles
            )
            return cached[0] + "\n" + extra

        # Fetch and scope each CSS file
        internal_base = "http://127.0.0.1:8069"
        parts = []
        for url in css_links:
            # Skip external CSS (Google Fonts, etc.) — include as-is
            if not url.startswith(base_url) and not url.startswith(internal_base):
                parts.append(f'@import url("{url}");')
                continue
            # Use internal URL for fetching
            fetch_url = url.replace(base_url, internal_base)
            try:
                req_obj = urllib.request.Request(
                    fetch_url, headers={"User-Agent": "KJWidgetProxy/1.0"},
                )
                with urllib.request.urlopen(req_obj, timeout=10) as resp:
                    css_text = resp.read().decode("utf-8")
                # Fix relative URLs in CSS (fonts, images) to absolute
                css_text = re.sub(
                    r"url\(\s*['\"]?(/[^)'\"\s]+)['\"]?\s*\)",
                    lambda m: f"url({base_url}{m.group(1)})",
                    css_text,
                )
                parts.append(_scope_css(css_text))
            except Exception as e:
                _logger.warning(f"Failed to fetch CSS {url}: {e}")
                continue

        combined = "\n".join(parts)
        self._css_scope_cache[cache_key] = (combined, now)

        # Add inline styles (scoped, with absolute URLs)
        scoped_inlines = []
        for s in inline_styles:
            s = re.sub(
                r"url\(\s*['\"]?(/[^)'\"\s]+)['\"]?\s*\)",
                lambda m: f"url({base_url}{m.group(1)})",
                s,
            )
            scoped_inlines.append(_scope_css(s))
        extra = "\n".join(scoped_inlines)
        return combined + "\n" + extra

    @http.route(
        "/api/widget/event",
        type="http",
        auth="public",
        methods=["POST", "OPTIONS"],
        cors="*",
        csrf=False,
    )
    def track_event(self, **kwargs):
        """Track widget events (impressions and clicks)."""
        try:
            data = json.loads(request.httprequest.data or "{}")
        except (json.JSONDecodeError, TypeError):
            return request.make_json_response({"error": "Invalid JSON"}, status=400)

        widget_key = data.get("key")
        event_type = data.get("type")
        if not widget_key or event_type not in ("open", "impression", "click"):
            return request.make_json_response(
                {"error": "Missing or invalid parameters"}, status=400,
            )

        if not _check_widget_rate_limit(widget_key):
            return request.make_json_response(
                {"error": "Rate limit exceeded"}, status=429,
            )

        config = (
            request.env["affiliate.widget.config"]
            .sudo()
            .search([("widget_key", "=", widget_key), ("is_active", "=", True)], limit=1)
        )
        if not config:
            return request.make_json_response(
                {"error": "Invalid widget key"}, status=403,
            )

        visitor_ip = request.httprequest.remote_addr or "unknown"

        # Normalize "open" to "impression" for unified tracking
        db_event_type = "impression" if event_type in ("open", "impression") else "click"

        if db_event_type == "impression":
            if _should_record_impression(config.id, visitor_ip):
                request.env["affiliate.widget.event"].sudo().create({
                    "config_id": config.id,
                    "event_type": "impression",
                    "referrer_url": data.get("referrer", ""),
                    "visitor_ip": visitor_ip,
                })
        elif db_event_type == "click":
            job_id = data.get("job_id")
            vals = {
                "config_id": config.id,
                "event_type": "click",
                "referrer_url": data.get("referrer", ""),
                "visitor_ip": visitor_ip,
            }
            if job_id and isinstance(job_id, int):
                vals["job_id"] = job_id
            request.env["affiliate.widget.event"].sudo().create(vals)

        return request.make_json_response({"ok": True})

    @http.route(
        "/api/widget/menu",
        type="http",
        auth="public",
        methods=["GET", "OPTIONS"],
        cors="*",
        csrf=False,
    )
    def get_website_menu(self, **kwargs):
        """Return the website menu tree as JSON for widget sidebar."""
        website = request.env["website"].sudo().search([], limit=1)
        if not website:
            return request.make_response(
                json.dumps({"items": []}),
                headers=[("Content-Type", "application/json; charset=utf-8")],
            )
        menu_tree = request.env["website.menu"].sudo().get_tree(website.id)

        def serialize(node):
            """Recursively serialize menu node to dict."""
            return {
                "name": node["fields"]["name"],
                "url": node["fields"].get("url", ""),
                "children": [serialize(c) for c in node.get("children", [])],
            }

        items = [serialize(c) for c in menu_tree.get("children", [])]
        return request.make_response(
            json.dumps({"items": items}),
            headers=[
                ("Content-Type", "application/json; charset=utf-8"),
                ("Cache-Control", "public, max-age=3600"),
            ],
        )

    # =================================================================
    # Dynamic Theme CSS
    # =================================================================

    @http.route(
        "/api/widget/css/<string:widget_key>/theme.css",
        type="http",
        auth="public",
        methods=["GET"],
        cors="*",
        csrf=False,
    )
    def serve_theme_css(self, widget_key, **kwargs):
        """Serve dynamically generated CSS for affiliate theming."""
        config = (
            request.env["affiliate.widget.config"]
            .sudo()
            .search(
                [("widget_key", "=", widget_key), ("is_active", "=", True)],
                limit=1,
            )
        )
        if not config:
            return request.make_response(
                "/* no config */",
                headers=[
                    ("Content-Type", "text/css; charset=utf-8"),
                    ("Cache-Control", "public, max-age=300"),
                ],
            )

        css = self._generate_theme_css(config)
        return request.make_response(
            css,
            headers=[
                ("Content-Type", "text/css; charset=utf-8"),
                ("Cache-Control", "public, max-age=300"),
            ],
        )

    def _generate_theme_css(self, config):
        """Build CSS string from affiliate's theme configuration."""
        base_color = _sanitize_hex(config.theme_color) or "#c0392b"
        palette = generate_palette(base_color)

        # Use explicit overrides if set, otherwise palette defaults
        bg = _sanitize_hex(config.theme_bg_color) or palette["bg"]
        heading = _sanitize_hex(config.theme_heading_color) or palette["heading"]
        text = _sanitize_hex(config.theme_text_color) or palette["text"]
        link = _sanitize_hex(config.theme_link_color) or palette["link"]
        btn = _sanitize_hex(config.theme_btn_color) or palette["button"]
        border = _sanitize_hex(config.theme_border_color) or palette["border"]
        navbar_c = palette["navbar"]
        panel_h = _sanitize_hex(config.theme_card_header_bg) or palette["panel_header"]
        card_bg = _sanitize_hex(config.theme_card_bg) or "#ffffff"
        input_bg = _sanitize_hex(config.theme_input_bg) or "#ffffff"
        form_bg = _sanitize_hex(config.theme_form_bg) or palette["bg"]
        text_sec = palette["text_secondary"]

        parts = []

        # --- Google Font import ---
        font_key = config.theme_font_family or "system"
        font_css = ""
        if font_key != "system" and font_key in _FONT_MAP:
            font_family, font_import = _FONT_MAP[font_key]
            parts.append(
                f"@import url('https://fonts.googleapis.com/css2?"
                f"family={font_import}&display=swap');"
            )
            font_css = f"font-family: {font_family} !important;"

        font_size = config.theme_font_size or 14
        if font_size < 10:
            font_size = 10
        elif font_size > 24:
            font_size = 24

        # --- Base body ---
        parts.append(f"""
body {{
  background-color: {bg} !important;
  color: {text} !important;
  font-size: {font_size}px !important;
  {font_css}
}}""")

        # --- Headings ---
        parts.append(f"""
h1, h2, h3, h4, h5, h6 {{
  color: {heading} !important;
  {font_css}
}}""")

        # --- Links ---
        parts.append(f"""
a {{ color: {link} !important; }}
a:hover {{ color: {heading} !important; }}""")

        # --- Buttons (all KJ-specific + Bootstrap primary/danger) ---
        # Use body prefix + doubled class for higher specificity to beat KJ SCSS !important
        parts.append(f"""
body .btn-primary.btn-primary,
body .btn-danger.btn-danger,
body .kj-btn-primary,
body .kj-cta-btn {{
  background-color: {btn} !important;
  border-color: {btn} !important;
  color: #fff !important;
}}
body .btn-primary:hover, body .btn-danger:hover,
body .kj-btn-primary:hover, body .kj-cta-btn:hover {{
  background-color: {heading} !important;
  border-color: {heading} !important;
}}
body .btn-outline-primary.btn-outline-primary,
body .btn-outline-danger.btn-outline-danger {{
  color: {btn} !important;
  border-color: {btn} !important;
  background-color: transparent !important;
}}
body .btn-outline-primary:hover,
body .btn-outline-danger:hover {{
  background-color: {btn} !important;
  color: #fff !important;
}}""")

        # --- Navbar ---
        navbar_scheme = config.theme_navbar_scheme or "default"
        if navbar_scheme != "default":
            if navbar_scheme == "light":
                nb_bg, nb_text = "#ffffff", text
            elif navbar_scheme == "dark":
                nb_bg, nb_text = "#212529", "#ffffff"
            else:  # custom
                nb_bg = _sanitize_hex(config.theme_navbar_bg) or navbar_c
                nb_text = _sanitize_hex(config.theme_navbar_text) or "#ffffff"
            parts.append(f"""
.kj-top-bar, .kj-navbar-row,
header .navbar, header nav {{
  background-color: {nb_bg} !important;
  color: {nb_text} !important;
}}
.kj-top-bar a, .kj-navbar-row a,
header .navbar a, header nav a {{
  color: {nb_text} !important;
}}""")

        # --- Site footer ---
        if config.theme_site_footer_visible:
            # Override the widget-mode default that hides footer
            parts.append("footer { display: block !important; height: auto !important; }")
            footer_scheme = config.theme_site_footer_scheme or "default"
            if footer_scheme != "default":
                if footer_scheme == "light":
                    ft_bg, ft_text = "#f8f9fa", text
                elif footer_scheme == "dark":
                    ft_bg, ft_text = "#212529", "#ffffff"
                else:  # custom
                    ft_bg = _sanitize_hex(config.theme_site_footer_bg) or "#f8f9fa"
                    ft_text = _sanitize_hex(config.theme_site_footer_text) or text
                parts.append(f"""
footer {{
  background-color: {ft_bg} !important;
  color: {ft_text} !important;
}}
footer a {{ color: {ft_text} !important; }}""")

        # --- Cards / Panels ---
        parts.append(f"""
.card, .kj-card, .kj-detail-card {{
  background-color: {card_bg} !important;
  border-color: {border} !important;
}}
.card-header, .kj-card-header {{
  background-color: {panel_h} !important;
}}""")

        # --- Forms ---
        parts.append(f"""
.kj-form-container, form.kj-form {{
  background-color: {form_bg} !important;
}}
.form-control, .form-select, input, select, textarea {{
  background-color: {input_bg} !important;
  border-color: {border} !important;
  color: {text} !important;
}}""")

        # --- Borders ---
        parts.append(f"""
.border, hr {{ border-color: {border} !important; }}
.table {{ border-color: {border} !important; }}
.table thead th {{ color: {heading} !important; }}
.table tbody td {{ color: {text_sec} !important; }}""")

        # --- Bootstrap + KJ hardcoded #ce0000 overrides ---
        btn_r, btn_g, btn_b = int(btn[1:3], 16), int(btn[3:5], 16), int(btn[5:7], 16)
        link_r, link_g, link_b = int(link[1:3], 16), int(link[3:5], 16), int(link[5:7], 16)
        parts.append(f"""
.bg-primary.bg-primary {{ background-color: {btn} !important; }}
.bg-danger.bg-danger {{ background-color: {btn} !important; }}
.text-primary.text-primary {{ color: {btn} !important; }}
.text-danger.text-danger {{ color: {btn} !important; }}
.border-primary {{ border-color: {btn} !important; }}
:root {{
  --bs-primary: {btn};
  --bs-primary-rgb: {btn_r}, {btn_g}, {btn_b};
  --bs-danger: {btn};
  --bs-danger-rgb: {btn_r}, {btn_g}, {btn_b};
  --bs-link-color: {link};
  --bs-link-color-rgb: {link_r}, {link_g}, {link_b};
  --bs-link-hover-color: {heading};
}}""")

        # --- Blanket override for KJ hardcoded #ce0000 ---
        # The KJ SCSS has ~140 hardcoded #ce0000 (rgb(206,0,0)) references.
        # We override them all with broad selectors using !important at end of body.
        parts.append(f"""
[class*="kj-"] {{
  --kj-primary: {btn};
}}
body .kj-stelle-finden-btn.kj-stelle-finden-btn,
body .kj-about-btn.kj-about-btn,
body .kj-newsletter-btn.kj-newsletter-btn,
body .kj-about-section .btn,
body .kj-newsletter-section .btn,
body .kj-filter-btn.active,
body .kj-faceted-filter .active,
body .kj-search-chip.active {{
  background-color: {btn} !important;
  border-color: {btn} !important;
  color: #fff !important;
}}
body .kj-action-btn.kj-action-btn,
body .kj-filter-btn.kj-filter-btn,
body .kj-search-chip {{
  color: {btn} !important;
  border-color: {btn} !important;
}}
body .kj-stelle-finden-btn:hover,
body .kj-about-btn:hover,
body .kj-newsletter-btn:hover {{
  background-color: {heading} !important;
  border-color: {heading} !important;
}}
.kj-detail-title, .kj-detail-title a,
.kj-company-name a, .kj-job-title a,
.kj-section-title {{
  color: {heading} !important;
}}
.kj-sidebar-block--red {{
  background-color: {btn} !important;
}}
.kj-pagination .page-item.active .page-link,
.kj-tab-active, .kj-nav-active {{
  background-color: {btn} !important;
  border-color: {btn} !important;
}}""")

        # --- CTA / Sidebar / Accent cards ---
        parts.append(f"""
.kj-cta-card, .kj-sidebar-block--red,
.kj-job-alert-cta, .kj-newsletter.bg-primary {{
  background-color: {btn} !important;
  color: #fff !important;
}}
.kj-cta-card a, .kj-sidebar-block--red a,
.kj-job-alert-cta a {{
  color: #fff !important;
}}
.kj-cta-card .btn, .kj-job-alert-cta .btn,
.kj-sidebar-block--red .btn {{
  background-color: #fff !important;
  color: {btn} !important;
  border-color: #fff !important;
}}
.kj-cta-card h5, .kj-cta-card h6,
.kj-job-alert-cta h5, .kj-job-alert-cta h6 {{
  color: #fff !important;
}}""")

        # --- Job card list items ---
        parts.append(f"""
.kj-job-card, .kj-job-item {{
  border-color: {border} !important;
}}
.kj-job-card:hover, .kj-job-item:hover {{
  border-color: {btn} !important;
}}
.kj-job-title a {{ color: {heading} !important; }}
.kj-job-title a:hover {{ color: {btn} !important; }}
.kj-job-meta, .kj-job-location {{ color: {text_sec} !important; }}
.kj-tag, .badge.bg-light {{
  background-color: {palette['panel_header']} !important;
  color: {heading} !important;
}}""")

        # --- Pagination ---
        parts.append(f"""
.page-link {{ color: {btn} !important; border-color: {border} !important; }}
.page-item.active .page-link {{
  background-color: {btn} !important;
  border-color: {btn} !important;
  color: #fff !important;
}}""")

        # --- Conditional visibility ---
        if not config.theme_show_search:
            parts.append(
                ".o_search_modal_btn, #kjSearchModal, .kj-search-bar,"
                " .kj-search-section { display: none !important; }"
            )
        if not config.theme_show_logo:
            parts.append(
                ".kj-header-logo-col, .kj-logo { display: none !important; }"
            )
        if not config.theme_show_newsletter_popup:
            parts.append(
                "#kjNewsletterModal, .kj-newsletter-popup"
                " { display: none !important; }"
            )

        # --- Header / menu visibility for inline mode ---
        if not config.inline_show_header:
            parts.append(
                "header#top, .kj-header-wrapper, .o_header_mobile,"
                " header > nav.navbar { display: none !important; }"
                " main { padding-top: 0 !important; }"
            )
        else:
            if not config.inline_show_topbar:
                parts.append(
                    ".kj-top-bar { display: none !important; }"
                )
            if not config.inline_show_logo:
                parts.append(
                    ".kj-header-logo-col { display: none !important; }"
                    " .kj-header-wrapper { grid-template-columns: 1fr !important; }"
                )
            if not config.inline_show_menu:
                parts.append(
                    ".kj-navbar-row { display: none !important; }"
                )
            else:
                # Individual menu items
                if not config.inline_show_menu_stellenangebote:
                    parts.append(
                        ".kj-main-menu a[href*='job-offers'],"
                        " .kj-main-menu a[href*='stellenangebote']"
                        " { display: none !important; }"
                    )
                if not config.inline_show_menu_firmenprofile:
                    parts.append(
                        ".kj-main-menu a[href*='company-profiles'],"
                        " .kj-main-menu a[href*='firmenprofile']"
                        " { display: none !important; }"
                    )
                if not config.inline_show_menu_karriere_tipps:
                    parts.append(
                        ".kj-main-menu a[href*='karriere-tipps'],"
                        " .kj-main-menu a[href*='career-tips']"
                        " { display: none !important; }"
                    )
                if not config.inline_show_menu_actions:
                    parts.append(
                        ".kj-header-actions { display: none !important; }"
                    )

            # Vertical menu layout
            if config.inline_menu_layout == "vertical":
                parts.append("""
/* Vertical sidebar menu layout */
.kj-header-wrapper {
  display: flex !important;
  flex-direction: row !important;
  grid-template-columns: none !important;
}
.kj-header-logo-col {
  width: auto !important;
  padding: 12px 16px !important;
}
.kj-header-content-col {
  display: flex !important;
  flex-direction: column !important;
  width: 100% !important;
}
.kj-navbar-row {
  position: fixed !important;
  left: 0 !important;
  top: 0 !important;
  bottom: 0 !important;
  width: 220px !important;
  z-index: 999 !important;
  background: var(--kj-navbar-bg, #1b2a4a) !important;
  padding: 60px 0 20px !important;
  overflow-y: auto !important;
}
.kj-navbar-row .kj-main-menu .navbar-nav {
  flex-direction: column !important;
  width: 100% !important;
}
.kj-navbar-row .kj-main-menu .navbar-nav .nav-link {
  padding: 10px 20px !important;
  border-bottom: 1px solid rgba(255,255,255,0.08) !important;
  color: #fff !important;
}
.kj-navbar-row .kj-main-menu .navbar-nav .nav-link:hover {
  background: rgba(255,255,255,0.1) !important;
}
.kj-navbar-row .kj-header-actions {
  flex-direction: column !important;
  padding: 16px !important;
  gap: 8px !important;
}
.kj-navbar-row .kj-header-actions .btn {
  width: 100% !important;
  justify-content: center !important;
}
/* Offset main content for sidebar */
main#main-content, #wrapwrap > main {
  margin-left: 220px !important;
}
header#top {
  margin-left: 220px !important;
}
""")

        # --- Custom logo ---
        if config.inline_custom_logo_url:
            logo_url = config.inline_custom_logo_url
            parts.append(f"""
/* Custom affiliate logo */
.kj-header-logo-col .navbar-brand img,
.kj-header-logo-col .logo img {{
  content: url('{logo_url}') !important;
  max-height: 50px !important;
  width: auto !important;
}}
""")
        elif config.inline_custom_logo:
            # Binary logo served via API
            logo_url = f"/api/widget/logo/{config.widget_key}"
            parts.append(f"""
/* Custom affiliate logo */
.kj-header-logo-col .navbar-brand img,
.kj-header-logo-col .logo img {{
  content: url('{logo_url}') !important;
  max-height: 50px !important;
  width: auto !important;
}}
""")

        return "\n".join(parts)

    # =================================================================
    # Custom Logo API
    # =================================================================

    @http.route(
        "/api/widget/logo/<string:widget_key>",
        type="http",
        auth="public",
        methods=["GET"],
        cors="*",
        csrf=False,
    )
    def serve_custom_logo(self, widget_key, **kwargs):
        """Serve the uploaded custom logo binary as an image."""
        config = (
            request.env["affiliate.widget.config"]
            .sudo()
            .search([("widget_key", "=", widget_key), ("is_active", "=", True)], limit=1)
        )
        if not config or not config.inline_custom_logo:
            return request.not_found()
        import base64
        logo_data = base64.b64decode(config.inline_custom_logo)
        # Detect PNG vs JPEG
        content_type = "image/png"
        if logo_data[:2] == b'\xff\xd8':
            content_type = "image/jpeg"
        elif logo_data[:4] == b'<svg' or logo_data[:5] == b'<?xml':
            content_type = "image/svg+xml"
        return request.make_response(
            logo_data,
            headers=[
                ("Content-Type", content_type),
                ("Cache-Control", "public, max-age=86400"),
            ],
        )

    # =================================================================
    # Palette Preview API
    # =================================================================

    @http.route(
        "/api/widget/palette/<string:hex_color>",
        type="http",
        auth="public",
        methods=["GET"],
        cors="*",
        csrf=False,
    )
    def get_palette(self, hex_color, **kwargs):
        """Return JSON of 9 derived color shades from a base hex color."""
        # Sanitize input
        hex_color = hex_color.lstrip("#")
        if not re.match(r"^[0-9a-fA-F]{6}$", hex_color):
            hex_color = "c0392b"
        palette = generate_palette(f"#{hex_color}")
        return request.make_json_response(palette)

    # =================================================================
    # Box Widget — Compact Jobs API
    # =================================================================

    @http.route(
        "/api/widget/jobs/compact",
        type="http",
        auth="public",
        methods=["GET", "OPTIONS"],
        cors="*",
        csrf=False,
    )
    def get_compact_jobs(self, **kwargs):
        """Return a lightweight JSON list of latest jobs for box widget."""
        widget_key = kwargs.get("key", "")
        limit = min(int(kwargs.get("limit", 5)), 20)

        if not widget_key:
            return request.make_json_response(
                {"error": "Missing key"}, status=400,
            )

        config = (
            request.env["affiliate.widget.config"]
            .sudo()
            .search(
                [("widget_key", "=", widget_key), ("is_active", "=", True)],
                limit=1,
            )
        )
        if not config:
            return request.make_json_response(
                {"error": "Invalid widget key"}, status=403,
            )

        domain = [("is_published", "=", True), ("active", "=", True)]
        domain += get_affiliate_job_domain(config)

        base_url = (
            request.env["ir.config_parameter"].sudo().get_param("web.base.url")
        )
        jobs = (
            request.env["hr.job"]
            .sudo()
            .search(
                domain,
                order="published_date desc, create_date desc",
                limit=limit,
            )
        )

        result = []
        for job in jobs:
            company = job.x_employer_parent_id
            company_name = (
                company.x_brand_name or company.name if company else ""
            )
            logo_url = ""
            if company:
                if company.x_premium_logo:
                    logo_url = f"{base_url}/web/image/res.partner/{company.id}/x_premium_logo"
                elif company.image_128:
                    logo_url = f"{base_url}/web/image/res.partner/{company.id}/image_128"

            result.append({
                "id": job.id,
                "name": job.name or "",
                "company_name": company_name,
                "company_logo": logo_url,
                "city": job.x_city or "",
                "url": f"/job-offers/{job.id}",
            })

        return request.make_response(
            json.dumps({"jobs": result}),
            headers=[
                ("Content-Type", "application/json; charset=utf-8"),
                ("Cache-Control", "public, max-age=300"),
            ],
        )


    # =================================================================
    # Full Job Search API (for inline widget — no iframe)
    # =================================================================

    @http.route(
        "/api/widget/jobs",
        type="http",
        auth="public",
        methods=["GET", "OPTIONS"],
        cors="*",
        csrf=False,
    )
    def search_jobs(self, **kwargs):
        """Search jobs with filtering and pagination for inline widget.

        Query params:
            key: widget key (required)
            q: text search
            field_ids: comma-separated field of law IDs
            func_id: job function ID
            location: city text filter
            page: page number (default 1)
            page_size: results per page (default 12, max 24)
        """
        widget_key = kwargs.get("key", "")
        if not widget_key:
            return request.make_json_response(
                {"error": "Missing key"}, status=400,
            )

        config = (
            request.env["affiliate.widget.config"]
            .sudo()
            .search(
                [("widget_key", "=", widget_key), ("is_active", "=", True)],
                limit=1,
            )
        )
        if not config:
            return request.make_json_response(
                {"error": "Invalid widget key"}, status=403,
            )

        page = max(1, int(kwargs.get("page", 1)))
        page_size = min(24, max(1, int(kwargs.get("page_size", 12))))
        q = kwargs.get("q", "").strip()
        field_ids = kwargs.get("field_ids", "")
        func_id = kwargs.get("func_id", "")
        location = kwargs.get("location", "").strip()

        domain = [("is_published", "=", True), ("active", "=", True)]
        domain += get_affiliate_job_domain(config)

        if q:
            domain.append("|")
            domain.append("|")
            domain.append(("name", "ilike", q))
            domain.append(("x_city", "ilike", q))
            domain.append(("x_employer_parent_id.name", "ilike", q))

        if field_ids:
            try:
                ids = [int(x) for x in field_ids.split(",") if x.strip()]
                if ids:
                    domain.append(("x_job_field_ids", "in", ids))
            except ValueError:
                pass

        if func_id:
            try:
                domain.append(("x_job_function_id", "=", int(func_id)))
            except ValueError:
                pass

        if location:
            domain.append(("x_city", "ilike", location))

        HrJob = request.env["hr.job"].sudo()
        total = HrJob.search_count(domain)
        offset = (page - 1) * page_size
        jobs = HrJob.search(
            domain,
            order="published_date desc, create_date desc",
            limit=page_size,
            offset=offset,
        )

        base_url = (
            request.env["ir.config_parameter"]
            .sudo()
            .get_param("web.base.url")
        )

        result = []
        for job in jobs:
            company = job.x_employer_parent_id
            company_name = company.x_brand_name or company.name if company else ""
            logo_url = ""
            if company:
                if company.x_premium_logo:
                    logo_url = f"{base_url}/web/image/res.partner/{company.id}/x_premium_logo"
                elif company.image_128:
                    logo_url = f"{base_url}/web/image/res.partner/{company.id}/image_128"

            fields_list = []
            if job.x_job_field_ids:
                fields_list = [
                    {"id": f.id, "name": f.x_name}
                    for f in job.x_job_field_ids[:3]
                ]

            func = None
            if job.x_job_function_id:
                func = {
                    "id": job.x_job_function_id.id,
                    "name": job.x_job_function_id.x_name,
                }

            result.append({
                "id": job.id,
                "name": job.name or "",
                "company_name": company_name,
                "company_logo": logo_url,
                "city": job.x_city or "",
                "state": job.x_state_id.name if job.x_state_id else "",
                "remote": job.x_home_office or "no",
                "fields": fields_list,
                "function": func,
                "published_date": str(job.published_date or job.create_date)[:10],
                "url": f"/job-offers/{job.id}",
            })

        pages = max(1, -(-total // page_size))  # ceil division
        return request.make_response(
            json.dumps({
                "jobs": result,
                "total": total,
                "page": page,
                "page_size": page_size,
                "pages": pages,
            }),
            headers=[
                ("Content-Type", "application/json; charset=utf-8"),
                ("Cache-Control", "public, max-age=300"),
            ],
        )

    @http.route(
        "/api/widget/filters",
        type="http",
        auth="public",
        methods=["GET", "OPTIONS"],
        cors="*",
        csrf=False,
    )
    def get_filters(self, **kwargs):
        """Return available filter options for the inline widget."""
        fields_of_law = (
            request.env["hr.job.fields"]
            .sudo()
            .search([("x_active", "=", True)], order="x_sequence, x_name")
        )
        functions = (
            request.env["hr.job.function"]
            .sudo()
            .search([("x_active", "=", True)], order="x_sequence, x_name")
        )

        # Get distinct cities from published jobs
        request.env.cr.execute("""
            SELECT DISTINCT x_city FROM hr_job
            WHERE is_published = true AND active = true
              AND x_city IS NOT NULL AND x_city != ''
            ORDER BY x_city
        """)
        locations = [row[0] for row in request.env.cr.fetchall()]

        data = {
            "fields_of_law": [
                {"id": f.id, "name": f.x_name} for f in fields_of_law
            ],
            "functions": [
                {"id": f.id, "name": f.x_name} for f in functions
            ],
            "locations": locations,
        }

        return request.make_response(
            json.dumps(data),
            headers=[
                ("Content-Type", "application/json; charset=utf-8"),
                ("Cache-Control", "public, max-age=3600"),
            ],
        )


def get_affiliate_job_domain(config):
    """Return extra domain clauses for affiliate job filtering."""
    if not config:
        return []
    domain = []
    filter_mode = config.filter_mode or "all"
    if filter_mode == "curated" and config.filter_job_ids:
        domain.append(("id", "in", config.filter_job_ids.ids))
    elif filter_mode == "filtered":
        if config.filter_field_ids:
            domain.append(
                ("x_job_field_ids", "in", config.filter_field_ids.ids)
            )
        if config.filter_function_ids:
            domain.append(
                ("x_job_function_id", "in", config.filter_function_ids.ids)
            )
        if config.filter_employer_ids:
            domain.append(
                ("x_employer_parent_id", "in", config.filter_employer_ids.ids)
            )
    return domain
