<script>
    /* globals dw */
    import { onMount, tick, afterUpdate, setContext, createEventDispatcher } from 'svelte';
    import { writable } from 'svelte/store';
    import VisContainerV1 from './v1/VisContainer.svelte';

    import migrate from './migrate';
    import debounce from 'lodash/debounce.js';
    import { chartBodyStyles, globalStyles, updateGlobalStyles } from './v1/themeStyles.js';

    import { domReady, width, getHeightMode } from './dw/utils/index.mjs';
    import observeFonts from '@datawrapper/shared/observeFonts.js';
    import get from '@datawrapper/shared/get.js';
    import set from '@datawrapper/shared/set.js';
    import { loadScript, loadStylesheet } from '@datawrapper/shared/fetch.js';
    import purifyHtml from '@datawrapper/shared/purifyHtml.js';
    import invertColor from '@datawrapper/shared/invertColor.js';
    import {
        clean,
        isTransparentColor,
        computeThemeData,
        deepmergeOverwriteArrays as deepmerge,
        parseChartLanguage
    } from './shared.mjs';

    import chroma from 'chroma-js';
    import coreBlocksV1 from './v1/blocks';

    const outerWidth = writable(400);
    const outerHeight = writable(400);
    const themeData = writable({});

    const dispatch = createEventDispatcher();

    setContext('stores', { outerWidth, outerHeight, themeData });

    export let chart;
    export let visualization = {};
    export let teamPublicSettings = {};
    export let theme = {};
    export let themeDataDark = {};
    export let themeDataLight = {};
    export let themeFonts = {};
    export let locales = {};
    export let textDirection = 'ltr';
    export let translations;
    export let blocks = {};
    export let chartAfterBodyHTML = '';
    export let isIframe;
    export let isPreview;
    export let assets;
    export let styleHolder;
    export let origin;
    export let externalDataUrl;
    export let outerContainer;
    export let containerWidth;
    export let containerHeight;

    // static style means user can't interact (e.g. in a png version)

    // autodark means dark/light display follows user prefers-color-scheme
    export let isAutoDark = false;
    export let renderFlags = {};

    $: isStylePlain = renderFlags.plain;
    $: isStyleStatic = renderFlags.static;
    $: isStyleTransparent = renderFlags.transparent;
    $: isStyleDark = renderFlags.dark !== 'auto' && renderFlags.dark;
    $: logoId = renderFlags.logoId;
    $: forceLogo = renderFlags.logo;
    $: caption = getCaption(visualization.id);

    export let emotion;

    export let frontendDomain = 'app.datawrapper.de';

    // .dw-chart-body
    let target, dwChart, vis;
    let container;
    let pluginBlocks = [];
    let contentBelowChart;

    let postEvent = () => {};

    const datasetName = `dataset.${get(chart.metadata, 'data.json') ? 'json' : 'csv'}`;

    // apply core metadata migrations
    migrate(chart.metadata);

    $: if (!get(chart, 'metadata.publish.blocks')) {
        // no footer settings found in metadata, apply theme defaults
        set(chart, 'metadata.publish.blocks', get($themeData, 'metadata.publish.blocks'));
    }

    const allowedAriaDescriptionTags = [
        'a',
        'span',
        'b',
        'br',
        'i',
        'strong',
        'sup',
        'sub',
        'strike',
        'u',
        'em',
        'tt',
        'table',
        'thead',
        'tbody',
        'tfoot',
        'caption',
        'colgroup',
        'col',
        'tr',
        'td',
        'th'
    ];
    $: ariaDescription = get(chart, 'metadata.describe.aria-description', '');

    $: customCSS = purifyHtml(get(chart, 'metadata.publish.custom-css', ''), []);

    $: metadataFields = {
        custom: get(chart, 'metadata.custom', {}),
        publish: get(chart, 'metadata.publish', {}),
        describe: get(chart, 'metadata.describe', {})
    };

    $: coreBlocks = coreBlocksV1;

    $: blockProps = {
        __,
        purifyHtml: clean,
        get,
        postEvent,
        teamPublicSettings,
        themeData: $themeData,
        // @todo: in theory we should remove `theme` from blockProps as all blocks should only
        // use `themeData`, but the logo block still needs the theme title as fallback text
        theme,
        chart,
        dwChart,
        vis,
        caption,
        logoId,
        forceLogo,
        textDirection
    };

    $: allBlocks = applyThemeBlockConfig([...coreBlocks, ...pluginBlocks], $themeData, blockProps);

    $: overrideContext = {
        type: chart.type,
        title: chart.title,
        team: chart.organizationId,
        width: $outerWidth,
        ...parseChartLanguage(chart.language),
        mode: {
            plain: isStylePlain,
            static: isStyleStatic,
            print: renderFlags.svgonly
        },
        ...metadataFields,
        blocks: Object.fromEntries(
            [...coreBlocks, ...pluginBlocks]
                .filter(({ includeInContext }) => includeInContext)
                .map(block => [
                    block.id,
                    !block.test || block.test({ chart, isStyleStatic, dwChart })
                ])
        )
    };

    $: $themeData = computeThemeData(
        isStyleDark ? themeDataDark : themeDataLight,
        overrideContext,
        isStyleDark
    );

    $: heightMode = getHeightMode({
        themeData: $themeData,
        renderFlags,
        visualizationMeta: visualization
    });

    function applyThemeBlockConfig(blocks, themeData, blockProps) {
        return blocks.map(block => {
            block.props = {
                ...(block.data || {}),
                ...blockProps,
                config: { frontendDomain },
                id: block.id
            };
            if (block.component.test) {
                block.test = block.component.test;
            }
            const options = get(themeData, 'options.blocks', {})[block.id];
            if (!options) return block;
            return {
                ...block,
                ...options
            };
        });
    }

    function getUrl(src) {
        return origin && src.indexOf('http') !== 0 ? `${origin}/${src}` : src;
    }

    function __(key, ...args) {
        if (typeof key !== 'string') {
            key = '';
            console.error(
                new TypeError(`function __ called without required 'key' parameter!
Please make sure you called __(key) with a key of type "string".
`)
            );
        }
        key = key.trim();

        let translation = translations[key] || key;

        if (args.length) {
            translation = translation.replace(/\$(\d)/g, (m, i) => {
                i = +i;
                return args[i] || m;
            });
        }

        return translation;
    }

    let filterUI = false;

    async function run() {
        // initialize $outerWidth to compute reactive theme data
        $outerWidth = outerContainer.clientWidth;
        await tick();

        // register theme, including overrides
        theme.data = $themeData;
        dw.theme.register(theme.id, theme.data);

        // register locales
        Object.keys(locales).forEach(vendor => {
            if (locales[vendor] === 'null') {
                locales[vendor] = null;
            }
            if (locales[vendor] && locales[vendor].base) {
                // eslint-disable-next-line
                const localeBase = eval(locales[vendor].base);
                locales[vendor] = deepmerge(localeBase, locales[vendor].custom);
            }
        });

        const useDwCdn = get(chart, 'metadata.data.use-datawrapper-cdn', true);

        const externalJSON =
            useDwCdn && get(chart, 'metadata.data.external-metadata', '').length
                ? `//${externalDataUrl}/${chart.id}.metadata.json`
                : get(chart, 'metadata.data.external-metadata');

        if (externalJSON && get(chart, 'metadata.data.upload-method') === 'external-data') {
            try {
                const now = new Date().getTime();
                const ts = useDwCdn ? now - (now % 60000) : now;
                const url = `${externalJSON}${externalJSON.includes('?') ? '&' : '?'}v=${ts}`;
                const res = await window.fetch(url);
                const obj = await res.json();
                if (obj.title) {
                    chart.title = obj.title;
                    delete obj.title;
                }

                chart.metadata = deepmerge(chart.metadata, obj);
                chart = chart;
            } catch (e) {
                console.warn('Invalid external metadata JSON, falling back on chart metadata');
            }
        }

        // initialize dw.chart object
        dwChart = dw
            .chart(chart)
            .locale((chart.language || 'en-US').substr(0, 2))
            .translations(translations)
            .theme(dw.theme(chart.theme))
            // TODO: remove isEditingAllowed alias and make sure all visualizations use allowEditing directly
            .flags({ isIframe, isEditingAllowed: renderFlags.allowEditing, ...renderFlags });

        dwChart.emotion = emotion;

        // if we're in the preview (i.e. iframe in the editor or exporter),
        // we want to access external data via our server so that we don't run into
        // CORS issues in case app.datawrapper.de has not been allowed as origin.
        const externalData = !isPreview && chart.externalData;

        // register chart assets
        const assetPromises = [];
        for (const [name, { url, value, load = true }] of Object.entries(assets)) {
            const isDataset = name === datasetName;

            if (!isDataset || !externalData) {
                if (url) {
                    if (load) {
                        const assetName = name;
                        assetPromises.push(
                            // eslint-disable-next-line
                            new Promise(async resolve => {
                                const res = await fetch(getUrl(assets[assetName].url));
                                const text = await res.text();
                                dwChart.asset(assetName, text);
                                resolve();
                            })
                        );
                    } else {
                        dwChart.asset(name, url);
                    }
                } else {
                    dwChart.asset(name, value);
                }
            }
        }
        await Promise.all(assetPromises);

        // initialize dw.vis object
        vis = dw.visualization(visualization.id, target);
        vis.meta = visualization;
        vis.lang = chart.language || 'en-US';
        vis.textDirection = textDirection;
        vis.addFilterUI = _filterUI => {
            filterUI = _filterUI;
        };

        // load chart data and assets
        await dwChart.load(dwChart.asset(datasetName) || '', externalData);
        dwChart.locales = locales;
        dwChart.vis(vis);

        // load & register blocks
        await loadBlocks(blocks);

        // add theme._computed to theme.data for better dark mode support
        theme.data._computed = theme._computed;

        // we only apply dark mode if base theme is light
        const lightBg = get(themeDataLight, 'colors.background', '#ffffff');
        if (chroma(lightBg).luminance() >= 0.3) {
            vis.initDarkMode(
                onDarkModeChange,
                initDarkModeColormap({ themeDataDark, themeDataLight })
            );
            if (isStyleDark) vis.darkMode(true);
        }

        if (isAutoDark) {
            // update dark mode state when browser preferences changes
            const matchMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
            matchMediaQuery.addEventListener('change', e => {
                updateDarkModeState(e.matches);
            });
            if (!isIframe && (renderFlags.dark === null || renderFlags.dark === 'auto')) {
                // initialize dark flag in web components
                renderFlags = { ...renderFlags, dark: matchMediaQuery.matches };
            }

            if (!CSS.supports('color-scheme', 'dark')) {
                // if the browser doesn't support prefer-color-scheme we need
                // to remove the media="(prefers-color-scheme: light)" attribute
                // from the light styles, or else no styles would be shown at all
                updateActiveCSS(false);
            }
        }

        // render chart
        if (window.parent === window && isPreview) {
            console.time('Chart rendered in'); // eslint-disable-line no-console
        }
        const rendered = dwChart.render(outerContainer);
        if (window.parent === window && isPreview) {
            rendered
                .then(() => {
                    console.timeEnd('Chart rendered in'); // eslint-disable-line no-console
                })
                .catch(err => {
                    console.error(err);
                });
        }

        // await necessary reload triggers
        observeFonts(themeFonts, themeDataLight.typography)
            .then(() => dwChart.render(outerContainer))
            .catch(() => dwChart.render(outerContainer));

        // iPhone/iPad fix
        if (/iP(hone|od|ad)/.test(navigator.platform)) {
            window.onload = dwChart.render(outerContainer);
        }

        isIframe && initResizeHandler(target);

        // need to measure container height manually if not in web component
        // (this is needed by the watermark block)
        if (isIframe) $outerHeight = outerContainer.clientHeight;

        dispatch('rendered');

        /**
         * updateDarkModeState is called when we detect a change in color-schema media preference.
         * It calls `vis.darkMode()` which will then trigger `onDarkModeChange()`
         * @param {boolean} isDark
         */
        function updateDarkModeState(isDark) {
            renderFlags = { ...renderFlags, dark: isDark };
            vis.darkMode(isDark);
        }

        /**
         * This is called by our visualization render code in vis.darkMode()
         * @param {boolean} isDark
         */
        async function onDarkModeChange(isDark) {
            // update renderFlag.dark in case someone just called vis.darkMode()
            // without the color-scheme preference changing in browser
            if (renderFlags.dark !== isDark) {
                renderFlags = { ...renderFlags, dark: isDark };
            }

            await tick();
            await updateChartThemeData();

            // manually swap active css if we're not in automatic darkmode
            if (!isAutoDark) {
                updateActiveCSS(isDark);
            }

            // revert dark palette to prevent double-mapping
            if (isDark) {
                set(theme.data, 'colors.palette', get(themeDataLight, 'colors.palette', []));
            }
            outerContainer.classList.toggle('is-dark-mode', isDark);
            dwChart.render(outerContainer);
        }

        /**
         * After switching dark mode we need to wait for the computed themeData
         * to uppate.
         */
        async function updateChartThemeData() {
            /*
             * Preserve pre-existing theme object,
             * as render code generally captures vis.theme() in variable just once.
             *
             * @todo: Implement more foolproof solution. E.g using svelte/store
             */
            Object.keys(theme.data).forEach(key => {
                if (key !== '_computed') delete theme.data[key];
                if ($themeData[key]) theme.data[key] = $themeData[key];
            });
        }

        /**
         * in some cases we need to manually switch from light to dark styles
         * without relying on the browser prefer-color-schema
         *
         * @param {boolean} isDark
         */
        function updateActiveCSS(isDark) {
            // @todo: access these without using document
            if (isIframe) {
                const metaColorSchema = document.querySelector('meta[name="color-scheme"]');
                if (metaColorSchema) {
                    metaColorSchema.setAttribute('content', isDark ? 'dark' : 'light');
                }
            }
            const cssLight = (styleHolder || document).querySelector('#css-light');
            const cssDark = (styleHolder || document).querySelector('#css-dark');
            (isDark ? cssLight : cssDark).setAttribute('media', '--disabled--');
            (isDark ? cssDark : cssLight).removeAttribute('media');
        }

        async function resizeVis() {
            await updateChartThemeData();
            dwChart.vis().fire('resize');
            dwChart.render(outerContainer);
        }

        const resizeVisDebounced = debounce(resizeVis, 200);

        /**
         * initialize the window resize event handler in iframe embeds
         * @param container
         */
        function initResizeHandler(container) {
            const isFixedHeight = dwChart.getHeightMode() === 'fixed';

            let currentWidth = width(container);

            window.addEventListener('resize', () => {
                // update $outerWidth and $outerHeight stores right away
                // it's ok to use window.innerHeight because this is only used in iframe embeds
                $outerWidth = width(outerContainer);
                $outerHeight = window.innerHeight;

                if (isFixedHeight) {
                    // in fixed height charts we only trigger re-render if the width
                    // has changed, effectively ignoring height-only resize events
                    const w = width(container);
                    if (currentWidth !== w) {
                        currentWidth = w;
                        resizeVisDebounced();
                    }
                } else {
                    // in fit height charts we need to re-render on every resize event
                    resizeVisDebounced();
                }
            });
        }

        return { dwChart, resizeVisDebounced };
    }

    async function loadBlocks(blocks) {
        if (blocks.length) {
            await Promise.all(
                blocks.map(d => {
                    return new Promise(resolve => {
                        if (d.preloaded) return resolve(); // in puppeteer tests, js and css files are preloaded
                        const p = [loadScript(getUrl(d.source.js))];

                        if (d.source.css) {
                            p.push(
                                loadStylesheet({
                                    src: getUrl(d.source.css),
                                    parentElement: styleHolder
                                })
                            );
                        }

                        Promise.all(p)
                            .then(resolve)
                            .catch(err => {
                                // log error
                                const url = err.target
                                    ? err.target.getAttribute('src') ||
                                      err.target.getAttribute('href')
                                    : null;
                                if (url) console.warn('could not load ', url);
                                else console.error('Unknown error', err);
                                // but resolve anyway
                                resolve();
                            });
                    });
                })
            );

            // all scripts are loaded
            blocks.forEach(d => {
                d.blocks.forEach(block => {
                    if (!dw.block.has(block.component)) {
                        return console.warn(
                            `component ${block.component} from chart block ${block.id} not found`
                        );
                    }
                    pluginBlocks.push({
                        ...block,
                        component: dw.block(block.component)
                    });
                });
            });

            // trigger svelte update after modifying array
            pluginBlocks = pluginBlocks;
        }
    }

    // watch for height changes - still needed?
    let currentHeight;

    afterUpdate(() => {
        if (!isIframe || currentHeight === undefined) return;
        const newHeight = document.body.offsetHeight;
        if (currentHeight !== newHeight && typeof dwChart.render === 'function') {
            dwChart.render(outerContainer);
            currentHeight = newHeight;
        }
    });

    let onResize;

    // update $outerWidth and $outerHeight dimensions when web component resizes
    $: if (!isIframe) $outerWidth = containerWidth;
    $: if (!isIframe) $outerHeight = containerHeight;
    // re-render vis when container width changes
    $: if (onResize && !isIframe) onResize(containerWidth);

    onMount(async () => {
        const { dwChart, resizeVisDebounced } = await run();
        // expose resize method to component-level so we can call it in a
        // reactive statement to re-render when the web component resizes
        onResize = resizeVisDebounced;

        outerContainer.classList.toggle('dir-rtl', textDirection === 'rtl');
        if (isIframe) {
            if (isStyleStatic && !isStyleTransparent) {
                const bodyBackground = get(theme.data, 'style.body.background', 'transparent');
                const previewBackground = get(theme.data, 'colors.background');
                if (previewBackground && isTransparentColor(bodyBackground)) {
                    document.body.style.background = previewBackground;
                }
            }

            // fire events on hashchange
            domReady(() => {
                if (!dwChart) {
                    return;
                }
                postEvent = dwChart.createPostEvent();
                window.addEventListener('hashchange', () => {
                    postEvent('hash.change', { hash: window.location.hash });
                });
            });

            currentHeight = document.body.offsetHeight;

            // provide external APIs
            window.__dw = window.__dw || {};
            window.__dw.params = {
                data: dwChart.asset(datasetName),
                visJSON: visualization
            };
            window.__dw.vis = vis;
            window.__dw.render = () => {
                dwChart.render(outerContainer);
            };
            window.fontsJSON = theme.fonts;
            window.getThemeData = key => get(themeDataLight, key);
            window.setThemeData = (key, value) => {
                set(themeDataLight, key, value);
                themeDataLight = themeDataLight;
            };
            window.setTheme = async id => {
                const res = await fetch(`http://api.datawrapper.local/v3/themes/${id}?extend=true`);
                const { data, fontsCSS } = await res.json();
                themeDataLight = data;
                theme.data = data;
                document.querySelector('style').innerText = fontsCSS;
                dwChart.render(outerContainer);
                // const st = document.createElement('style');
                // st.innerHTML = fontsCSS;
                // document.head.appendChild(st);
            };
        }
    });

    function getCaption(id) {
        if (id === 'd3-maps-choropleth' || id === 'd3-maps-symbols' || id === 'locator-map')
            return 'map';
        else if (id === 'tables') return 'table';
        return 'chart';
    }

    function initDarkModeColormap({ themeDataLight, themeDataDark }) {
        const colorCache = new Map();
        const darkPalette = get(themeDataDark, 'colors.palette', []);
        const lightPalette = get(themeDataLight, 'colors.palette', []);

        const lightBg = get(themeDataLight, 'colors.background', '#ffffff');
        const darkBg = get(themeDataDark, 'colors.background');

        const themeColorMap = Object.fromEntries(
            lightPalette.map((light, i) => [light.toLowerCase(), darkPalette[i]])
        );

        return function (color, { forceInvert, noInvert } = {}) {
            const darkModeNoInvert = !vis.get('dark-mode-invert', true);
            if (noInvert || (darkModeNoInvert && !forceInvert)) return color;

            if (!chroma.valid(color)) return color;

            color = color.toLowerCase();

            if (themeColorMap[color]) {
                // theme has a hard replacement color
                return themeColorMap[color];
            }

            if (colorCache.has(color)) {
                // we've already mapped this color
                return colorCache.get(color);
            }

            colorCache.set(color, invertColor(color, darkBg, lightBg, 0.85));

            return colorCache.get(color);
        };
    }

    $: if (emotion) updateGlobalStyles(globalStyles(emotion, $themeData, isIframe));
