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

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

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

    @Inject()
    protected readonly db!: DatabaseService

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

        throw new HTTPError(HTTPStatus.NOT_FOUND)
    }

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

        let result = await this.make<Builder>(Builder)
            .select(config.primaryKey, ...config.fields.filter(x => x.queryable !== false).map(x => x.key))
            .from(config.collection)
            .limit(500)
            .orderBy(config.orderField || config.primaryKey, config.orderDirection || 'asc')
            .connection(this.db.get())
            .get()
            .all()

        if ( config.process?.afterRead ) {
            result = result.map(config.process.afterRead)
        }

        return api.many(result)
    }

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

        let row = await this.make<Builder>(Builder)
            .select(config.primaryKey, ...config.fields.filter(x => x.queryable !== false).map(x => x.key))
            .from(config.collection)
            .where(config.primaryKey, '=', id)
            .limit(1)
            .connection(this.db.get())
            .first()

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

        if ( config.process?.afterRead ) {
            row = config.process.afterRead(row)
        }

        return api.one(row)
    }

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

        // Load input values
        const 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()
        }

        // Create insert query
        const result = await this.make<Builder>(Builder)
            .table(config.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 = 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
        const result = await this.make<Builder>(Builder)
            .table(config.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 = this.getResourceConfigOrFail(key)
        this.checkAction(config, ResourceAction.delete)

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

        // Execute the query
        await this.make<Builder>(Builder)
            .table(config.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 ( 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 ( 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 && hasOwnProperty(fieldDef, 'options') ) {
            const options = collect(fieldDef.options as SelectOptions)
            if ( options.pluck('value').includes(value) ) {
                return value
            }

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

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

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