path = require 'path'
fs = require 'fs'
EventEmitter = require 'events'

gulp = require 'gulp'
browserify = require 'browserify'
glob =  require 'glob'
_ = require 'underscore'

exhaust = require 'stream-exhaust'
onEnd = require 'end-of-stream'

touch = require 'touch'
a$ = require 'async'

globbedBrowserify = require './index'
{ factory } = globbedBrowserify

here = process.cwd()
fixture = 'fixture'
updateSafe = 'gulp-browserify-watchify-glob:bundle-done'

# Shorthand for the path to a fixture module.
fix = (segments..., num) ->
    "#{path.join here, fixture, segments...}#{num}.coffee"

glob1 = path.join fixture, '*.coffee'
glob2 = '*.coffee'
brokenGlob = '%$:}{'
glob1expanded = [1, 2, 3].map (num) -> fix 'entry', num
glob2expanded = glob.sync glob2, absolute: on
bothGlobsExpanded = glob1expanded.concat glob2expanded
globMap = [glob1]: glob1expanded, [glob2]: glob2expanded
fixtureLayout =
    '': 'entry'
    dependencies: 'dep'
    hidden: 'spare'

# Collect all fixture modules with common name in target.
collectFiles = (name, target, done) ->
    targetDir = path.join fixture, target
    moveFile = (file, cb) ->
        basename = path.basename file
        targetPath = path.join targetDir, basename
        fs.rename file, targetPath, cb
    a$.waterfall [
        a$.constant "#{path.join fixture, '**', name}?.coffee"
        glob
        (files, cb) -> a$.each files, moveFile, cb
    ], done

# Put all fixture modules in the right directories.
resetFixture = (done) -> a$.eachOf fixtureLayout, collectFiles, done

# Run the library factory with mock dependencies injected.
createLib = (overrides = {}) ->
    factory(
        overrides.gulp       || gulp
        overrides.browserify || browserify
        overrides.glob       || glob
        overrides.underscore || _
    )

# Fixture bundler.
createBundler = (library = globbedBrowserify) ->
    library
        entries: glob1
        extensions: ['.coffee']
    .transform 'coffeeify'
    .transform 'babelify',
        presets: [['@babel/env', targets: node: 6]]
        extensions: ['.coffee']

describe 'expandGlobsSync', ->
    { expandGlobsSync } = globbedBrowserify

    it 'throws the same error as glob.sync', ->
        theError = new Error()
        _glob = jasmine.createSpyObj ['sync']
        _glob.sync.and.throwError theError
        library = createLib glob: _glob
        expect -> library.expandGlobsSync [glob1]
        .toThrow theError

    it 'returns an array of matching absolute paths', ->
        expect expandGlobsSync [glob1]
        .toEqual glob1expanded
        expect expandGlobsSync [glob2]
        .toEqual glob2expanded

    it 'also works with multiple globs', ->
        expect expandGlobsSync [glob1, glob2]
        .toEqual bothGlobsExpanded

describe 'expandGlobs', ->
    { expandGlobs } = globbedBrowserify

    it 'fails with the same error as glob', (done) ->
        theError = new Error()
        _glob = jasmine.createSpy().and.callFake (p, o, cb) ->
            a$.nextTick (cb ? o), theError
        library = createLib glob: _glob
        library.expandGlobs [glob1], (error) ->
            expect(error).toBe(theError)
            done()

    expectMatch = (expansion, glob, done) -> a$.waterfall [
        a$.constant if _.isArray glob then glob else [glob]
        expandGlobs
        a$.asyncify (result) -> expect(result).toEqual(expansion)
    ], done

    it 'completes with an array of matching absolute paths', ->
        a$.eachOf globMap, expectMatch

    it 'also works with multiple globs', (done) ->
        expectMatch bothGlobsExpanded, [glob1, glob2], done

describe 'difference', ->
    { difference } = globbedBrowserify
    set1 = new Set [1, 2, 3]
    set2 = new Set [3, 4, 5]

    it 'returns a new set with the elements in the first not in the second', ->
        onlyIn1 = difference set1, set2
        expect(onlyIn1.has 1).toBeTruthy()
        expect(onlyIn1.has 2).toBeTruthy()
        expect(onlyIn1.has 3).toBeFalsy()
        expect(onlyIn1.has 4).toBeFalsy()
        expect(onlyIn1.has 5).toBeFalsy()

