import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { useLayoutState } from '../src/platform/features/admin/components/layout/useLayoutState';
import { fieldRemovalScope, sectionRemovalScope } from '../src/platform/features/admin/components/layout/removalScope';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const useLayoutStateSource = readFileSync(
    path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/platform/features/admin/components/layout/useLayoutState.ts'),
    'utf8',
);
import { fieldDisplayLabel } from '../src/platform/features/admin/components/layout/fieldDisplayLabel';
import type { PageLayout, SObjectDescribe } from '../src/platform/types';

// app-packs#679: the preview lens implies that what you do on the canvas is
// scoped to the lens. Exactly one control honours that — the row's eye. The ✕
// beside it takes the field out of the layout regardless of the lens, and until
// this change it did so with no confirmation at all, while the eye one pixel
// away was per-mode.
//
// These tests own the gate and the claim the dialogs are allowed to make. They
// do NOT pin wording: three review rounds falsified three different attempts to
// enumerate which forms a removal reaches, so what is pinned is that the
// enumeration stays gone and the create hint stays hedged.

const layout = (): PageLayout => ({
    id: 'curated_Account_default',
    objectApiName: 'Account',
    layoutName: 'Account Layout',
    sections: [
        { id: 's1', label: 'Info', columns: 2, fields: ['Name', 'Industry'] },
        { id: 's2', label: 'More', columns: 2, fields: ['Website'] },
    ],
} as unknown as PageLayout);

const metadata = {
    name: 'Account',
    fields: [
        { name: 'Name', label: 'Account Name', type: 'string' },
        { name: 'Industry', label: 'Industry', type: 'picklist' },
        { name: 'Website', label: 'Website', type: 'string' },
    ],
} as unknown as SObjectDescribe;

// The argument object is built ONCE per mount and closed over, never rebuilt
// inside the render callback. Passing `existingLayout: layout()` inline hands
// the hook a new object identity on every render, its sync effect re-runs
// forever, and the worker dies with a heap exhaustion rather than a test
// failure — which reads as an environment problem instead of a fixture bug.
const mount = () => {
    const args = {
        objectApiName: 'Account',
        metadata,
        existingLayout: layout(),
        layoutLoading: false,
        layoutError: null,
    };
    return renderHook(() => useLayoutState(args));
};

const fieldsOf = (result: { current: { layout: PageLayout | null } }) =>
    (result.current.layout?.sections || []).map(s => [...(s.fields || [])]);

