import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { EMPTY_CANVAS, PageProvider, usePages } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { useWhpApi } from './useWhpApi';
import { WhpConfig } from '../types';

/**
 * Task 13b: `PageContext.loadState` runs every stored craft state through
 * `repairOrphanNodes` before handing it to `actions.deserialize` -- that
 * covers every page SWITCH (see `PageContext.orphan-repair-wiring.test.tsx`,
 * the model for this file). But the INITIAL load -- `useWhpApi`'s `load()`,
 * fired once on mount by `TopBar.tsx` -- used to call
 * `actions.deserialize(state)` directly on the first page's stored
 * `craftState`, bypassing repair entirely. A site whose saved state
 * contains a node unreachable from ROOT would get it silently repaired on
 * the NEXT page switch but not on the load that actually renders it first.
 *
 * This mocks `@craftjs/core` the same way `useWhpApi.load.test.tsx` and
 * `PageContext.orphan-repair-wiring.test.tsx` do, and asserts on the exact
 * string handed to the mocked `actions.deserialize` -- the orphan must be
 * reattached to ROOT and a `console.error` must fire, exactly like the
 * page-switch path (I5 review: this was `console.warn`, which
 * console-buffer.ts -- feeding the in-builder issue reporter -- does not
 * capture).
 */
const deserializeMock = vi.fn();
vi.mock('@craftjs/core', () => ({
  useEditor: () => ({
    query: { serialize: () => '{}' },
    actions: { deserialize: deserializeMock },
  }),
}));

const whpConfig: WhpConfig = {
  user: 'testuser',
  apiUrl: '/panel/api/site-builder',
  csrfToken: 'tok',
  siteId: 42,
  siteDomain: 'example.com',
  siteName: 'Test Site',
  backUrl: '/panel/sites',
  isRoot: false,
};

let container: HTMLDivElement;
let root: Root;

interface Captured {
  load: ReturnType<typeof useWhpApi>['load'];
  pages: ReturnType<typeof usePages>['pages'];
}

function render(): { get: () => Captured } {
  container = document.createElement('div');
  document.body.appendChild(container);
  let captured: Captured | null = null;

  const Consumer: React.FC = () => {
    const { load } = useWhpApi();
    const { pages } = usePages();
    captured = { load, pages };
    return null;
  };

  act(() => {
    root = createRoot(container);
    root.render(
      <EditorConfigProvider config={whpConfig}>
        <SiteDesignProvider>
          <PageProvider>
            <Consumer />
          </PageProvider>
        </SiteDesignProvider>
      </EditorConfigProvider>,
    );
  });

  return { get: () => captured! };
}

function unmount() {
  act(() => {
    root.unmount();
  });
  container.remove();
}

/** Same shape as `PageContext.orphan-repair-wiring.test.tsx`'s ORPHAN_STATE:
 *  a ROOT with no children plus an orphan ('stray') whose `parent` points at
 *  an id that doesn't exist in the tree, and which no node's `nodes`/
 *  `linkedNodes` lists -- unreachable by BFS from ROOT. */
const ORPHAN_STATE = JSON.stringify({
  ROOT: {
    type: { resolvedName: 'Container' },
    isCanvas: true,
    props: { style: {}, tag: 'div' },
    displayName: 'Container',
    custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null,
  },
  stray: {
    type: { resolvedName: 'HtmlBlock' },
    isCanvas: false,
    props: { code: '<p>stranded</p>', style: {} },
    displayName: 'HTML',
    custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
  },
});

describe('useWhpApi load() repairs an orphaned node on the FIRST page before deserializing', () => {
  beforeEach(() => {
    deserializeMock.mockClear();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  test('initial load with an orphaned first-page craftState reattaches it and warns', async () => {
    const fetchMock = vi.fn().mockResolvedValue({
      json: async () => ({
        success: true,
        project: {
          design: null,
          header_craft_state: null,
          footer_craft_state: null,
          pages_craft_state: [
            { id: 'home', name: 'Home', slug: 'index', craftState: ORPHAN_STATE },
          ],
        },
      }),
    });
    vi.stubGlobal('fetch', fetchMock);

    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const harness = render();

    await act(async () => {
      await harness.get().load();
    });

    expect(deserializeMock).toHaveBeenCalled();
    const passedState = deserializeMock.mock.calls[deserializeMock.mock.calls.length - 1][0];
    const parsed = JSON.parse(passedState);
    // The orphan is now an ordinary, reachable child of ROOT.
    expect(parsed.ROOT.nodes).toContain('stray');
    expect(parsed.stray.parent).toBe('ROOT');

    // The observable signal that repair actually ran, not just that the
    // orphan happened to be absent for some unrelated reason. (React's own
    // act()-environment warnings also go through console.error in this
    // harness, so search all calls rather than assuming index 0.)
    expect(errorSpy.mock.calls.some((call) => String(call[0]).includes('reattached'))).toBe(true);

    errorSpy.mockRestore();
    unmount();
  });
});

/**
 * I5 (review): `PageContext.loadState`'s deserialize failure path logs via
 * `console.error` AND falls back to `EMPTY_CANVAS` so the user always ends
 * up with a working (if blank) editor. `useWhpApi.load()`'s equivalent path
 * used to be `console.warn` with NO fallback deserialize -- the initial
 * load, which decides whether the user sees a working editor at all, both
 * failed harder (silently leaving the Frame undeserialized) and reported
 * quieter than every subsequent page switch. This pins the aligned
 * behaviour.
 */
describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state cannot be deserialized', () => {
  beforeEach(() => {
    deserializeMock.mockClear();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  test('a deserialize failure on the first page logs console.error and retries with EMPTY_CANVAS', async () => {
    const fetchMock = vi.fn().mockResolvedValue({
      json: async () => ({
        success: true,
        project: {
          design: null,
          header_craft_state: null,
          footer_craft_state: null,
          pages_craft_state: [
            { id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{"broken":true}}' },
          ],
        },
      }),
    });
    vi.stubGlobal('fetch', fetchMock);

    deserializeMock.mockImplementationOnce(() => {
      throw new Error('malformed state');
    });

    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const harness = render();

    await act(async () => {
      await harness.get().load();
    });

    // First call (the broken state) threw; the second call is the fallback.
    expect(deserializeMock).toHaveBeenCalledTimes(2);
    expect(deserializeMock.mock.calls[1][0]).toBe(EMPTY_CANVAS);

    expect(errorSpy).toHaveBeenCalledWith('Failed to load page state:', expect.any(Error));

    errorSpy.mockRestore();
    unmount();
  });
});