describe 'updateEntries', ->
    entry1 = fix 'entry', 1
    entry2 = fix 'entry', 2
    entry3 = fix 'entry', 3
    spare1 = fix 'hidden', 'spare', 1
    changedExpansion = [entry1, entry2, spare1]

    beforeEach ->
        @glob = jasmine.createSpy()
        @glob.sync = jasmine.createSpy().and.returnValue glob1expanded
        @library = createLib glob: @glob
        {@updateEntries} = @library
        @instance = createBundler @library
        a$.series [
            a$.apply onEnd, exhaust @instance.bundle()
            a$.asyncify =>
                @reset = spyOn @instance, 'reset'
                @add = spyOn @instance, 'add'
                @pipeWrite = spyOn @instance.pipeline, 'write'
        ]

    it 'fails with the same error as expandGlobs', (done) ->
        theError = new Error()
        @glob.and.callFake (p, o, cb) ->
            a$.nextTick (cb ? o), theError
        @updateEntries ['x'], [], @instance, (error) ->
            expect(error).toBe(theError)
            done()

    it 'updates the entries of the bundler', ->
        @glob.and.callFake (p, o, cb) ->
            a$.nextTick (cb ? o), null, changedExpansion
        entries = _.clone glob1expanded
        expectSpyCalls = a$.asyncify (newEntries) =>
            expect @reset
            .toHaveBeenCalledBefore @add
            expect @reset
            .toHaveBeenCalledBefore @pipeWrite
            expect @add
            .toHaveBeenCalledWith spare1
            expect @pipeWrite
            .toHaveBeenCalledWith jasmine.objectContaining file: entry1
            expect @pipeWrite
            .toHaveBeenCalledWith jasmine.objectContaining file: entry2
            expect @pipeWrite
            .not.toHaveBeenCalledWith jasmine.objectContaining file: entry3
            expect newEntries
            .toEqual changedExpansion
            expect entries
            .toEqual changedExpansion
        a$.waterfall [
            a$.apply @updateEntries, [glob1], entries, @instance
            expectSpyCalls
        ]

