import { FieldDefinitionValueType } from '@sprucelabs/schema'
import { namesUtil, testLog } from '@sprucelabs/spruce-skill-utils'
import { assert } from '@sprucelabs/test-utils'
import { FieldDefinitions } from '#spruce/schemas/fields/fields.types'
import testUtil from '../tests/utilities/test.utility'
import { ExecutionResults } from '../types/cli.types'
import { GraphicsInterface } from '../types/cli.types'
import {
    GraphicsTextEffect,
    ProgressBarOptions,
    ProgressBarUpdateOptions,
    ImageDimensions,
} from '../types/graphicsInterface.types'
import durationUtil from '../utilities/duration.utility'
import TerminalInterface from './TerminalInterface'

export default class SpyInterface implements GraphicsInterface {
    public invocations: { command: string; options?: any }[] = []
    private cursorPosition = { x: 0, y: 0 }

    private promptResolver?: (
        input: FieldDefinitionValueType<FieldDefinitions>
    ) => void | undefined

    private confirmResolver?: (pass: boolean) => void | undefined
    private waitForEnterResolver?: () => void | undefined
    private promptDefaultValue: any
    private term?: TerminalInterface
    private startTime: number
    private promptTimeout?: any
    private error?: Error

    public constructor() {
        this.startTime = new Date().getTime()

        this.term = this.shouldRenderTestLogs()
            ? new TerminalInterface(process.cwd(), true, (...strs: []) => {
                  this.optionallyRenderLine(strs.join(' '))
              })
            : undefined
    }

    public setTitle(_title: string): void {}

    private shouldRenderTestLogs() {
        return process.env.SHOULD_RENDER_TEST_LOGS === 'true'
    }

    public renderWarning(
        message: string,
        effects?: GraphicsTextEffect[] | undefined
    ): void {
        this.trackInvocation('renderWarning', { message, effects })
        this.term?.renderWarning(message)
    }

    public renderHint(
        message: string,
        effects?: GraphicsTextEffect[] | undefined
    ): void {
        this.trackInvocation('renderHint', { message, effects })
        this.optionallyRenderLine(`Hint: ${message}`)
        this.term?.renderHint(message)
    }

    private trackInvocation(command: string, options?: any) {
        // testUtil.log(command, JSON.stringify(options), '\n')
        this.invocations.push({ command, options })
    }

    public isWaitingForInput() {
        return !!(
            this.promptResolver ||
            this.confirmResolver ||
            this.waitForEnterResolver
        )
    }

    public reset() {
        this.promptResolver = undefined
        this.confirmResolver = undefined
        this.waitForEnterResolver = undefined
        clearTimeout(this.promptTimeout)
    }

    public setError(err: Error) {
        this.error = err
    }

    public getLastInvocation() {
        return this.invocations[this.invocations.length - 1]
    }

    public async sendInput(input: Input): Promise<void> {
        this.trackInvocation('sendInput', input)

        this.optionallyRenderLine(
            `Sending input: "${
                (input as FilePromptInput).path
                    ? (input as FilePromptInput).path
                    : (input as string).length > 0
                      ? input
                      : 'ENTER'
            }"`
        )

        if (this.waitForEnterResolver) {
            const resolver = this.waitForEnterResolver
            this.waitForEnterResolver = undefined

            resolver()
        } else if (this.promptResolver) {
            const resolver = this.promptResolver
            this.promptResolver = undefined

            resolver(
                input !== '\n' && input !== '' ? input : this.promptDefaultValue
            )
        } else if (this.confirmResolver) {
            const resolver = this.confirmResolver
            this.confirmResolver = undefined

            resolver(
                input === '\n' ||
                    (input as string).length === 0 ||
                    (typeof input === 'string' && input.toLowerCase() === 'y')
            )
        } else {
            throw new Error('Sent input before prompted for input')
        }

        return new Promise((resolve) => {
            setTimeout(resolve, 50)
        })
    }

    public renderSection(options: {
        headline: string
        lines?: string[] | string[]
        headlineEffects?: GraphicsTextEffect[]
        dividerEffects?: GraphicsTextEffect[]
        bodyEffects?: GraphicsTextEffect[]
        object?: any
    }): void {
        this.trackInvocation('renderSection', options)
        this.term?.renderSection(options)
    }

    public renderObject(obj: any): void {
        this.trackInvocation('renderObject', obj)
        this.term?.renderObject(obj)
    }

    public renderError(err: Error): void {
        this.trackInvocation('renderError', err)
        this.term?.renderError(err)
    }

    public renderCodeSample(code: string): void {
        this.trackInvocation('renderCodeSample', code)
        this.term?.renderCodeSample(code)
    }

    public renderActionSummary(results: ExecutionResults): void {
        this.trackInvocation('renderCommandSummary', results)
        this.term?.renderActionSummary(results)
    }

    public renderHero(
        message: string,
        effects?: GraphicsTextEffect[] | undefined
    ): void {
        this.trackInvocation('renderHero', { message, effects })
        this.term?.renderHero(message, effects)
    }

    public renderHeadline(
        message: string,
        effects: GraphicsTextEffect[],
        dividerEffects: GraphicsTextEffect[]
    ): void {
        this.trackInvocation('renderHeadline', {
            message,
            effects,
            dividerEffects,
        })
        this.term?.renderHeadline(message, effects, dividerEffects)
    }

