import type { JSONObject } from 'tiny-types';

import { asyncMap } from '../../io';
import type { UsesAbilities } from '../abilities';
import type { Answerable } from '../Answerable';
import { Interaction } from '../Interaction';
import type { QuestionAdapter } from '../Question';
import { Question } from '../Question';
import type { AnswersQuestions, DescriptionFormattingOptions} from '../questions';
import { the } from '../questions';
import type { ChainableSetter } from './ChainableSetter';
import { TakeNotes } from './TakeNotes';

/**
 * Serenity/JS Screenplay Pattern-style adapter for the [`Notepad`](https://serenity-js.org/api/core/class/Notepad/),
 * that makes it easier for the [actors](https://serenity-js.org/api/core/class/Actor/) to access its APIs.
 *
 * See [`TakeNotes`](https://serenity-js.org/api/core/class/TakeNotes/), [`Notepad`](https://serenity-js.org/api/core/class/Notepad/) and [`notes`](https://serenity-js.org/api/core/function/notes/) for more examples.
 *
 * @group Notes
 */
export class NotepadAdapter<Notes extends Record<any, any>> implements ChainableSetter<Notes> {

    /**
     * Checks if a note identified by `subject` exists in the notepad.
     *
     * #### Learn more
     * - [`Notepad.has`](https://serenity-js.org/api/core/class/Notepad/#has)}
     *
     * @param subject
     *   A subject (name) that uniquely identifies a given note
     *
     * @returns
     *  Question that resolves to `true` if the note exists, `false` otherwise
     */
    has<Subject extends keyof Notes>(subject: Subject): QuestionAdapter<boolean> {
        return Question.about(`a note of ${ String(subject) } exists`, actor => {
            return TakeNotes.as(actor).notepad.has(subject);
        });
    }

    /**
     * Retrieves a note, identified by `subject`, from the notepad.
     *
     * #### Learn more
     * - [`Notepad.get`](https://serenity-js.org/api/core/class/Notepad/#get)}
     *
     * @param subject
     *   A subject (name) that uniquely identifies a given note
     *
     * @returns
     *  The value of the previously recorded note.
     *
     * @throws
     *  Throws a [`LogicError`](https://serenity-js.org/api/core/class/LogicError/) if the note with a given `subject`
     *  has never been recorded.
     */
    get<Subject extends keyof Notes>(subject: Subject): QuestionAdapter<Notes[Subject]> {
        return Question.about(`a note of ${ String(subject) }`, actor => {
            return TakeNotes.as(actor).notepad.get(subject);
        }).describedAs(Question.formattedValue());
    }

    /**
     * Resolves a given `Answerable<value>` and stores it in the notepad,
     * uniquely identified by its `subject`.
     *
     * **Pro tip:** calls to `set` can be chained and result in an accumulation
     * of values to be recorded in the [`Notepad`](https://serenity-js.org/api/core/class/Notepad/).
     * Those values are resolved and recorded when the [`Interaction`](https://serenity-js.org/api/core/class/Interaction/)
     * returned by this method is performed by an [`Actor`](https://serenity-js.org/api/core/class/Actor/).
     *
     * If a note identified by a given `subject` is set multiple times,
     * the last call wins.
     *
     * ```ts
     *  import { actorCalled, notes, TakeNotes } from '@serenity-js/core'
     *  import { Ensure, equals } from '@serenity-js/assertions'
     *
     *  interface MyNotes {
     *      stringNote: string;
     *      numberNote: number;
     *  }
     *
     * await actorCalled('Alice')
     *   .whoCan(TakeNotes.usingAnEmptyNotepad<MyNotes>());
     *   .attemptsTo(
     *
     *     notes<MyNotes>()
     *       .set('stringNote', 'example')
     *       .set('numberNote', Promise.resolve(42))
     *       .set('stringNote', 'another example'),
     *
     *     Ensure.equal(notes().toJSON(), {
     *       firstNote: 'another example',
     *       secondNote: 42,
     *     })
     * )
     * ```
     *
     * #### Learn more
     * - [`Notepad.set`](https://serenity-js.org/api/core/class/Notepad/#set)
     *
     * @param subject
     *   A subject (name) that uniquely identifies a given note
     *
     * @param value
     *  The value to record
     */
    set<Subject extends keyof Notes>(subject: Subject, value: Answerable<Notes[Subject]>): ChainableSetter<Notes> & Interaction {
        return new ChainableNoteSetter<Notes>({ [subject]: value } as NotesToSet<Notes>);
    }