describe('removing a field asks first', () => {
    it('does not touch the layout on the click alone', () => {
        const { result } = mount();

        act(() => result.current.handleRemoveField('s1', 'Industry'));

        expect(fieldsOf(result)).toEqual([['Name', 'Industry'], ['Website']]);
        expect(result.current.showRemoveFieldModal).toBe(true);
        expect(result.current.fieldToRemove).toBe('Industry');
    });

    it('removes exactly the confirmed field', () => {
        const { result } = mount();

        act(() => result.current.handleRemoveField('s1', 'Industry'));
        act(() => result.current.confirmRemoveField());

        expect(fieldsOf(result)).toEqual([['Name'], ['Website']]);
        expect(result.current.showRemoveFieldModal).toBe(false);
    });

    it('leaves the layout alone when the admin backs out', () => {
        const { result } = mount();

        act(() => result.current.handleRemoveField('s1', 'Industry'));
        act(() => result.current.setShowRemoveFieldModal(false));

        expect(fieldsOf(result)).toEqual([['Name', 'Industry'], ['Website']]);
        expect(result.current.showRemoveFieldModal).toBe(false);
    });

    it('does not let the first target survive into a second removal', () => {
        // The failure this guards: confirmation state closed over at click time,
        // or not cleared between two removals, deletes the field the admin
        // removed a moment ago instead of the one in front of them. Two
        // different sections on purpose — a stale sectionId is as wrong as a
        // stale field name and would not show up within one section.
        const { result } = mount();

        act(() => result.current.handleRemoveField('s1', 'Name'));
        act(() => result.current.confirmRemoveField());
        act(() => result.current.handleRemoveField('s2', 'Website'));
        act(() => result.current.confirmRemoveField());

        expect(fieldsOf(result)).toEqual([['Industry'], []]);
    });

    it('ignores a confirm that belongs to a different modal', () => {
        const { result } = mount();

        const before = result.current.layout;
        act(() => result.current.handleRemoveSection('s1'));
        act(() => result.current.confirmRemoveField());

        // Identity, not deep equality. `removeFieldFromLayout` builds a NEW
        // object even when it filters nothing, and no ActiveModal member other
        // than removeField carries a fieldApiName — so running the transform
        // with an undefined target is a no-op that a deep-equal assertion
        // cannot tell apart from the guard refusing. Dropping the guard on the
        // destructive path alone left this file green until this line.
        expect(result.current.layout).toBe(before);
        expect(result.current.showRemoveSectionModal).toBe(true);
    });

    it('ignores a confirm with no modal open at all', () => {
        const { result } = mount();

        const before = result.current.layout;
        act(() => result.current.confirmRemoveField());

        expect(result.current.layout).toBe(before);
    });

    it('reports which section the pending removal belongs to', () => {
        // The dialog needs the section, not just the field: an expansion's
        // authored label is stored on the section it sits in.
        const { result } = mount();

        act(() => result.current.handleRemoveField('s2', 'Website'));

        expect(result.current.fieldToRemove).toBe('Website');
        expect(result.current.fieldToRemoveSectionId).toBe('s2');
    });

    it('drops a pending removal when the layout is replaced under it', () => {
        // Two stable argument objects, swapped by a prop — NOT rebuilt per
        // render. Rebuilding is what makes the sync effect loop forever; see
        // the note on `mount`. Swapping identity once is exactly the re-sync
        // being modelled.
        const first = {
            objectApiName: 'Account',
            metadata,
            existingLayout: layout(),
            layoutLoading: false,
            layoutError: null,
        };
        const second = { ...first, existingLayout: layout() };
        const { result, rerender } = renderHook(
            ({ swapped }: { swapped: boolean }) => useLayoutState(swapped ? second : first),
            { initialProps: { swapped: false } },
        );

        act(() => result.current.handleRemoveField('s1', 'Name'));
        expect(result.current.showRemoveFieldModal).toBe(true);

        // A re-sync while the dialog is open. The captured target may no longer
        // exist, and confirming it would filter nothing while the modal closed
        // as if it had succeeded — a silent no-op the admin reads as a completed
        // removal. The confirmation is dropped instead, so they act on what is
        // now on screen.
        act(() => rerender({ swapped: true }));

        expect(result.current.showRemoveFieldModal).toBe(false);
    });
});

describe('what a removal can honestly be said to reach', () => {
    // These ask whether the confirmation's claim is TRUE, which a source pin
    // over prose cannot. Three rounds falsified the earlier "it leaves all
    // three views" sentence three different ways, so the dialogs no longer
    // enumerate surfaces at all — see removalScope for why the client cannot
    // know that. What is left is a hedged hint, and these pin the hedge.

    it('warns when a removed field has a server create requirement', () => {
        expect(fieldRemovalScope('Name', ['Name'])).toEqual({ requiredOnCreate: true });
    });

    it('stays quiet for a field the create path does not demand', () => {
        expect(fieldRemovalScope('Website', ['Name'])).toEqual({ requiredOnCreate: false });
    });

    it('stays quiet when the demand set is unknown, rather than guessing', () => {
        // undefined is "unknown", including older saved snapshots. Showing
        // an unverifiable hint would be the same overclaim in a smaller font;
        // showing nothing claims nothing in either direction.
        expect(fieldRemovalScope('Name', undefined)).toEqual({ requiredOnCreate: false });
    });

    it('counts the fields a section would carry that warning for', () => {
        expect(sectionRemovalScope(['Name', 'Industry', 'Website'], ['Name', 'Website']))
            .toMatchObject({ demandedCount: 2 });
    });

    it('counts none when the demand set is unknown', () => {
        expect(sectionRemovalScope(['Name'], undefined)).toMatchObject({ demandedCount: 0 });
    });

    it('flags a lookup expansion, which the palette cannot give back', () => {
        // availableFields is built from metadata.fields, which never contains a
        // dotted entry, so "its fields go back to the palette" is false for one.
        expect(sectionRemovalScope(['Name', 'OwnerId.Name'], []).hasLookupExpansion).toBe(true);
        expect(sectionRemovalScope(['Name'], []).hasLookupExpansion).toBe(false);
    });
});

