import { assert } from '@esm-bundle/chai'
import sinon from 'sinon'
import { fixture, html } from '@open-wc/testing'
import { FragmentManager, FragmentState } from '../../src/core/FragmentManager.js'
import { Fragment } from '../../src/core/Fragment.js'
import { Activity } from '../../src/core/Activity.js'
import { Application } from '../../src/core/Application.js'
import { nothing } from 'lit'

class TestFragment extends Fragment {
  onCreateSpy = sinon.spy(super.onCreate)
  onAttachSpy = sinon.spy(super.onAttach)
  onStartSpy = sinon.spy(super.onStart)
  onResumeSpy = sinon.spy(super.onResume)
  onPauseSpy = sinon.spy(super.onPause)
  onStopSpy = sinon.spy(super.onStop)
  onDetachSpy = sinon.spy(super.onDetach)
  onDestroySpy = sinon.spy(super.onDestroy)
  setRenderRootSpy = sinon.spy(super.setRenderRoot)
  requestUpdateSpy = sinon.spy(super.requestUpdate)
  renderSpy = sinon.spy(super.render)
  hasRequestCodeStub = sinon.stub().returns(false)
  rootClassStub = sinon.stub<[], string | undefined>()

  override onCreate(data?: unknown) {
    return this.onCreateSpy(data)
  }
  override onAttach(data?: unknown) {
    return this.onAttachSpy(data)
  }
  override onStart() {
    return this.onStartSpy()
  }
  override onResume() {
    return this.onResumeSpy()
  }
  override onPause() {
    return this.onPauseSpy()
  }
  override onStop() {
    return this.onStopSpy()
  }
  override onDetach() {
    return this.onDetachSpy()
  }
  override onDestroy() {
    return this.onDestroySpy()
  }
  override setRenderRoot(renderRoot: HTMLElement | string) {
    this.setRenderRootSpy(renderRoot)
  }
  override requestUpdate() {
    this.requestUpdateSpy()
  }
  override render() {
    return this.renderSpy()
  }
  override hasRequestCode(requestCode: number): boolean {
    return this.hasRequestCodeStub(requestCode)
  }
  override rootClass(): string | undefined {
    return this.rootClassStub()
  }

  resetSpies() {
    this.onCreateSpy.resetHistory()
    this.onAttachSpy.resetHistory()
    this.onStartSpy.resetHistory()
    this.onResumeSpy.resetHistory()
    this.onPauseSpy.resetHistory()
    this.onStopSpy.resetHistory()
    this.onDetachSpy.resetHistory()
    this.onDestroySpy.resetHistory()
    this.setRenderRootSpy.resetHistory()
    this.requestUpdateSpy.resetHistory()
    this.renderSpy.resetHistory()
    this.hasRequestCodeStub.reset()
    this.rootClassStub.reset()
    this.state = FragmentState.Initialized
  }
}

class TestActivity extends Activity {
  requestUpdateSpy = sinon.spy(super.requestUpdate)
  override requestUpdate() {
    this.requestUpdateSpy()
  }
}

class TestApplication extends Application {
  override async onStart(): Promise<void> {}
}

async function basicFixture(): Promise<HTMLElement> {
  return fixture(html`<div id="app-root"></div>`)
}