</script>

<style lang="scss">
    :global(body),
    :global(.web-component-body) {
        margin: 0;
        padding: 0;
        -webkit-text-size-adjust: 100%;
    }
    :global(body.in-editor),
    :global(.web-component-body.in-editor) {
        padding-bottom: 10px;
    }

    :global(.chart.dir-rtl) .dw-chart-body {
        direction: rtl;
    }

    :global(.chart.is-dark-mode .hide-in-dark) {
        display: none;
    }
    :global(.chart:not(.is-dark-mode) .hide-in-light) {
        display: none;
    }

    :global(.chart) {
        :global(.sr-only) {
            position: absolute;
            left: -9999px;
            height: 1px;
        }

        &.js :global(.noscript) {
            display: none;
        }

        :global(.hidden),
        :global(.hide) {
            display: none;
        }
    }

    :global(a img) {
        border: 0px;
    }

    :global(svg rect) {
        shape-rendering: crispEdges;
    }

    :global(.error) {
        background-color: #fbeff2;
        box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05) inset;
        margin: 2em;
        :global(p) {
            color: #b63057;
            font-size: 14px;
            padding: 1.25em 1.5em;
            line-height: 1.5;
            border-color: #cf4870;
            border-style: solid;
            border-width: 0 0 0 4px;
            border-radius: 4px;
        }
    }

    .dw-chart-body-content {
        position: relative;
        z-index: 0;
    }
</style>

<VisContainerV1
    bind:container
    bind:contentBelowChart
    {allBlocks}
    {allowedAriaDescriptionTags}
    {ariaDescription}
    {chart}
    {chartAfterBodyHTML}
    {emotion}
    {filterUI}
    {isIframe}
    {renderFlags}
    {teamPublicSettings}
    {textDirection}
    {theme}
    {themeData}
    {vis}
>
    <div
        id="chart"
        class:content-below-chart={contentBelowChart}
        aria-hidden={!!ariaDescription}
        class="dw-chart-body vis-height-{heightMode} {emotion
            ? chartBodyStyles(emotion, $themeData)
            : ''}"
        class:dir-rtl={textDirection === 'rtl'}
    >
        <div bind:this={target} class="dw-chart-body-content">
            <noscript>
                <img
                    style="max-width: 100%"
                    src="../plain.png"
                    aria-hidden="true"
                    alt="fallback image"
                />
                <p style="opacity:0.6;padding:1ex; text-align:center">
                    {__('fallback-image-note')}
                </p>
            </noscript>
        </div>
    </div>
</VisContainerV1>
