import {
    api, ArrayIterable, AsyncCollection, AsyncPipe, Awaitable,
    Builder,
    collect, Collection,
    Config,
    Controller,
    DatabaseService,
    DataContainer,
    hasOwnProperty,
    HTTPError,
    HTTPStatus,
    Inject,
    Injectable,
    Iterable,
    Maybe,
    QueryRow,
} from '@extollo/lib'
import {
    DataSource,
    FieldDefinition,
    FieldType,
    ResourceAction,
    ResourceConfiguration,
    SelectOptions,
} from '../../../cobalt'

const parser = require('any-date-parser')

class AwaitableIterable<T> extends Iterable<T> {
    public static lift<TInner>(source: Awaitable<Iterable<TInner>>): AwaitableIterable<TInner> {
        return new AwaitableIterable<TInner>(source)
    }

    private resolved?: Iterable<T>

    constructor(
        protected source: Awaitable<Iterable<T>>
    ) {
        super()
    }

    at(i: number): Promise<Maybe<T>> {
        return this.resolve()
            .then(x => x.at(i))
    }

    clone(): Iterable<T> {
        return AwaitableIterable.lift(
            this.resolve()
                .then(x => x.clone())
        )
    }

    range(start: number, end: number): Promise<Collection<T>> {
        return this.resolve()
            .then(x => x.range(start, end))
    }

    count(): Promise<number> {
        return this.resolve()
            .then(x => x.count())
    }

    private async resolve(): Promise<Iterable<T>> {
        if ( !this.resolved ) {
            this.resolved = await this.source
        }
        return this.resolved
    }
}

@Injectable()
export class ResourceAPI extends Controller {
    @Inject()
    protected readonly config!: Config

    @Inject()
    protected readonly db!: DatabaseService

    public async configure(key: string) {
        const config = await this.getResourceConfig(key)
        if ( config ) {
            return api.one(config)
        }

        throw new HTTPError(HTTPStatus.NOT_FOUND)
    }

    public async read(key: string) {
        const config = await this.getResourceConfigOrFail(key)
        this.checkAction(config, ResourceAction.read)

        let result: QueryRow[]
        if ( 'controller' in config.source ) {
            const controller = config.source.controller.apply(this.request)
            result = await controller.read()
        } else if ( 'method' in config.source ) {
            const method = config.source.method.apply(this.request)
            result = await method()
        } else {
            result = await this.make<Builder>(Builder)
                .select(config.primaryKey, ...config.fields.filter(x => x.queryable !== false).map(x => x.key))
                .from(config.source.collection)
                .limit(500)
                .orderBy(config.orderField || config.primaryKey, config.orderDirection || 'asc')
                .connection(this.db.get())
                .get()
                .all()
        }

        if ( config.processAfterRead ) {
            result = await Promise.all(result.map(config.processAfterRead))
        }

        return api.many(result)
    }

    public async readOne(key: string, id: number|string) {
        const config = await this.getResourceConfigOrFail(key)
        this.checkAction(config, ResourceAction.readOne)

        let row: Maybe<QueryRow>
        if ( 'controller' in config.source ) {
            const controller = config.source.controller.apply(this.request)
            row = await controller.readOne(id)
        } else if ( 'method' in config.source ) {
            const method = config.source.method.apply(this.request)
            row = collect(await method())
                .firstWhere(config.primaryKey, '=', key)
        } else {
            row = await this.make<Builder>(Builder)
                .select(config.primaryKey, ...config.fields.filter(x => x.queryable !== false).map(x => x.key))
                .from(config.source.collection)
                .where(config.primaryKey, '=', id)
                .limit(1)
                .connection(this.db.get())
                .first()
        }

        if ( !row ) {
            throw new HTTPError(HTTPStatus.NOT_FOUND)
        }

        if ( config.processAfterRead ) {
            row = await config.processAfterRead(row)
        }

        return api.one(row)
    }

    public async create(key: string, dataContainer: DataContainer) {
        const config = await this.getResourceConfigOrFail(key)
        this.checkAction(config, ResourceAction.create)

        // Load input values
        let queryRow: QueryRow = {}
        for ( const field of config.fields ) {
            const value = dataContainer.input(field.key)
            if ( field.required && typeof value === 'undefined' ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${field.key}`)
            }

            queryRow[field.key] = this.castValue(field, value)
        }

        if ( config.generateKeyOnInsert ) {
            queryRow[config.primaryKey] = config.generateKeyOnInsert()
        }

        if ( config.processBeforeInsert ) {
            queryRow = await config.processBeforeInsert(queryRow)
        }

        // Create insert query
        let result: Maybe<QueryRow>
        if ( 'controller' in config.source ) {
            const controller = config.source.controller.apply(this.request)
            result = await controller.insert(queryRow)
        } else if ( 'method' in config.source ) {
            throw new Error('The "method" source type does not support creating records.')
        } else {
            result = await this.make<Builder>(Builder)
                .table(config.source.collection)
                .returning(config.primaryKey, ...config.fields.map(x => x.key))
                .connection(this.db.get())
                .insert(queryRow)
                .then(x => x.rows.first())
        }

        // Return result
        return api.one(result)
    }

    public async update(key: string, id: number|string, dataContainer: DataContainer) {
        const config = await this.getResourceConfigOrFail(key)
        this.checkAction(config, ResourceAction.update)

        // Load input values
        const queryRow: QueryRow = { [config.primaryKey]: id }
        for ( const field of config.fields ) {
            const value = dataContainer.input(field.key)
            if ( field.required && typeof value === 'undefined' ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${field.key}`)
            }

            queryRow[field.key] = this.castValue(field, value)
        }