    /**
     * Removes the note identified by `subject` from the notepad.
     *
     * #### Using as an `Interaction`
     *
     * ```ts
     * import { actorCalled, Check, Log, notes } from '@serenity-js/core'
     * import { isPresent } from '@serenity-js/assertions'
     *
     * interface MyNotes {
     *   myNote: string;
     * }
     *
     * await actorCalled('Alice')
     *   .whoCan(TakeNotes.using(Notepad.empty<MyNotes>()))
     *   .attemptsTo(
     *     notes<MyNotes>().set('myNote', 'example value'),
     *
     *     notes<MyNotes>().delete('myNote'),
     *
     *     Check.whether(notes<MyNotes>().get('myNote'), isPresent())
     *       .andIfSo(
     *         Log.the('myNote is present'),
     *       )
     *       .otherwise(
     *         Log.the('myNote was deleted'),
     *       )
     *   )
     *   // logs: myNote was deleted
     * ```
     *
     * #### Using as a `Question`
     *
     * ```ts
     * import { actorCalled, Check, Log, notes } from '@serenity-js/core'
     * import { isTrue } from '@serenity-js/assertions'
     *
     * interface MyNotes {
     *   myNote: string;
     * }
     *
     * await actorCalled('Alice')
     *   .whoCan(TakeNotes.using(Notepad.empty<MyNotes>()))
     *   .attemptsTo(
     *     notes<MyNotes>().set('myNote', 'example value'),
     *
     *     Check.whether(notes<MyNotes>().delete('myNote'), isTrue())
     *       .andIfSo(
     *         Log.the('myNote was deleted'),
     *       )
     *       .otherwise(
     *         Log.the('myNote could not be deleted because it was not set'),
     *       )
     *   )
     *   // logs: myNote was deleted
     * ```
     *
     * #### Learn more
     * - [`Notepad.delete`](https://serenity-js.org/api/core/class/Notepad/#delete)
     *
     * @param subject
     *
     * @returns
     *  When used as a `Question`, resolves to `true` if the item in the Notepad object existed and has been removed,
     *  `false` otherwise.
     */
    delete<Subject extends keyof Notes>(subject: Subject): QuestionAdapter<boolean> {
        return Question.about(`#actor deletes a note of ${ String(subject) }`, actor => {
            return TakeNotes.as(actor).notepad.delete(subject);
        });
    }

    /**
     * Deletes all the notes stored in this notepad.
     *
     * ```ts
     * import { actorCalled, notes } from '@serenity-js/core'
     * import { isTrue } from '@serenity-js/assertions'
     *
     * interface MyNotes {
     *   myNote: string;
     * }
     *
     * await actorCalled('Alice')
     *   .whoCan(TakeNotes.using(Notepad.empty<MyNotes>()))
     *   .attemptsTo(
     *     notes<MyNotes>().set('myNote', 'example value'),
     *     Log.the(notes<MyNotes>().size()),   // emits 1
     *     notes<MyNotes>().clear(),
     *     Log.the(notes<MyNotes>().size()),   // emits 0
     *   )
     * ```
     *
     * #### Learn more
     * - [`Notepad.clear`](https://serenity-js.org/api/core/class/Notepad/#clear)
     */
    clear(): Interaction {
        return Interaction.where(the`#actor clears ${ new NumberOfNotes() } from their notepad`, actor => {
            return TakeNotes.as(actor).notepad.clear();
        });
    }

    /**
     * Returns the number of notes stored in the notepad.
     *
     * ```ts
     * import { actorCalled, notes } from '@serenity-js/core'
     * import { isTrue } from '@serenity-js/assertions'
     *
     * interface MyNotes {
     *   myNote: string;
     * }
     *
     * await actorCalled('Alice')
     *   .whoCan(TakeNotes.using(Notepad.empty<MyNotes>()))
     *   .attemptsTo(
     *     Log.the(notes<MyNotes>().size()),   // emits 0
     *     notes<MyNotes>().set('myNote', 'example value'),
     *     Log.the(notes<MyNotes>().size()),   // emits 1
     *   )
     * ```
     *
     * #### Learn more
     * - [`Notepad.size`](https://serenity-js.org/api/core/class/Notepad/#size)
     */
    size(): QuestionAdapter<number> {
        return Question.about(the`${ new NumberOfNotes() }`, async actor => {
            return TakeNotes.as(actor).notepad.size();
        });
    }