describe 'globbedBrowserify', ->
    beforeEach ->
        @instance = createBundler()

    it 'needs entries', ->
        buggy = -> globbedBrowserify extensions: ['.coffee']
        expect buggy
        .toThrowError TypeError, /needs entries/

    it 'returns an augmented browserify instance', ->
        expect @instance instanceof browserify
        .toBe true
        expect @instance.watch
        .toBeDefined()
        expect @instance.wrap
        .toBeDefined()

    describe 'the instance', ->
        trivialTask = (done) -> a$.nextTick done

        describe 'bundle', ->
            it 'creates a bundle based on the entries glob', -> a$.waterfall [
                (cb) => @instance.bundle cb
                a$.asyncify (buffer) ->
                    text = buffer.toString()
                    expect(text).toContain('entry 1')
                    expect(text).toContain('entry 2')
                    expect(text).toContain('entry 3')
                    expect(text).toContain('dependency 1')
                    expect(text).toContain('dependency 2')
                    expect(text).toContain('dependency 3')
                    expect(text).not.toContain('spare 1')
                    expect(text).not.toContain('spare 2')
                    expect(text).not.toContain('spare 3')
            ]

        describe 'wrap', ->
            beforeEach ->
                @task = jasmine.createSpy().and.callFake trivialTask

            it 'returns a new task that invokes the passed task', ->
                wrapped = @instance.wrap @task
                expect(wrapped).not.toBe(@task)
                a$.series [
                    wrapped
                    a$.asyncify => expect(@task).toHaveBeenCalled()
                ]

        describe 'watch', ->
            beforeEach (done) ->
                @task1 = jasmine.createSpy().and.callFake trivialTask
                @task2 = jasmine.createSpy().and.callFake trivialTask
                @wrapped = @instance.watch @task1, @task2
                @instance.bundle done

            afterEach (done) ->
                @instance.close()
                resetFixture done

            awaitEvent = (emitter, name) -> (cb) -> emitter.once name, cb

            expectEvents = (instance, done) -> a$.series [
                awaitEvent instance, 'update'
                awaitEvent instance, updateSafe
                a$.asyncify -> expect(yes).toBe(yes)
            ], done

            it 'returns a wrapper of the second argument', -> a$.series [
                a$.apply @wrapped
                a$.asyncify => expect(@task2).toHaveBeenCalled()
            ]

            it 'defaults the second argument to the first', ->
                task = jasmine.createSpy().and.callFake trivialTask
                instance = createBundler()
                a$.series [
                    instance.watch task
                    a$.asyncify ->
                        expect(task).toHaveBeenCalled()
                        instance.close()
                ]

            it 'emits events when a module is modified', (done) ->
                modified = fix 'dependencies', 'dep', 1
                a$.parallel [
                    a$.apply expectEvents, @instance
                    a$.apply touch, modified
                ], done

            it 'emits events when an entry is added', ->
                added = fix 'hidden', 'spare', 1
                target = fix 'spare', 1
                a$.parallel [
                    a$.apply expectEvents, @instance
                    a$.apply fs.rename, added, target
                ]

            it 'emits events when an entry is removed', ->
                removed = fix 'entry', 1
                target = fix 'hidden', 'entry', 1
                a$.parallel [
                    a$.apply expectEvents, @instance
                    a$.apply fs.rename, removed, target
                ]

            it 'starts the first argument on update', (done) ->
                expect(@task1).not.toHaveBeenCalled()
                a$.series [
                    awaitEvent @instance, updateSafe
                    a$.asyncify => expect(@task1).toHaveBeenCalled()
                ], done
                @instance.emit 'update'

            it 'reflects entry changes when rebundling', ->
                added = fix 'hidden', 'spare', 1
                addedTarget = fix 'spare', 1
                removed = fix 'entry', 3
                removedTarget = fix 'hidden', 'entry', 3
                a$.autoInject
                    add: (done) -> fs.rename added, addedTarget, done
                    remove: (done) -> fs.rename removed, removedTarget, done
                    waitUntilSafe: (add, remove, done) =>
                        @instance.once updateSafe, done
                    bundle: (waitUntilSafe, done) => @instance.bundle done
                    test: ['bundle', a$.asyncify (buffer) ->
                        text = buffer.toString()
                        expect(text).toContain('entry 1')
                        expect(text).toContain('entry 2')
                        expect(text).not.toContain('entry 3')
                        expect(text).toContain('dependency 1')
                        expect(text).toContain('dependency 2')
                        expect(text).not.toContain('dependency 3')
                        expect(text).toContain('spare 1')
                        expect(text).not.toContain('spare 2')
                        expect(text).not.toContain('spare 3')
                    ]

            it 'prevents race conditions', ->
                modified = fix 'dependencies', 'dep', 1
                added = fix 'hidden', 'spare', 1
                addedTarget = fix 'spare', 1
                maxConcurrentCalls = concurrentCalls = 0
                @task1.and.callFake (done) ->
                    if ++concurrentCalls > maxConcurrentCalls
                        maxConcurrentCalls = concurrentCalls
                    finish = ->
                        --concurrentCalls
                        done()
                    setTimeout finish, 400
                a$.autoInject
                    modify: (done) -> touch modified, done
                    wait200ms: (done) -> setTimeout done, 200
                    add: (wait200ms, done) ->
                        fs.rename added, addedTarget, done
                    waitUntilSafe: (modify, add, done) =>
                        @instance.once updateSafe, done
                    test: (waitUntilSafe, done) ->
                        expect(maxConcurrentCalls).toBe(1)
                        a$.nextTick done

            it 'adds a close method to the instance', ->
                expect @instance.close
                .toBeDefined()

        describe 'close', ->
            beforeEach ->
                @instance = createBundler()
                @task = jasmine.createSpy().and.callFake trivialTask
                @instance.watch @task
                a$.series [
                    (cb) => @instance.bundle cb
                    a$.asyncify => @instance.close()
                ]

            afterEach resetFixture

            expectNoEvents = ({instance, task}, fsTrigger) ->
                updateSpy = jasmine.createSpy()
                safeSpy = jasmine.createSpy()
                instance.on 'update', updateSpy
                instance.on updateSafe, safeSpy
                a$.series [
                    a$.apply fsTrigger...
                    (cb) -> setTimeout cb, 1000
                    a$.asyncify ->
                        expect(updateSpy).not.toHaveBeenCalled()
                        expect(safeSpy).not.toHaveBeenCalled()
                        expect(task).not.toHaveBeenCalled()
                ]

            it 'stops the instance from reacting to change events', ->
                modified = fix 'dependencies', 'dep', 1
                date = new Date()
                expectNoEvents @, [touch, modified]

            it 'stops the instance from reacting to add events', ->
                added = fix 'hidden', 'spare', 1
                addedTarget = fix 'spare', 1
                expectNoEvents @, [fs.rename, added, addedTarget]

            it 'stops the instance from reacting to remove events', ->
                removed = fix 'entry', 3
                removedTarget = fix 'hidden', 'entry', 3
                expectNoEvents @, [fs.rename, removed, removedTarget]