        // Make sure the record already exists
        await this.readOne(key, id)

        // Create update query
        let result: Maybe<QueryRow>
        if ( 'controller' in config.source ) {
            const controller = config.source.controller.apply(this.request)
            result = await controller.update(id, queryRow)
        } else if ( 'method' in config.source ) {
            throw new Error('The "method" source type does not support updating records.')
        } else {
            result = await this.make<Builder>(Builder)
                .table(config.source.collection)
                .returning(config.primaryKey, ...config.fields.map(x => x.key))
                .connection(this.db.get())
                .where(config.primaryKey, '=', id)
                .update(queryRow)
                .then(x => x.rows.first())
        }

        // Return the result
        return api.one(result)
    }

    public async delete(key: string, id: number|string) {
        const config = await this.getResourceConfigOrFail(key)
        this.checkAction(config, ResourceAction.delete)

        // Make sure the row exists
        await this.readOne(key, id)

        // Execute the query
        if ( 'controller' in config.source ) {
            const controller = config.source.controller.apply(this.request)
            await controller.delete(id)
        } else if ( 'method' in config.source ) {
            throw new Error('The "method" source type does not support deleting records.')
        } else {
            await this.make<Builder>(Builder)
                .table(config.source.collection)
                .connection(this.db.get())
                .where(config.primaryKey, '=', id)
                .delete()
        }

        return { success: true }
    }

    private checkAction(config: ResourceConfiguration, action: ResourceAction) {
        if ( !config.supportedActions.includes(action) ) {
            throw new HTTPError(HTTPStatus.METHOD_NOT_ALLOWED, `Unsupported action: ${action}`)
        }
    }

    private castValue(fieldDef: FieldDefinition, value: any): any {
        const { type, key, required } = fieldDef
        if ( type === FieldType.text || type === FieldType.textarea || type === FieldType.html ) {
            const cast = String(value || '')
            if ( required && !cast ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`)
            }
            return cast
        } else if ( type === FieldType.number ) {
            const cast = parseFloat(String(value))
            if ( required && !value && value !== 0 ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`)
            }

            if ( value && value !== 0 && isNaN(cast) ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Field must be a number: ${key}`)
            }
            return cast
        } else if ( type === FieldType.integer ) {
            const cast = parseInt(String(value))
            if ( required && !value && value !== 0 ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`)
            }

            if ( value && value !== 0 && isNaN(cast) ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Field must be a number: ${key}`)
            }
            return cast
        } else if ( type === FieldType.date ) {
            if ( required && !value ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`)
            } else if ( !value ) {
                return
            }

            return parser.fromAny(value)
        } else if ( type === FieldType.email ) {
            const cast = String(value || '')
            if ( required && !cast ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Missing required field: ${key}`)
            }

            const rex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/
            if ( !rex.test(cast) ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Invalid e-mail address format: ${key}`)
            }
            return cast
        } else if ( type === FieldType.select && 'options' in fieldDef ) {
            const options = collect(fieldDef.options as SelectOptions)
            const selectedValues = Array.isArray(value) ? value : [value]
            const validOptions = options.pluck('value').intersect(selectedValues)
            if ( validOptions.isNotEmpty() ) {
                return fieldDef.multiple ? validOptions.all() : validOptions.get(0)
            }

            if ( required ) {
                throw new HTTPError(HTTPStatus.BAD_REQUEST, `Invalid or missing value for select: ${key}`)
            }
        } else if ( type === FieldType.bool ) {
            return !!value
        }
    }

    protected async getResourceConfigOrFail(key: string): Promise<ResourceConfiguration> {
        const config = await this.getResourceConfig(key)
        if ( !config ) {
            throw new HTTPError(HTTPStatus.NOT_FOUND)
        }
        return config
    }

    protected async getResourceConfig(key: string): Promise<Maybe<ResourceConfiguration>> {
        const configs = this.config.get('cobalt.resources') as ResourceConfiguration[]
        let config: Maybe<ResourceConfiguration> = undefined
        for ( const match of configs ) {
            if ( match.key === key ) {
                config = match
                break
            }
        }

        if ( !config ) {
            return config
        }

        // Resolve `select` fields with remote sources
        config.fields = await collect(config.fields)
            .map(async field => {
                if ( field.type === FieldType.select && 'source' in field ) {
                    return {
                        ...field,
                        options: await this.readSource(field.source)
                            .map(qr => ({
                                display: qr[field.displayFrom],
                                value: qr[field.valueFrom],
                            }))
                            .then(c => c.all())
                    } as FieldDefinition
                }

                return field
            })
            .awaitAll()
            .then(x => x.all())

        return config
    }

    protected readSource(source: DataSource): AsyncCollection<QueryRow> {
        let iter: Iterable<QueryRow>

        if ( 'controller' in source ) {
            iter = AwaitableIterable.lift(
                AsyncPipe.wrap(source.controller.apply(this.request))
                    .tap(controller => controller.read())
                    .tap(rows => new ArrayIterable(rows))
                    .resolve()
            )
        } else if ( 'method' in source ) {
            iter = AwaitableIterable.lift(
                AsyncPipe.wrap(source.method.apply(this.request))
                    .tap(method => method())
                    .tap(rows => new ArrayIterable(rows))
                    .resolve()
            )
        } else {
            iter = this.make<Builder>(Builder)
                .from(source.collection)
                .limit(500)
                .connection(this.db.get())
                .getResultIterable()
        }

        return new AsyncCollection(iter)
    }
}
