"""Tests for the palette extraction action.

Mocks the crawler endpoints and the LLM so the suite runs offline.
The action tries CSS computed-colour extraction first and falls back
to the vision LLM only when the page exposes no real brand colour.
"""

from unittest.mock import patch

from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase


class TestPaletteExtraction(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        Cfg = cls.env['affiliate.widget.config']
        # Pick an internal user without an existing config to keep the
        # unique constraint on (user_id, mode) happy.
        owned_ids = Cfg.search([]).user_id.ids
        cls.affiliate_user = cls.env['res.users'].search([
            ('share', '=', False),
            ('id', 'not in', owned_ids),
            ('active', '=', True),
        ], limit=1)
        if not cls.affiliate_user:
            cls.skipTest(cls, "No internal user without an existing widget config")
        cls.config = Cfg.create({
            'user_id': cls.affiliate_user.id,
            'partner_id': cls.affiliate_user.partner_id.id,
            'widget_key': 'test-palette-key-001',
            'widget_mode': 'overlay',
            'affiliate_website': 'https://example-affiliate.de',
        })

    def _canned_vision_palette(self):
        return (
            '{"primary":"#0055aa","on_primary":"#ffffff",'
            '"background":"#ffffff","surface":"#f5f7fb",'
            '"heading":"#222222","text":"#444444","border":"#d0d7e2",'
            '"navbar_bg":"#0b2545","navbar_text":"#ffffff",'
            '"footer_bg":"#101820","footer_text":"#cccccc"}'
        )

    def _computed_with_brand(self):
        """A computed-colours dict with a clear non-grey brand colour."""
        return {
            'root_vars': {'--primary': '#0055aa'},
            'body_bg': 'rgb(255, 255, 255)',
            'body_color': 'rgb(34, 34, 34)',
            'button_bg': 'rgb(0, 85, 170)',
            'button_color': 'rgb(255, 255, 255)',
            'link_color': 'rgb(0, 85, 170)',
            'heading_color': 'rgb(17, 17, 17)',
            'nav_bg': 'rgb(11, 37, 69)',
            'nav_color': 'rgb(255, 255, 255)',
            'footer_bg': 'rgb(16, 24, 32)',
            'footer_color': 'rgb(204, 204, 204)',
            'card_bg': 'rgb(245, 247, 251)',
            'border_color': 'rgb(208, 215, 226)',
        }

    def _computed_greyscale(self):
        """A computed-colours dict that is entirely neutral — no brand
        colour to extract, so the action should fall back to vision."""
        return {
            'root_vars': {},
            'body_bg': 'rgb(255, 255, 255)',
            'body_color': 'rgb(51, 51, 51)',
            'button_bg': None,
            'button_color': None,
            'link_color': 'rgb(120, 120, 120)',
            'heading_color': 'rgb(0, 0, 0)',
            'nav_bg': 'rgb(238, 238, 238)',
            'footer_bg': 'rgb(245, 245, 245)',
            'border_color': 'rgb(204, 204, 204)',
        }

    # -- helper unit tests --------------------------------------------------

    def test_rgb_to_hex(self):
        f = type(self.config)._rgb_to_hex
        self.assertEqual(f('rgb(0, 85, 170)'), '#0055aa')
        self.assertEqual(f('rgba(255, 255, 255, 1)'), '#ffffff')
        self.assertEqual(f('#0055AA'), '#0055aa')
        self.assertEqual(f('#abc'), '#aabbcc')
        self.assertIsNone(f('rgba(0, 0, 0, 0)'))   # fully transparent
        self.assertIsNone(f('transparent'))
        self.assertIsNone(f(None))

    def test_is_greyscale(self):
        g = type(self.config)._is_greyscale
        self.assertTrue(g('#ffffff'))
        self.assertTrue(g('#333333'))
        self.assertTrue(g('#cccccc'))
        self.assertFalse(g('#0055aa'))
        self.assertFalse(g('#65828b'))   # rechtscentrum's desaturated teal

    # -- CSS-first path -----------------------------------------------------

    def test_css_extraction_writes_theme_fields(self):
        """When the page exposes a real brand colour, the action uses the
        exact computed values and does not call the vision LLM."""
        crawler_cls = type(self.env['llm.webcrawler'])
        huble_cls = type(self.env['llm.huble'])
        with patch.object(crawler_cls, 'extract_computed_colors',
                          return_value=self._computed_with_brand()), \
             patch.object(huble_cls, 'get_llm_vision_response') as vision:
            self.config.action_extract_palette_from_website()
        self.assertFalse(vision.called, 'CSS path must not call the vision LLM')
        # primary from the :root --primary variable, fanned out
        self.assertEqual(self.config.theme_color, '#0055aa')
        self.assertEqual(self.config.theme_btn_color, '#0055aa')
        self.assertEqual(self.config.box_link_color, '#0055aa')
        # other slots from exact computed values
        self.assertEqual(self.config.theme_bg_color, '#ffffff')
        self.assertEqual(self.config.theme_text_color, '#222222')
        self.assertEqual(self.config.theme_heading_color, '#111111')
        self.assertEqual(self.config.theme_border_color, '#d0d7e2')
        self.assertEqual(self.config.theme_navbar_bg, '#0b2545')
        self.assertEqual(self.config.theme_site_footer_bg, '#101820')
        self.assertTrue(self.config.palette_extraction_date)

    # -- vision fallback ----------------------------------------------------

    def test_vision_fallback_when_css_colourless(self):
        """A page with only neutral computed colours falls back to the
        vision LLM."""
        crawler_cls = type(self.env['llm.webcrawler'])
        huble_cls = type(self.env['llm.huble'])
        cfg_cls = type(self.config)
        with patch.object(crawler_cls, 'extract_computed_colors',
                          return_value=self._computed_greyscale()), \
             patch.object(crawler_cls, 'take_screenshot',
                          return_value={'image_b64': 'AAAA', 'mimetype': 'image/png'}), \
             patch.object(cfg_cls, '_downscale_screenshot',
                          return_value=('JPEG_B64', 'image/jpeg')), \
             patch.object(huble_cls, 'get_llm_vision_response',
                          return_value=self._canned_vision_palette()) as vision:
            self.config.action_extract_palette_from_website()
        self.assertTrue(vision.called,
                        'colourless CSS must trigger the vision fallback')
        self.assertEqual(self.config.theme_color, '#0055aa')   # from canned

    def test_vision_fallback_when_css_extraction_raises(self):
        """If the computed-colour crawl fails outright, the vision path
        still runs."""
        crawler_cls = type(self.env['llm.webcrawler'])
        huble_cls = type(self.env['llm.huble'])
        cfg_cls = type(self.config)
        with patch.object(crawler_cls, 'extract_computed_colors',
                          side_effect=UserError('crawler down')), \
             patch.object(crawler_cls, 'take_screenshot',
                          return_value={'image_b64': 'AAAA', 'mimetype': 'image/png'}), \
             patch.object(cfg_cls, '_downscale_screenshot',
                          return_value=('JPEG_B64', 'image/jpeg')), \
             patch.object(huble_cls, 'get_llm_vision_response',
                          return_value=self._canned_vision_palette()) as vision:
            self.config.action_extract_palette_from_website()
        self.assertTrue(vision.called)
        self.assertEqual(self.config.theme_color, '#0055aa')

    # -- guards -------------------------------------------------------------

    def test_missing_website_raises(self):
        """No URL set on either source → clear UserError, no crawl."""
        self.config.affiliate_website = False
        self.config.partner_id.website = False
        crawler_cls = type(self.env['llm.webcrawler'])
        with patch.object(crawler_cls, 'extract_computed_colors') as crawl, \
             self.assertRaises(UserError):
            self.config.action_extract_palette_from_website()
        self.assertFalse(crawl.called)

    def test_partner_website_fallback(self):
        """When `affiliate_website` is empty, the action falls back to
        `partner_id.website`."""
        self.config.affiliate_website = False
        self.config.partner_id.website = 'https://fallback-affiliate.de'
        crawler_cls = type(self.env['llm.webcrawler'])
        with patch.object(crawler_cls, 'extract_computed_colors',
                          return_value=self._computed_with_brand()):
            self.config.action_extract_palette_from_website()
        self.assertEqual(self.config.palette_source_url,
                         'https://fallback-affiliate.de')