describe('the dialog names its target the way the canvas does', () => {
    // The dialog's only job is "is this the one you meant", and it cannot do
    // that while naming the target by a string the admin is not looking at.
    // Codex found the gap on dotted expansions: metadata has no entry for
    // `OwnerId.Name`, so a plain lookup fell back to the api path while the row
    // showed a derived label — and the comment justifying that fallback claimed
    // it kept the authored text, which the code never did.
    const fields = [
        { name: 'Name', label: 'Account Name' },
        { name: 'OwnerId', label: 'Owner' },
    ];

    it('uses the metadata label for a plain field', () => {
        expect(fieldDisplayLabel('Name', null, fields)).toBe('Account Name');
    });

    it('falls back to the api name when metadata does not describe it', () => {
        expect(fieldDisplayLabel('Mystery__c', null, fields)).toBe('Mystery__c');
    });

    it('derives an expansion label from the parent, not the api path', () => {
        expect(fieldDisplayLabel('OwnerId.Name', null, fields)).toBe('Owner → Name');
    });

    it('prefers the label authored on the section for an expansion', () => {
        const section = { fieldLabels: { 'OwnerId.Name': 'Account Owner' } };
        expect(fieldDisplayLabel('OwnerId.Name', section, fields)).toBe('Account Owner');
    });

    it('uses the parent api name when the parent is undescribed', () => {
        expect(fieldDisplayLabel('Custom__r.Name', null, fields)).toBe('Custom__r → Name');
    });

    it('ignores an authored override on a plain field, as the canvas does', () => {
        // The canvas applies fieldLabels inside its expansion branch only. If
        // this function were more generous than the row it mirrors, the two
        // would disagree again in the other direction.
        const section = { fieldLabels: { Name: 'Should Not Win' } };
        expect(fieldDisplayLabel('Name', section, fields)).toBe('Account Name');
    });
});

describe('the editor offers no control that authors state nothing renders', () => {
    // The section header carried an eye labelled "Set Visibility Rules". It set
    // an activeModal member that NO component rendered anywhere in the app, so
    // clicking it produced nothing at all -- no dialog, no feedback, no way to
    // author a condition. Production agrees: across every stored layout, not one
    // section carries a visibilityCondition, because there was never a route to
    // create one. The single stored condition in the whole org is on a record
    // panel, which is a different mechanism and works.
    //
    // Asserted on the hook's own contract rather than on source text, so a
    // reinstated button that reuses different identifiers still fails here.
    it('exposes no visibility-editor state', () => {
        const { result } = mount();
        const api = result.current as Record<string, unknown>;

        for (const key of [
            'openVisibilityEditor',
            'visibilityEditorOpen',
            'setVisibilityEditorOpen',
            'selectedSectionForVisibility',
            'tempVisibilityCondition',
            'setTempVisibilityCondition',
        ]) {
            expect(api).not.toHaveProperty(key);
        }
    });

    it('keeps the stored condition in the layout it round-trips', () => {
        // Removing the CONTROL must not remove the FIELD: record panels honour
        // visibilityCondition today, and an existing section value has to
        // survive an editor session untouched rather than be dropped on save.
        const { result } = mount();
        expect(result.current.layout).not.toBeNull();
        expect('visibilityCondition' in (result.current.layout!.sections[0] as object)).toBe(false);
        // The fixture authors none; what matters is that nothing in the hook
        // deletes the key from sections that do carry it.
        expect(String(useLayoutStateSource)).not.toMatch(/delete\s+\w*\.?visibilityCondition/);
    });
});
