import { Component, Input } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
  DataSourceEditor,
  DataSourceType,
  InterfaceDesignerSchemaService,
  ModelBindingValueViewModelAttributeEditor,
  SchemaEditor,
  SchemaModelEditor,
  SchemaViewModelEditor,
  ViewModelAttributeEditor,
  ViewModelAttributeType,
  ViewNodeEditor,
  ViewNodePropertyValue,
  ViewNodePropertyValueType,
} from '@creatio/interface-designer';

import { RatingPanelComponent } from './rating-panel.component';

@Component({
  selector: 'crt-interface-designer-properties-panel-wrapper',
  standalone: false,
  template: '<ng-content></ng-content>',
})
class PropertiesPanelWrapperStubComponent {
  @Input() public headerTitle?: string;
  @Input() public showHeader?: boolean;
  @Input() public canShowTooltip?: boolean;
}

describe('RatingPanelComponent', () => {
  let fixture: ComponentFixture<RatingPanelComponent>;
  let component: RatingPanelComponent;

  function createSchemaEditorMock(): SchemaEditor {
    return {
      modelEditor: {
        getDataSourceEditor: jest.fn().mockResolvedValue(undefined),
      } as Partial<SchemaModelEditor>,
      viewModelEditor: {
        canRemoveAttribute: jest.fn().mockResolvedValue(true),
        createAttribute: jest.fn().mockResolvedValue(undefined),
        getAttributeEditor: jest.fn().mockResolvedValue(undefined),
        removeAttribute: jest.fn().mockResolvedValue(undefined),
      } as Partial<SchemaViewModelEditor>,
    } as SchemaEditor;
  }

  function createComponent(schemaEditor: SchemaEditor): void {
    jest.spyOn(InterfaceDesignerSchemaService.prototype, 'getSchemaEditor').mockReturnValue(schemaEditor);

    fixture = TestBed.createComponent(RatingPanelComponent);
    component = fixture.componentInstance;
  }

  function createViewNodeEditorMock(propertyValues: Record<string, ViewNodePropertyValue | undefined>): ViewNodeEditor {
    return {
      nodeName: 'RatingNode1',
      getPropertyValue: jest.fn((propertyName: string) => Promise.resolve(propertyValues[propertyName])),
      setPropertyValue: jest.fn().mockResolvedValue(undefined),
    } as unknown as ViewNodeEditor;
  }

  function constant(value: unknown): ViewNodePropertyValue {
    return { type: ViewNodePropertyValueType.Constant, value } as ViewNodePropertyValue;
  }

  function attributeBinding(attributePath: string): ViewNodePropertyValue {
    return { type: ViewNodePropertyValueType.AttributeBinding, attributePath } as ViewNodePropertyValue;
  }

  async function initWithViewNodeEditor(
    propertyValues: Record<string, ViewNodePropertyValue | undefined>,
    schemaEditor: SchemaEditor = createSchemaEditorMock(),
  ): Promise<ViewNodeEditor> {
    createComponent(schemaEditor);

    const viewNodeEditor = createViewNodeEditorMock(propertyValues);
    component.viewNodeEditor = viewNodeEditor;
    await jest.runAllTimersAsync();
    fixture.detectChanges();

    return viewNodeEditor;
  }

  function getInput(id: string): HTMLInputElement {
    const input = fixture.nativeElement.querySelector(`#${id}`) as HTMLInputElement | null;
    if (!input) {
      throw new Error(`Input with id '${id}' not found`);
    }
    return input;
  }

  function getSelect(id: string): HTMLSelectElement {
    const select = fixture.nativeElement.querySelector(`#${id}`) as HTMLSelectElement | null;
    if (!select) {
      throw new Error(`Select with id '${id}' not found`);
    }
    return select;
  }

  async function setInputValue(id: string, value: string, eventType: 'input' | 'change' = 'change'): Promise<void> {
    const input = getInput(id);
    input.value = value;
    input.dispatchEvent(new Event(eventType));
    fixture.detectChanges();
    await jest.runAllTimersAsync();
    fixture.detectChanges();
  }

  async function setSelectValue(id: string, value: string): Promise<void> {
    const select = getSelect(id);
    select.value = value;
    select.dispatchEvent(new Event('change'));
    fixture.detectChanges();
    await jest.runAllTimersAsync();
    fixture.detectChanges();
  }

  beforeEach(async () => {
    jest.useFakeTimers({ legacyFakeTimers: false });

    await TestBed.configureTestingModule({
      declarations: [RatingPanelComponent, PropertiesPanelWrapperStubComponent],
    }).compileComponents();
  });

  afterEach(() => {
    jest.restoreAllMocks();
    jest.useRealTimers();
  });

  it('should create', () => {
    createComponent(createSchemaEditorMock());
    expect(component).toBeDefined();
  });

  describe('panel readiness', () => {
    it('should not render panel content until ready', () => {
      createComponent(createSchemaEditorMock());
      fixture.detectChanges();

      const wrapper = fixture.nativeElement.querySelector('crt-interface-designer-properties-panel-wrapper');
      expect(wrapper).toBeNull();
    });

    it('should render panel content once viewNodeEditor data has loaded', async () => {
      await initWithViewNodeEditor({
        starCount: constant(5),
        ratingValue: constant(0),
      });

      const wrapper = fixture.nativeElement.querySelector('crt-interface-designer-properties-panel-wrapper');
      expect(wrapper).not.toBeNull();
    });
  });

  describe('loading state from viewNodeEditor', () => {
    it('should load a constant rating value into the constant field', async () => {
      await initWithViewNodeEditor({
        starCount: constant(5),
        ratingValue: constant(3),
      });

      expect(getInput('rating-value-constant').value).toBe('3');
      expect(getSelect('rating-source').value).toBe('constant');
    });

    it('should load a data-source-bound rating value into the data source fields', async () => {
      const schemaEditor = createSchemaEditorMock();
      (schemaEditor.viewModelEditor.getAttributeEditor as jest.Mock).mockResolvedValue({
        attributeType: ViewModelAttributeType.ModelBindingValue,
        getModelBinding: jest
          .fn()
          .mockResolvedValue({ dataSourceName: 'PDS', dataSourceAttributePath: 'UsrRating' }),
      } as Partial<ModelBindingValueViewModelAttributeEditor>);

      await initWithViewNodeEditor(
        {
          starCount: constant(5),
          ratingValue: attributeBinding('RatingNode1_Rating'),
        },
        schemaEditor,
      );

      expect(getSelect('rating-source').value).toBe('dataSource');
      expect(getInput('rating-ds-name').value).toBe('PDS');
      expect(getInput('rating-ds-attribute').value).toBe('UsrRating');
    });
  });

  describe('star count validation', () => {
    it('should block applying an out-of-range star count (0)', async () => {
      const viewNodeEditor = await initWithViewNodeEditor({ starCount: constant(5), ratingValue: constant(0) });

      await setInputValue('rating-star-count', '0');

      expect(viewNodeEditor.setPropertyValue).not.toHaveBeenCalledWith('starCount', expect.anything());
      expect(fixture.nativeElement.querySelector('[data-testid="rating-panel-validation"]')?.textContent).toContain(
        'between 1 and 10',
      );
    });

    it('should block applying an out-of-range star count (11)', async () => {
      const viewNodeEditor = await initWithViewNodeEditor({ starCount: constant(5), ratingValue: constant(0) });

      await setInputValue('rating-star-count', '11');

      expect(viewNodeEditor.setPropertyValue).not.toHaveBeenCalledWith('starCount', expect.anything());
    });

    it('should apply a valid star count and clear the validation message', async () => {
      const viewNodeEditor = await initWithViewNodeEditor({ starCount: constant(5), ratingValue: constant(0) });

      await setInputValue('rating-star-count', '8');

      expect(viewNodeEditor.setPropertyValue).toHaveBeenCalledWith('starCount', { constant: 8 });
      expect(fixture.nativeElement.querySelector('[data-testid="rating-panel-validation"]')).toBeNull();
    });
  });

  describe('rating value (constant mode) validation', () => {
    it('should block a rating value below 0', async () => {
      const viewNodeEditor = await initWithViewNodeEditor({ starCount: constant(5), ratingValue: constant(0) });

      await setInputValue('rating-value-constant', '-1');

      expect(viewNodeEditor.setPropertyValue).not.toHaveBeenCalledWith('ratingValue', expect.anything());
    });

    it('should block a rating value above starCount', async () => {
      const viewNodeEditor = await initWithViewNodeEditor({ starCount: constant(5), ratingValue: constant(0) });

      await setInputValue('rating-value-constant', '6');

      expect(viewNodeEditor.setPropertyValue).not.toHaveBeenCalledWith('ratingValue', expect.anything());
    });

    it('should apply a valid rating value', async () => {
      const viewNodeEditor = await initWithViewNodeEditor({ starCount: constant(5), ratingValue: constant(0) });

      await setInputValue('rating-value-constant', '4');

      expect(viewNodeEditor.setPropertyValue).toHaveBeenCalledWith('ratingValue', { constant: 4 });
    });
  });

  describe('rating source: constant -> data source', () => {
    it('should not bind when only the data source name is filled', async () => {
      const viewNodeEditor = await initWithViewNodeEditor({ starCount: constant(5), ratingValue: constant(0) });

      await setSelectValue('rating-source', 'dataSource');
      await setInputValue('rating-ds-name', 'PDS', 'input');

      expect(viewNodeEditor.setPropertyValue).not.toHaveBeenCalledWith('ratingValue', {
        bindToAttribute: expect.anything(),
      });
      expect(fixture.nativeElement.querySelector('[data-testid="rating-panel-validation"]')).toBeNull();
    });

    it('should mark the data source fields as required instead of showing a persistent message', async () => {
      await initWithViewNodeEditor({ starCount: constant(5), ratingValue: constant(0) });

      await setSelectValue('rating-source', 'dataSource');

      expect(getInput('rating-ds-name').required).toBe(true);
      expect(getInput('rating-ds-attribute').required).toBe(true);
      expect(fixture.nativeElement.querySelector('[data-testid="rating-panel-validation"]')).toBeNull();
    });

    it('should create and bind a view model attribute once both data source fields are filled', async () => {
      const schemaEditor = createSchemaEditorMock();
      (schemaEditor.modelEditor.getDataSourceEditor as jest.Mock).mockResolvedValue({
        dataSourceType: DataSourceType.EntityDataSource,
      } as Partial<DataSourceEditor>);

      const viewNodeEditor = await initWithViewNodeEditor(
        { starCount: constant(5), ratingValue: constant(0) },
        schemaEditor,
      );

      await setSelectValue('rating-source', 'dataSource');
      await setInputValue('rating-ds-name', 'PDS', 'input');
      await setInputValue('rating-ds-attribute', 'UsrRating', 'input');

      expect(schemaEditor.viewModelEditor.createAttribute).toHaveBeenCalledWith('RatingNode1_Rating', {
        bindToModel: { dataSourceName: 'PDS', dataSourceAttributePath: 'UsrRating' },
      });
      expect(viewNodeEditor.setPropertyValue).toHaveBeenCalledWith('ratingValue', {
        bindToAttribute: 'RatingNode1_Rating',
      });
    });
  });

  describe('rating source: data source -> constant', () => {
    it('should unbind the existing view model attribute before applying the constant value', async () => {
      const schemaEditor = createSchemaEditorMock();
      (schemaEditor.viewModelEditor.getAttributeEditor as jest.Mock).mockResolvedValue({
        attributeType: ViewModelAttributeType.ModelBindingValue,
        getModelBinding: jest
          .fn()
          .mockResolvedValue({ dataSourceName: 'PDS', dataSourceAttributePath: 'UsrRating' }),
      } as Partial<ViewModelAttributeEditor>);

      const viewNodeEditor = await initWithViewNodeEditor(
        { starCount: constant(5), ratingValue: attributeBinding('RatingNode1_Rating') },
        schemaEditor,
      );

      await setSelectValue('rating-source', 'constant');

      expect(viewNodeEditor.setPropertyValue).toHaveBeenCalledWith('ratingValue', { constant: null });
      expect(schemaEditor.viewModelEditor.canRemoveAttribute).toHaveBeenCalledWith('RatingNode1_Rating');
      expect(schemaEditor.viewModelEditor.removeAttribute).toHaveBeenCalledWith('RatingNode1_Rating');
      expect(viewNodeEditor.setPropertyValue).toHaveBeenCalledWith('ratingValue', { constant: 0 });
    });
  });

  describe('boolean and enum fields', () => {
    it('should apply the size change immediately', async () => {
      const viewNodeEditor = await initWithViewNodeEditor({ starCount: constant(5), ratingValue: constant(0) });

      await setSelectValue('rating-size', 'large');

      expect(viewNodeEditor.setPropertyValue).toHaveBeenCalledWith('size', { constant: 'large' });
    });

    it('should apply the visible checkbox change immediately', async () => {
      const viewNodeEditor = await initWithViewNodeEditor({ starCount: constant(5), ratingValue: constant(0) });

      const hostElement = fixture.nativeElement as HTMLElement;
      const checkboxes = hostElement.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
      const visibleCheckbox = Array.from(checkboxes).find((checkbox) =>
        checkbox.closest('label')?.textContent?.includes('Visible'),
      );
      if (!visibleCheckbox) {
        throw new Error('Visible checkbox not found');
      }

      visibleCheckbox.checked = false;
      visibleCheckbox.dispatchEvent(new Event('change'));
      fixture.detectChanges();
      await jest.runAllTimersAsync();

      expect(viewNodeEditor.setPropertyValue).toHaveBeenCalledWith('visible', { constant: false });
    });
  });
});
