/**
 * @license
 * Copyright 2023 Google LLC
 * SPDX-License-Identifier: BSD-3-Clause
 */

import {LitElement, html, render} from 'lit';
import {property} from 'lit/decorators.js';
import {cache} from 'lit/directives/cache.js';
import {assert} from '@esm-bundle/chai';
import {watch, signal, computed, SignalWatcher} from '../index.js';

let elementNameId = 0;
const generateElementName = () => `test-${elementNameId++}`;

suite('watch directive', () => {
  let container: HTMLElement;

  setup(() => {
    container = document.createElement('div');
    document.body.appendChild(container);
  });

  teardown(() => {
    container?.remove();
  });

  test('watches a signal', async () => {
    let renderCount = 0;
    const count = signal(0);
    class TestElement extends SignalWatcher(LitElement) {
      override render() {
        renderCount++;
        return html`<p>count: ${watch(count)}</p>`;
      }
    }
    customElements.define(generateElementName(), TestElement);
    const el = new TestElement();
    container.append(el);

    // The first DOM update is because of an element render
    await el.updateComplete;
    assert.equal(
      el.shadowRoot?.querySelector('p')?.textContent,
      'count: 0',
      'A'
    );
    assert.equal(renderCount, 1);
    assert.isFalse(el.isUpdatePending);

    // The DOM updates because signal update
    count.set(1);
    // watch does *not* trigger update
    assert.isFalse(el.isUpdatePending);
    await el.updateComplete;
    assert.equal(
      el.shadowRoot?.querySelector('p')?.textContent,
      'count: 1',
      'B'
    );
    // The updated DOM is not because of an element render
    assert.equal(renderCount, 1);
  });

  test('watches a signal with Lit render', async () => {
    const count = signal(0);
    const template = () => {
      return html` <p>count: ${watch(count)}</p>
        <p>count: ${watch(count)}</p>
        <p>count: ${watch(count)}</p>`;
    };
    const el = document.createElement('div');
    container.append(el);
    render(template(), el);
    el.querySelectorAll('p').forEach((p) => {
      assert.equal(p.textContent, 'count: 0', 'A');
    });
    // The DOM updates because signal update
    count.set(1);
    await new Promise(requestAnimationFrame);
    el.querySelectorAll('p').forEach((p) => {
      assert.equal(p.textContent, 'count: 1', 'B');
    });
    count.set(2);
    await new Promise(requestAnimationFrame);
    el.querySelectorAll('p').forEach((p) => {
      assert.equal(p.textContent, 'count: 2', 'C');
    });
  });

  test('can mix signals in render with watch', async () => {
    let renderCount = 0;
    const count = signal(0);
    const renderSignal = signal(0);
    class TestElement extends SignalWatcher(LitElement) {
      override render() {
        renderCount++;
        return html`<p>count: ${watch(count)}</p>
          <div>renderSignal: ${renderSignal.get()}</div>`;
      }
    }
    customElements.define(generateElementName(), TestElement);
    const el = new TestElement();
    container.append(el);

    // The first DOM update is because of an element render
    await el.updateComplete;
    assert.equal(
      el.shadowRoot?.querySelector('p')?.textContent,
      'count: 0',
      'A'
    );
    assert.equal(
      el.shadowRoot?.querySelector('div')?.textContent,
      'renderSignal: 0',
      'A'
    );
    assert.equal(renderCount, 1);
    assert.isFalse(el.isUpdatePending);

    // The DOM updates because signal update
    count.set(1);
    assert.isFalse(el.isUpdatePending);
    await el.updateComplete;
    assert.equal(
      el.shadowRoot?.querySelector('p')?.textContent,
      'count: 1',
      'B'
    );
    assert.equal(
      el.shadowRoot?.querySelector('div')?.textContent,
      'renderSignal: 0',
      'B'
    );
    // The updated DOM is not because of an element render
    assert.equal(renderCount, 1);
    renderSignal.set(1);
    assert.isTrue(el.isUpdatePending);
    await el.updateComplete;
    assert.equal(
      el.shadowRoot?.querySelector('p')?.textContent,
      'count: 1',
      'C'
    );
    assert.equal(
      el.shadowRoot?.querySelector('div')?.textContent,
      'renderSignal: 1',
      'C'
    );
    // The updated DOM is because of an element render
    assert.equal(renderCount, 2);
    // Update watch signal again, after a render signal update
    count.set(2);
    assert.isFalse(el.isUpdatePending);
    await el.updateComplete;
    assert.equal(
      el.shadowRoot?.querySelector('p')?.textContent,
      'count: 2',
      'B'
    );
    assert.equal(
      el.shadowRoot?.querySelector('div')?.textContent,
      'renderSignal: 1',
      'B'
    );
  });

  test('updateEffect + watch', async () => {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    let renderCount = 0;
    const count = signal(0);
    class TestElement extends SignalWatcher(LitElement) {
      override render() {
        renderCount++;
        return html`<p>count: ${watch(count)}</p>`;
      }
    }
    customElements.define(generateElementName(), TestElement);
    const el = new TestElement();
    container.append(el);

    // The first DOM update is because of an element render
    await el.updateComplete;
    let effectBeforeValue = -1;
    let effectBeforeDom: string | null | undefined = '';
    const disposeEffectBefore = el.updateEffect(
      () => {
        effectBeforeValue = count.get();
        effectBeforeDom = el.shadowRoot?.querySelector('p')?.textContent;
      },
      {beforeUpdate: true}
    );
    let effectValue = -1;
    let effectDom: string | null | undefined = '';
    const disposeEffect = el.updateEffect(() => {
      effectValue = count.get();
      effectDom = el.shadowRoot?.querySelector('p')?.textContent;
    });
    await el.updateComplete;
    assert.equal(effectBeforeValue, 0);
    assert.equal(effectBeforeDom, 'count: 0');
    assert.equal(effectValue, 0);
    assert.equal(effectDom, 'count: 0');

    count.set(1);
    await el.updateComplete;
    assert.equal(effectBeforeValue, 1);
    assert.equal(effectBeforeDom, 'count: 0');
    assert.equal(effectValue, 1);
    assert.equal(effectDom, 'count: 1');
    disposeEffect();
    disposeEffectBefore();

    count.set(2);
    assert.equal(effectBeforeValue, 1);
    assert.equal(effectBeforeDom, 'count: 0');
    assert.equal(effectValue, 1);
    assert.equal(effectDom, 'count: 1');
  });

  test('unsubscribes to a signal on element disconnect', async () => {
    let readCount = 0;
    const count = signal(0);
    const countPlusOne = computed(() => {
      readCount++;
      return count.get() + 1;
    });

    class TestElement extends SignalWatcher(LitElement) {
      override render() {
        return html`<p>count: ${watch(countPlusOne)}</p>`;
      }
    }
    customElements.define(generateElementName(), TestElement);
    const el = new TestElement();
    container.append(el);

    // First render, expect one read of the signal
    await el.updateComplete;
    assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1');
    assert.equal(readCount, 1);

    // Force the directive to disconnect
    el.remove();
    await el.updateComplete;

    // Expect no reads while disconnected
    count.set(1);
    assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1');
    assert.equal(readCount, 1);

    // Even after an update
    await el.updateComplete;
    assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1');
    assert.equal(readCount, 1);

    // Force the directive to reconnect
    container.append(el);
    // SignalWatcher automatically renders when re-connected
    assert.isTrue(el.isUpdatePending);

    // So when reconnected, we read the signal value again
    await el.updateComplete;
    assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 2');
    assert.equal(readCount, 2);

    // And signal updates propagate again
    count.set(2);
    await el.updateComplete;
    assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 3');
    assert.equal(readCount, 3);
  });

  test('unsubscribes to a signal on directive disconnect', async () => {
    let readCount = 0;
    const count = signal(0);
    const countPlusOne = computed(() => {
      readCount++;
      return count.get() + 1;
    });

    class TestElement extends SignalWatcher(LitElement) {
      @property() renderWithSignal = true;

      signalTemplate = html`${watch(countPlusOne)}`;

      stringTemplate = html`string`;

      override render() {
        const t = this.renderWithSignal
          ? this.signalTemplate
          : this.stringTemplate;
        // Cache the expression so that we preserve the directive instance
        // and trigger the reconnected code-path.
        // TODO (justinfagnani): it would be nice if we could assert that we
        // really did trigger reconnected instead of rendering a new directive,
        // but we don't want to code the directive to specifically leave a trace
        // of reconnected-ness.
        return html`<p>value: ${cache(t)}</p>`;
      }
    }
    customElements.define(generateElementName(), TestElement);
    const el = new TestElement();
    container.append(el);

    // First render with the signal, expect one read of the signal
    await el.updateComplete;
    assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'value: 1');
    assert.equal(readCount, 1);

    // Render with a non-signal
    el.renderWithSignal = false;
    await el.updateComplete;

    // Expect no reads while disconnected
    count.set(1);
    assert.equal(
      el.shadowRoot?.querySelector('p')?.textContent,
      'value: string'
    );
    assert.equal(readCount, 1);

    // Render with the signal again
    el.renderWithSignal = true;
    await el.updateComplete;

    // Render should use the new value
    assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'value: 2');
    assert.equal(readCount, 2);

    // And signal updates propagate again
    count.set(2);
    await el.updateComplete;
    assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'value: 3');
    assert.equal(readCount, 3);
  });

  test('does not trigger an element update', async () => {
    let renderCount = 0;
    const count = signal(0);
    class TestElement extends SignalWatcher(LitElement) {
      override render() {
        renderCount++;
        return html`<p>count: ${watch(count)}</p>`;
      }
    }
    customElements.define(generateElementName(), TestElement);
    const el = new TestElement();
    container.append(el);

    await el.updateComplete;
    assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 0');
    assert.equal(renderCount, 1);

    count.set(1);
    await el.updateComplete;
    assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1');
    // The updated DOM is not because of an element render
    assert.equal(renderCount, 1, 'A');
    // The signal update does not trigger a render
    assert.equal(el.isUpdatePending, false);
  });
});