describe('FragmentManager', () => {
  let app: TestApplication
  let hostActivity: TestActivity
  let manager: FragmentManager

  beforeEach(async () => {
    const renderRoot = await basicFixture()
    app = new TestApplication(renderRoot)
    hostActivity = new TestActivity(app)
    manager = new FragmentManager(hostActivity)
    // Clear documentElement classes
    if (document.documentElement) {
      document.documentElement.className = ''
    }
  })

  afterEach(() => {
    sinon.restore()
  })

  describe('Constructor', () => {
    it('sets the host property', () => {
      assert.strictEqual(manager['host'], hostActivity)
    })
  })

  describe('onDestroy()', () => {
    it('calls onDestroy and removeFragmentStyles on all managed fragments and clears map', async () => {
      const fragment1 = new TestFragment()
      const fragment2 = new TestFragment()
      manager['fragments'].set('frag1', fragment1)
      manager['fragments'].set('frag2', fragment2)
      const removeStylesSpy1 = sinon.spy(manager, 'removeFragmentStyles')

      await manager.onDestroy()

      assert.isTrue(fragment1.onDestroySpy.calledOnce)
      assert.isTrue(fragment2.onDestroySpy.calledOnce)
      assert.isTrue(removeStylesSpy1.calledWith(fragment1))
      assert.isTrue(removeStylesSpy1.calledWith(fragment2))
      assert.equal(manager['fragments'].size, 0)
    })
  })

  describe('attachFragment()', () => {
    it('attaches a fragment and calls lifecycle methods', async () => {
      const fragment = new TestFragment()
      const parent = new TestActivity(app) // Can be activity or fragment
      const data = { test: 'data' }

      await manager.attachFragment('testKey', fragment, parent, data)

      assert.strictEqual(fragment.parent, parent, 'Fragment parent should be set')
      assert.strictEqual(manager['fragments'].get('testKey'), fragment, 'Fragment should be added to manager')
      assert.isTrue(fragment.onCreateSpy.calledOnceWith(data), 'onCreate should be called with data')
      assert.isTrue(fragment.onAttachSpy.calledOnceWith(data), 'onAttach should be called with data')
      assert.equal(fragment.state, FragmentState.Attached, 'Fragment state should be Attached')
    })
  })

  describe('detachFragment()', () => {
    it('detaches a fragment and calls lifecycle methods', async () => {
      const fragment = new TestFragment()
      manager['fragments'].set('testKey', fragment)
      const hideSpy = sinon.spy(manager, 'hideFragment')

      await manager.detachFragment('testKey')

      assert.isTrue(hideSpy.calledOnceWith(fragment), 'hideFragment should be called')
      assert.isTrue(fragment.onDetachSpy.calledOnce, 'onDetach should be called')
      assert.isTrue(fragment.onDestroySpy.calledOnce, 'onDestroy should be called')
      assert.equal(fragment.state, FragmentState.Destroyed, 'Fragment state should be Destroyed')
      assert.isFalse(manager['fragments'].has('testKey'), 'Fragment should be removed from manager')
      assert.isUndefined(fragment.parent, 'Fragment parent should be cleared')
    })

    it('throws if fragment not found', async () => {
      try {
        await manager.detachFragment('nonExistentKey')
        assert.fail('Should have thrown')
      } catch (e) {
        assert.equal((e as Error).message, 'Fragment with key nonExistentKey not found.')
      }
    })
  })

  describe('showFragment()', () => {
    let fragment: TestFragment
    const fragmentKey = 'testKey'

    beforeEach(() => {
      fragment = new TestFragment()
      manager['fragments'].set(fragmentKey, fragment)
    })

    it('shows a fragment, calls lifecycle, sets styles, and requests update via host', async () => {
      const setStylesSpy = sinon.spy(manager, 'setFragmentStyles')

      await manager.showFragment(fragmentKey)

      assert.isTrue(fragment.onStartSpy.calledOnce, 'onStart should be called')
      assert.isTrue(fragment.onResumeSpy.calledOnce, 'onResume should be called')
      assert.equal(fragment.state, FragmentState.Resumed, 'Fragment state should be Resumed')
      assert.isTrue(hostActivity.requestUpdateSpy.calledOnce, 'Host activity should request update')
      assert.isTrue(setStylesSpy.calledOnceWith(fragment), 'setFragmentStyles should be called with fragment')
    })

    it('shows a fragment with renderTarget, calls setRenderRoot and requests fragment update', async () => {
      const renderTarget = document.createElement('div')
      await manager.showFragment(fragmentKey, renderTarget)

      assert.isTrue(fragment.setRenderRootSpy.calledOnceWith(renderTarget))
      assert.isTrue(fragment.requestUpdateSpy.calledOnce) // Fragment's own requestUpdate
    })

    it('returns if fragment is already Resumed', async () => {
      fragment.state = FragmentState.Resumed
      await manager.showFragment(fragmentKey)
      assert.isFalse(fragment.onStartSpy.called)
    })

    it('throws if fragment not found', async () => {
      try {
        await manager.showFragment('nonExistentKey')
        assert.fail('Should have thrown')
      } catch (e) {
        assert.equal((e as Error).message, 'Fragment with key nonExistentKey not found.')
      }
    })
  })

  describe('setFragmentStyles() / removeFragmentStyles()', () => {
    let fragment: TestFragment
    let renderRootDiv: HTMLElement

    beforeEach(() => {
      fragment = new TestFragment()
      renderRootDiv = document.createElement('div')
      // Mock fragment.renderer.renderRoot
      Object.defineProperty(fragment.renderer, 'renderRoot', {
        get: () => renderRootDiv,
        configurable: true,
      })
    })

    it('setFragmentStyles adds classes to fragment renderRoot', () => {
      fragment.rootClassStub.returns('custom-class')
      manager.setFragmentStyles(fragment)
      assert.isTrue(renderRootDiv.classList.contains('testfragment'))
      assert.isTrue(renderRootDiv.classList.contains('custom-class'))
    })

    it('removeFragmentStyles removes classes from fragment renderRoot', () => {
      fragment.rootClassStub.returns('custom-class')
      renderRootDiv.classList.add('testfragment', 'custom-class')
      manager.removeFragmentStyles(fragment)
      assert.isFalse(renderRootDiv.classList.contains('testfragment'))
      assert.isFalse(renderRootDiv.classList.contains('custom-class'))
    })

    it('setFragmentStyles uses documentElement if renderRoot is null', () => {
      Object.defineProperty(fragment.renderer, 'renderRoot', { get: () => null, configurable: true })
      fragment.rootClassStub.returns('custom-doc-class')
      manager.setFragmentStyles(fragment)
      assert.isTrue(document.documentElement.classList.contains('testfragment'))
      assert.isTrue(document.documentElement.classList.contains('custom-doc-class'))
    })

    it('removeFragmentStyles uses documentElement if renderRoot is null', () => {
      Object.defineProperty(fragment.renderer, 'renderRoot', { get: () => null, configurable: true })
      fragment.rootClassStub.returns('custom-doc-class')
      document.documentElement.classList.add('testfragment', 'custom-doc-class')
      manager.removeFragmentStyles(fragment)
      assert.isFalse(document.documentElement.classList.contains('testfragment'))
      assert.isFalse(document.documentElement.classList.contains('custom-doc-class'))
    })

    it('setFragmentStyles handles no custom rootClass', () => {
      fragment.rootClassStub.returns(undefined)
      manager.setFragmentStyles(fragment)
      assert.isTrue(renderRootDiv.classList.contains('testfragment'))
      assert.equal(renderRootDiv.classList.length, 1)
    })
  })

  describe('getVisible()', () => {
    it('returns only Resumed fragments', () => {
      const frag1 = new TestFragment()
      frag1.state = FragmentState.Resumed
      const frag2 = new TestFragment()
      frag2.state = FragmentState.Started
      const frag3 = new TestFragment()
      frag3.state = FragmentState.Resumed
      manager['fragments'].set('f1', frag1)
      manager['fragments'].set('f2', frag2)
      manager['fragments'].set('f3', frag3)

      const visible = manager.getVisible()
      assert.lengthOf(visible, 2)
      assert.include(visible, frag1)
      assert.include(visible, frag3)
    })
  })

  describe('hideFragment()', () => {
    it('hides a fragment and calls lifecycle methods', async () => {
      const fragment = new TestFragment()
      const removeStylesSpy = sinon.spy(manager, 'removeFragmentStyles')

      await manager.hideFragment(fragment)

      assert.isTrue(fragment.onPauseSpy.calledOnce, 'onPause should be called')
      assert.isTrue(fragment.onStopSpy.calledOnce, 'onStop should be called')
      assert.equal(fragment.state, FragmentState.Stopped, 'Fragment state should be Stopped')
      assert.isTrue(removeStylesSpy.calledOnceWith(fragment), 'removeFragmentStyles should be called with fragment')
    })
  })

  describe('findFragment()', () => {
    it('finds an existing fragment', () => {
      const fragment = new TestFragment()
      manager['fragments'].set('testKey', fragment)
      assert.strictEqual(manager.findFragment('testKey'), fragment)
    })

    it('returns undefined if fragment not found', () => {
      assert.isUndefined(manager.findFragment('nonExistentKey'))
    })
  })

  describe('renderFragment()', () => {
    it('renders a Resumed fragment', () => {
      const fragment = new TestFragment()
      manager.attachFragment('testKey', fragment, hostActivity)
      const renderSpy = sinon.stub(fragment, 'render').returns(html`<div>Content</div>`)
      fragment.state = FragmentState.Resumed
      const result = manager.renderFragment('testKey')
      assert.isTrue(renderSpy.calledOnce, 'render should be called once')
      assert.notEqual(result, nothing, 'should not be nothing')
    })

    it('returns nothing if fragment not found', () => {
      assert.deepEqual(manager.renderFragment('nonExistentKey'), nothing)
    })

    it('returns nothing if fragment is not Resumed', () => {
      const fragment = new TestFragment()
      fragment.state = FragmentState.Started
      manager['fragments'].set('testKey', fragment)
      assert.strictEqual(manager.renderFragment('testKey'), nothing)
      assert.isFalse(fragment.renderSpy.called)
    })

    it('returns nothing if fragment render returns nothing', () => {
      const fragment = new TestFragment()
      manager.attachFragment('testKey', fragment, hostActivity)
      fragment.state = FragmentState.Resumed
      const renderSpy = sinon.stub(fragment, 'render').returns(nothing)

      const result = manager.renderFragment('testKey')
      assert.isTrue(renderSpy.calledOnce)
      assert.strictEqual(result, nothing)
    })
  })

  describe('updateActiveFragments()', () => {
    it('calls requestUpdate on Started or Resumed fragments', () => {
      const frag1 = new TestFragment()
      frag1.state = FragmentState.Started
      const frag2 = new TestFragment()
      frag2.state = FragmentState.Resumed
      const frag3 = new TestFragment()
      frag3.state = FragmentState.Attached
      manager['fragments'].set('f1', frag1)
      manager['fragments'].set('f2', frag2)
      manager['fragments'].set('f3', frag3)

      manager.updateActiveFragments()

      assert.isTrue(frag1.requestUpdateSpy.calledOnce)
      assert.isTrue(frag2.requestUpdateSpy.calledOnce)
      assert.isFalse(frag3.requestUpdateSpy.called)
    })
  })

  describe('findByRequestCode()', () => {
    it('finds a fragment by request code', () => {
      const frag1 = new TestFragment()
      frag1.hasRequestCodeStub.withArgs(123).returns(true)
      const frag2 = new TestFragment()
      frag2.hasRequestCodeStub.withArgs(456).returns(true)
      manager['fragments'].set('f1', frag1)
      manager['fragments'].set('f2', frag2)

      assert.strictEqual(manager.findByRequestCode(123), frag1)
      assert.strictEqual(manager.findByRequestCode(456), frag2)
    })

    it('returns null if no fragment has the request code', () => {
      const frag1 = new TestFragment()
      frag1.hasRequestCodeStub.returns(false)
      manager['fragments'].set('f1', frag1)

      assert.isNull(manager.findByRequestCode(789))
    })
  })
})