    /**
     * Produces a [`QuestionAdapter`](https://serenity-js.org/api/core/#QuestionAdapter) that resolves to a `JSONObject`
     * representing the resolved notes stored in the notepad.
     *
     * Note that serialisation to JSON will simplify some data types that might not be serialisable by default,
     * but are commonly used in data structures representing actor's notes.
     * For example a [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) will be serialised as a regular JSON object, a [`Set`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) will be serialised as [`Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array).
     *
     * Additionally, notepad assumes that the data structure you use it with does not contain cyclic references.
     *
     * To learn more about the serialisation mechanism used by the notepad, please refer to [TinyTypes documentation](https://jan-molak.github.io/tiny-types/).
     *
     * ```ts
     * import { actorCalled, notes } from '@serenity-js/core'
     *
     * await actorCalled('Alice')
     *   .whoCan(TakeNotes.using(Notepad.with({
     *     aSet: new Set(['apples', 'bananas', 'cucumbers']),
     *     aPromisedValue: Promise.resolve(42),
     *     aString: 'example'
     *   })))
     *   .attemptsTo(
     *     Log.the(notes().toJSON()),
     *   )
     *   // emits: {
     *   //    aSet: ['apples', 'bananas', 'cucumbers']
     *   //    aPromisedValue: 42,
     *   //    aString: 'example',
     *   // }
     * ```
     */
    toJSON(): QuestionAdapter<JSONObject> {
        return Question.about('notepad serialised to JSON', async actor => {
            return TakeNotes.as(actor).notepad.toJSON() as JSONObject;
        });
    }

    /**
     * @inheritDoc
     */
    toString(): string {
        return 'notes';
    }
}

type NotesToSet<Notes extends Record<any, any>> = {
    [Subject in keyof Notes]?: Answerable<Notes[Subject]>
}

/**
 * @package
 */
class ChainableNoteSetter<Notes extends Record<any, any>> extends Interaction implements ChainableSetter<Notes> {

    constructor(private readonly notes: NotesToSet<Notes>) {
        super(new DescriptionOfNotes(notes));
    }

    set<K extends keyof Notes>(subject: K, value: Answerable<Notes[K]>): ChainableSetter<Notes> & Interaction {
        return new ChainableNoteSetter({
            ...this.notes,
            [subject]: value,
        } as NotesToSet<Notes>)
    }

    async performAs(actor: AnswersQuestions & UsesAbilities): Promise<void> {

        const notepad = TakeNotes.as(actor).notepad;

        for (const [ subject, value ] of Object.entries(this.notes)) {
            const answer = await actor.answer(value);
            notepad.set(subject, answer);
        }
    }
}

class DescriptionOfNotes<Notes extends Record<any, any>>
    extends Question<Promise<string>>
{
    constructor(
        private readonly notes: NotesToSet<Notes>,
        private readonly options?: DescriptionFormattingOptions,
    ) {
        super(`#actor takes notes: ${ Object.keys(notes).join(', ') }`);
    }

    async answeredBy(actor: AnswersQuestions & UsesAbilities & { name: string }): Promise<string> {
        const noteNames = Object.keys(this.notes);
        const maxWidth  = noteNames.reduce((max, name) => Math.max(max, name.length), 0);

        const list = await asyncMap(noteNames, async noteName => {
            const label = `${ noteName }:`.padEnd(maxWidth + 1);
            const noteDescription = await actor.answer(Question.formattedValue(this.options).of(this.notes[noteName]));

            return `- ${ label } ${ noteDescription }`
        })

        return [
            `#actor takes notes:`,
            ...list,
        ].join('\n');
    }

    async describedBy(actor: AnswersQuestions & UsesAbilities & { name: string }): Promise<string> {
        return this.answeredBy(actor);
    }
}

class NumberOfNotes extends Question<Promise<number>> {
    constructor() {
        super('notes');
    }

    async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<number> {
        return TakeNotes.as(actor).notepad.size();
    }

    async describedBy(actor: AnswersQuestions & UsesAbilities & { name: string }): Promise<string> {
        const count = await this.answeredBy(actor);

        return count === 1
            ? '1 note'
            : `${ count } notes`;
    }
}