    public renderDivider(effects?: GraphicsTextEffect[] | undefined): void {
        this.trackInvocation('renderDivider', effects)
        this.term?.renderDivider(effects)
    }

    public renderLine(
        message: string,
        effects?: GraphicsTextEffect[] | undefined
    ): void {
        this.trackInvocation('renderLine', { message, effects })
        message && message.length > 0 && this.optionallyRenderLine(message)
    }

    private optionallyRenderLine(message: string) {
        if (this.shouldRenderTestLogs()) {
            const duration = new Date().getTime() - this.startTime
            const friendly = durationUtil.msToFriendly(duration)

            testLog.info(
                `${
                    //@ts-ignore
                    global.activeTest?.test
                        ? //@ts-ignore
                          global.activeTest?.test + ' :: '
                        : ''
                }${friendly} :: ${message}`
            )
        }
    }

    public async renderImage(
        path: string,
        options?: ImageDimensions
    ): Promise<void> {
        this.trackInvocation('renderImage', { path, options })
    }

    public renderLines(
        messages: string[],
        effects?: GraphicsTextEffect[] | undefined
    ): void {
        this.trackInvocation('renderLines', { messages, effects })
        this.term?.renderLines(messages, effects)
    }

    public async prompt<T extends FieldDefinitions>(definition: T) {
        this.trackInvocation('prompt', definition)

        if (this.promptResolver) {
            throw new Error(
                'Tried to double prompt. Try this.term?.sendInput() before calling prompt next.'
            )
        }

        let msg = `${namesUtil.toPascal(definition.type)} Prompt: ${
            definition.label
        }`

        if (definition.type === 'select') {
            msg += '\n\nChoices:\n'
            definition.options.choices.forEach((choice) => {
                msg += `\n${choice.value}: ${choice.label}`
            })

            msg += '\n'
        }

        this.optionallyRenderLine(msg)

        return new Promise<FieldDefinitionValueType<FieldDefinitions>>(
            (resolve, reject) => {
                this.promptResolver = (...args: any[]) => {
                    clearTimeout(this.promptTimeout)
                    //@ts-ignore
                    resolve(...args)
                }
                this.promptTimeout = setTimeout(() => {
                    this.reset()

                    try {
                        assert.fail(
                            `Timed out waiting for input with label: '${definition.label}'\n\nConsider passing what you need to Action().execute({ something })`
                        )
                    } catch (err: any) {
                        reject(err)
                    }
                }, 10000)
                this.promptDefaultValue = definition.defaultValue
            }
        )
    }

    public startLoading(message?: string | undefined): void {
        this.trackInvocation('startLoading', message)
        this.optionallyRenderLine(message ? `${message}` : 'Start loading...')
    }

    public stopLoading(): void {
        this.trackInvocation('stopLoading')
        this.optionallyRenderLine('Stop loading...')
    }

    public async waitForEnter(message?: string | undefined): Promise<void> {
        this.trackInvocation('waitForEnter', message)
        this.optionallyRenderLine(
            `${message ? ` ${message}\n\n` : ``}Waiting for enter...`
        )

        return new Promise((resolve) => {
            this.waitForEnterResolver = resolve
        })
    }

    public async waitForInput() {
        const ttl = 1000 * 60 * 2
        const checkInterval = 100
        let loops = ttl / checkInterval
        let lastWriteCount = this.invocations.length

        while (!this.isWaitingForInput()) {
            if (loops-- === 0) {
                assert.fail(`Waiting for input timed out.`)
            }

            const hasWritten = lastWriteCount != this.invocations.length

            if (hasWritten) {
                loops = ttl / checkInterval
                lastWriteCount = this.invocations.length
                if (this.shouldRenderTestLogs()) {
                    testUtil.log(
                        'waitForInput timeout reset because of new output.'
                    )
                }
            }

            if (this.error) {
                throw this.error
            }

            await new Promise((resolve) => setTimeout(resolve, checkInterval))
        }
    }

    public confirm(question: string): Promise<boolean> {
        this.trackInvocation('confirm', question)
        this.optionallyRenderLine(`${question} :: Y/N...`)

        return new Promise((resolve) => {
            this.confirmResolver = resolve
        })
    }

    public clear(): void {
        this.trackInvocation('clear')
    }

    public renderProgressBar(options: ProgressBarOptions): void {
        this.trackInvocation('renderProgressBar', options)
        this.optionallyRenderLine(
            `Showing progress${options.title ? ` ${options.title}` : ``}`
        )
    }

    public updateProgressBar(options: ProgressBarUpdateOptions): void {
        this.trackInvocation('updateProgressBar', options)
    }

    public removeProgressBar(): void {
        this.trackInvocation('removeProgressBar')
        this.optionallyRenderLine(`Hiding progress`)
    }

    public async getCursorPosition(): Promise<{ x: number; y: number } | null> {
        this.trackInvocation('getCursorPosition')
        return this.cursorPosition
    }

    public setCursorPosition(pos: { x: number; y: number }) {
        this.cursorPosition = pos
    }

    public moveCursorTo(x: number, y: number): void {
        this.trackInvocation('moveCursorTo', { x, y })
    }

    public clearBelowCursor(): void {
        this.trackInvocation('clearBelowCursor')
    }
}

interface FilePromptInput {
    path: string
}

type Input = string | string[] | FilePromptInput
