--- === hs.redshift ===
---
--- Inverts and/or lowers the color temperature of the screen(s) on a schedule, for a more pleasant experience at night
---
--- Usage:
--- ```
--- -- make a windowfilterDisable for redshift: VLC, Photos and screensaver/login window will disable color adjustment and inversion
--- local wfRedshift=hs.window.filter.new({VLC={focused=true},Photos={focused=true},loginwindow={visible=true,allowRoles='*'}},'wf-redshift')
--- -- start redshift: 2800K + inverted from 21 to 7, very long transition duration (19->23 and 5->9)
--- hs.redshift.start(2800,'21:00','7:00','4h',true,wfRedshift)
--- -- allow manual control of inverted colors
--- hs.hotkey.bind(HYPER,'f1','Invert',hs.redshift.toggleInvert)
--- ```
---
--- Note:
---  * As of macOS 10.12.4, Apple provides "Night Shift", which implements a simple red-shift effect, as part of the OS. It seems unlikely that `hs.redshift` will see significant future development.

local screen=require'hs.screen'
local timer=require'hs.timer'
local windowfilter=require'hs.window.filter'
local settings=require'hs.settings'
local log=require'hs.logger'.new('redshift')
local redshift={setLogLevel=log.setLogLevel} -- module

local type,ipairs,pairs,next,floor,abs,min,max,sformat=type,ipairs,pairs,next,math.floor,math.abs,math.min,math.max,string.format

local SETTING_INVERTED_OVERRIDE='hs.redshift.inverted.override'
local SETTING_DISABLED_OVERRIDE='hs.redshift.disabled.override'
--local BLACKPOINT = {red=0.00000001,green=0.00000001,blue=0.00000001}
local BLACKPOINT = {red=0,green=0,blue=0}
--local COLORRAMP

local running,nightStart,nightEnd,dayStart,dayEnd,nightTemp,dayTemp
local tmr,tmrNext,applyGamma,screenWatcher
local invertRequests,invertCallbacks,invertAtNight,invertUser,prevInvert={},{}
local disableRequests,disableUser={}
local wfDisable,modulewfDisable

local function round(v) return floor(0.5+v) end
local function lerprgb(p,a,b) return {red=a[1]*(1-p)+b[1]*p,green=a[2]*(1-p)+b[2]*p,blue=a[3]*(1-p)+b[3]*p} end
local function ilerp(v,s,e,a,b)
  if s>e then
    if v<e then v=v+86400 end
    e=e+86400
  end
  local p=(v-s)/(e-s)
  return a*(1-p)+b*p
end
local function getGamma(temp)
  local R,lb,ub=redshift.COLORRAMP
  for k,_ in pairs(R) do
    if k<=temp then lb=max(lb or 0,k) else ub=min(ub or 10000,k) end
  end
  if lb==nil or ub==nil then local t=R[ub or lb] return {red=t[1],green=t[2],blue=t[3]} end
  local p=(temp-lb)/(ub-lb)
  return lerprgb(p,R[lb],R[ub])
    --  local idx=floor(temp/100)-9
    --  local p=(temp%100)/100
    --  return lerprgb(p,COLORRAMP[idx],COLORRAMP[idx+1])
end
local function between(v,s,e)
  if s<=e then return v>=s and v<=e else return v>=s or v<=e end
end

local function isInverted()
  if not running then return false end
  if invertUser~=nil then return invertUser and 'user'
  else return next(invertRequests) or false end
end
local function isDisabled()
  if not running then return true end
  if disableUser~=nil then return disableUser and 'user'
  else return next(disableRequests) or false end
end

-- core fn
applyGamma=function()
  if tmrNext then tmrNext:stop() tmrNext=nil end
  local now=timer.localTime()
  local temp,timeNext,invertReq
  if isDisabled() then temp=6500 timeNext=now-1 log.i('disabled')
  elseif between(now,nightStart,nightEnd) then temp=ilerp(now,nightStart,nightEnd,dayTemp,nightTemp) --dusk
  elseif between(now,dayStart,dayEnd) then temp=ilerp(now,dayStart,dayEnd,nightTemp,dayTemp) --dawn
  elseif between(now,dayEnd,nightStart) then temp=dayTemp timeNext=nightStart log.i('daytime')--day
  elseif between(now,nightEnd,dayStart) then invertReq=invertAtNight temp=nightTemp timeNext=dayStart log.i('nighttime')--night
  else error('wtf') end
  redshift.requestInvert('redshift-night',invertReq)
  local invert=isInverted()
  local gamma=getGamma(temp)
  log.df('set color temperature %dK (gamma %d,%d,%d)%s',floor(temp),round(gamma.red*100),
    round(gamma.green*100),round(gamma.blue*100),invert and (' - inverted by '..invert) or '')
  for _,scr in ipairs(screen.allScreens()) do
    scr:setGamma(invert and BLACKPOINT or gamma,invert and gamma or BLACKPOINT)
  end
  if invert~=prevInvert then
    log.i('inverted status changed',next(invertCallbacks) and '- notifying callbacks' or '')
    for _,fn in pairs(invertCallbacks) do fn(invert) end
    prevInvert=invert
  end
  if timeNext then
    tmrNext=timer.doAt(timeNext,applyGamma)
  else
    tmr:start()
  end
end

--- hs.redshift.invertSubscribe([id,]fn)
--- Function
--- Subscribes a callback to be notified when the color inversion status changes
---
--- You can use this to dynamically adjust the UI colors in your modules or configuration, if appropriate.
---
--- Parameters:
---  * id - (optional) a string identifying the requester (usually the module name); if omitted, `fn`
---    itself will be the identifier; this identifier must be passed to `hs.redshift.invertUnsubscribe()`
---  * fn - a function that will be called whenever color inversion status changes; it must accept a
---    single parameter, a string or false as per the return value of `hs.redshift.isInverted()`
---
--- Returns:
---  * None
function redshift.invertSubscribe(key,fn)
  if type(key)=='function' then fn=key end
  if type(key)~='string' and type(key)~='function' then error('invalid key',2) end
  if type(fn)~='function' then error('invalid callback',2) end
  invertCallbacks[key]=fn
  log.i('add invert callback',key)
  return running and fn(isInverted())
end
--- hs.redshift.invertUnsubscribe(id)
--- Function
--- Unsubscribes a previously subscribed color inversion change callback
---
--- Parameters:
---  * id - a string identifying the requester or the callback function itself, depending on how you
---    called `hs.redshift.invertSubscribe()`
---
--- Returns:
---  * None
function redshift.invertUnsubscribe(key)
  if not invertCallbacks[key] then return end
  log.i('remove invert callback',key)
  invertCallbacks[key]=nil
end

--- hs.redshift.isInverted() -> string or false
--- Function
--- Checks if the colors are currently inverted
---
--- Parameters:
---  * None
---
--- Returns:
---  * false if the colors are not currently inverted; otherwise, a string indicating the reason, one of:
---    * "user" for the user override (see `hs.redshift.toggleInvert()`)
---    * "redshift-night" if `hs.redshift.start()` was called with `invertAtNight` set to true,
---      and it's currently night time
---    * the ID string (usually the module name) provided to `hs.redshift.requestInvert()`, if another module requested color inversion
redshift.isInverted=isInverted

redshift.isDisabled=isDisabled

--- hs.redshift.requestInvert(id,v)
--- Function
--- Sets or clears a request for color inversion
---
--- Parameters:
---  * id - a string identifying the requester (usually the module name)
---  * v - a boolean indicating whether to invert the colors (if true) or clear any previous requests (if false or nil)
---
--- Returns:
---  * None
---
--- Notes:
---  * you can use this function e.g. to automatically invert colors if the ambient light sensor reading drops below
---    a certain threshold (`hs.brightness.DDCauto()` can optionally do exactly that)
---  * if the user's configuration doesn't explicitly start the redshift module, calling this will have no effect

local function request(t,k,v)
  if type(k)~='string' then error('key must be a string',3) end
  if v==false then v=nil end
  if t[k]~=v then t[k]=v return true end
end
function redshift.requestInvert(key,v)
  if request(invertRequests,key,v) then
    log.f('invert request from %s %s',key,v and '' or 'canceled')
    return running and applyGamma()
  end
end
function redshift.requestDisable(key,v)
  if request(disableRequests,key,v) then
    log.f('disable color adjustment request from %s %s',key,v and '' or 'canceled')
    return running and applyGamma()
  end
end

--- hs.redshift.toggleInvert([v])
--- Function
--- Sets or clears the user override for color inversion.
---
--- This function should be bound to a hotkey, e.g.:
--- `hs.hotkey.bind('ctrl-cmd','=','Invert',hs.redshift.toggleInvert)`
---
--- Parameters:
---  * v - (optional) a boolean; if true, the override will invert the colors no matter what; if false,
---    the override will disable color inversion no matter what; if omitted or nil, it will toggle the
---    override, i.e. clear it if it's currently enforced, or set it to the opposite of the current
---    color inversion status otherwise.
---
--- Returns:
---  * None
function redshift.toggleInvert(v)
  if not running then return end
  if v==nil and invertUser==nil then v=not isInverted() end
  if v~=nil and type(v)~='boolean' then error ('v must be a boolean or nil',2) end
  log.f('invert user override%s',v==true and ': inverted' or (v==false and ': not inverted' or ' cancelled'))
  if v==nil then settings.clear(SETTING_INVERTED_OVERRIDE)
  else settings.set(SETTING_INVERTED_OVERRIDE,v) end
  invertUser=v
  return applyGamma()
end

--- hs.redshift.toggle([v])
--- Function
--- Sets or clears the user override for color temperature adjustment.
---
--- This function should be bound to a hotkey, e.g.:
--- `hs.hotkey.bind('ctrl-cmd','-','Redshift',hs.redshift.toggle)`
---
--- Parameters:
---  * v - (optional) a boolean; if true, the override will enable color temperature adjustment on
---    the given schedule; if false, the override will disable color temperature adjustment;
---    if omitted or nil, it will toggle the override, i.e. clear it if it's currently enforced, or
---    set it to the opposite of the current color temperature adjustment status otherwise.
---
--- Returns:
---  * None
function redshift.toggle(v)
  if not running then return end
  if v==nil then
    if disableUser==nil then v=not isDisabled() end
  elseif type(v)~='boolean' then error ('v must be a boolean or nil',2)
  else v=not v end
  log.f('color adjustment user override%s',v==true and ': disabled' or (v==false and ': enabled' or ' cancelled'))
  if v==nil then settings.clear(SETTING_DISABLED_OVERRIDE)
  else settings.set(SETTING_DISABLED_OVERRIDE,v) end
  disableUser=v
  return applyGamma()
end

--- hs.redshift.stop()
--- Function
--- Stops the module and disables color adjustment and color inversion
---
--- Parameters:
---  * None
---
--- Returns:
---  * None
function redshift.stop()
  if not running then return end
  log.i('stopped')
  tmr:stop()
  screen.restoreGamma()
  if wfDisable then
    if modulewfDisable then modulewfDisable:delete() modulewfDisable=nil
    else wfDisable:unsubscribe(redshift.wfsubs) end
    wfDisable=nil
  end
  if tmrNext then tmrNext:stop() tmrNext=nil end
  screenWatcher:stop() screenWatcher=nil
  running=nil
end
local function gc(t) return t.stop()end

local function stime(time)
  return sformat('%02d:%02d:%02d',floor(time/3600),floor(time/60)%60,floor(time%60))
end

tmr=timer.delayed.new(10,applyGamma)
--- hs.redshift.start(colorTemp,nightStart,nightEnd[,transition[,invertAtNight[,windowfilterDisable[,dayColorTemp]]]])
--- Function
--- Sets the schedule and (re)starts the module
---
--- Parameters:
---  * colorTemp - a number indicating the desired color temperature (Kelvin) during the night cycle;
---    the recommended range is between 3600K and 1400K; lower values (minimum 1000K) result in a more pronounced adjustment
---  * nightStart - a string in the format "HH:MM" (24-hour clock) or number of seconds after midnight
---    (see `hs.timer.seconds()`) indicating when the night cycle should start
---  * nightEnd - a string in the format "HH:MM" (24-hour clock) or number of seconds after midnight
---    (see `hs.timer.seconds()`) indicating when the night cycle should end
---  * transition - (optional) a string or number of seconds (see `hs.timer.seconds()`) indicating the duration of
---    the transition to the night color temperature and back; if omitted, defaults to 1 hour
---  * invertAtNight - (optional) a boolean indicating whether the colors should be inverted (in addition to
---    the color temperature shift) during the night; if omitted, defaults to false
---  * windowfilterDisable - (optional) an `hs.window.filter` instance that will disable color adjustment
---    (and color inversion) whenever any window is allowed; alternatively, you can just provide a list of application
---    names (typically media apps and/or apps for color-sensitive work) and a windowfilter will be created
---    for you that disables color adjustment whenever one of these apps is focused
---  * dayColorTemp - (optional) a number indicating the desired color temperature (in Kelvin) during the day cycle;
---    you can use this to maintain some degree of "redshift" during the day as well, or, if desired, you can
---    specify a value higher than 6500K (up to 10000K) for more bluish colors, although that's not recommended;
---    if omitted, defaults to 6500K, which disables color adjustment and restores your screens' original color profiles
---
--- Returns:
---  * None
function redshift.start(nTemp,nStart,nEnd,dur,invert,wf,dTemp)
  if not dTemp then dTemp=6500 end
  if nTemp<1000 or nTemp>10000 or dTemp<1000 or dTemp>10000 then error('invalid color temperature',2) end
  nStart,nEnd=timer.seconds(nStart),timer.seconds(nEnd)
  dur=timer.seconds(dur or 3600)
  if dur>14400 then error('max transition time is 4h',2) end
  if abs(nStart-nEnd)<dur or abs(nStart-nEnd+86400)<dur
    or abs(nStart-nEnd-86400)<dur then error('nightTime too close to dayTime',2) end
  nightTemp,dayTemp=floor(nTemp),floor(dTemp)
  redshift.stop()

  invertAtNight=invert
  nightStart,nightEnd=(nStart-dur/2)%86400,(nStart+dur/2)%86400
  dayStart,dayEnd=(nEnd-dur/2)%86400,(nEnd+dur/2)%86400
  log.f('started: %dK @ %s -> %dK @ %s,%s %dK @ %s -> %dK @ %s',
    dayTemp,stime(nightStart),nightTemp,stime(nightEnd),invert and ' inverted,' or '',nightTemp,stime(dayStart),dayTemp,stime(dayEnd))
  running=true
  tmr:setDelay(max(1,dur/200))
  screenWatcher=screen.watcher.new(function()tmr:start(5)end):start()
  invertUser=settings.get(SETTING_INVERTED_OVERRIDE)
  disableUser=settings.get(SETTING_DISABLED_OVERRIDE)
  applyGamma()
  if wf~=nil then
    if windowfilter.iswf(wf) then wfDisable=wf
    else
      wfDisable=windowfilter.new(wf,'wf-redshift',log.getLogLevel())
      modulewfDisable=wfDisable
      if type(wf=='table') then
        local isAppList=true
        for k,v in pairs(wf) do
          if type(k)~='number' or type(v)~='string' then isAppList=false break end
        end
        if isAppList then wfDisable:setOverrideFilter{focused=true} end
      end
    end
    redshift.wfsubs={
      [windowfilter.hasWindow]=function()redshift.requestDisable('wf-redshift',true)end,
      [windowfilter.hasNoWindows]=function()redshift.requestDisable('wf-redshift')end,
    }
    wfDisable:subscribe(redshift.wfsubs,true)
  end
end

--- hs.redshift.COLORRAMP
--- Variable
--- A table holding the gamma values for given color temperatures; each key must be a color temperature number in K (useful values are between
--- 1400 and 6500), and each value must be a list of 3 gamma numbers between 0 and 1 for red, green and blue respectively.
--- The table must have at least two entries (a lower and upper bound); the actual gamma values used for a given color temperature
--- are linearly interpolated between the two closest entries; linear interpolation isn't particularly precise for this use case,
--- so you should provide as many values as possible.
---
--- Notes:
---  * `hs.inspect(hs.redshift.COLORRAMP)` from the console will show you how the table is built
---  * the default ramp has entries from 1000K to 10000K every 100K
redshift.COLORRAMP={ -- from https://github.com/jonls/redshift/blob/master/src/colorramp.c
  [1000]={1.00000000,  0.18172716,  0.00000000}, -- 1000K
  [1100]={1.00000000,  0.25503671,  0.00000000}, -- 1100K
  [1200]={1.00000000,  0.30942099,  0.00000000}, -- 1200K
  [1300]={1.00000000,  0.35357379,  0.00000000}, -- ...
  [1400]={1.00000000,  0.39091524,  0.00000000},
  [1500]={1.00000000,  0.42322816,  0.00000000},
  [1600]={1.00000000,  0.45159884,  0.00000000},
  [1700]={1.00000000,  0.47675916,  0.00000000},
  [1800]={1.00000000,  0.49923747,  0.00000000},
  [1900]={1.00000000,  0.51943421,  0.00000000},
  [2000]={1.00000000,  0.54360078,  0.08679949},
  [2100]={1.00000000,  0.56618736,  0.14065513},
  [2200]={1.00000000,  0.58734976,  0.18362641},
  [2300]={1.00000000,  0.60724493,  0.22137978},
  [2400]={1.00000000,  0.62600248,  0.25591950},
  [2500]={1.00000000,  0.64373109,  0.28819679},
  [2600]={1.00000000,  0.66052319,  0.31873863},
  [2700]={1.00000000,  0.67645822,  0.34786758},
  [2800]={1.00000000,  0.69160518,  0.37579588},
  [2900]={1.00000000,  0.70602449,  0.40267128},
  [3000]={1.00000000,  0.71976951,  0.42860152},
  [3100]={1.00000000,  0.73288760,  0.45366838},
  [3200]={1.00000000,  0.74542112,  0.47793608},
  [3300]={1.00000000,  0.75740814,  0.50145662},
  [3400]={1.00000000,  0.76888303,  0.52427322},
  [3500]={1.00000000,  0.77987699,  0.54642268},
  [3600]={1.00000000,  0.79041843,  0.56793692},
  [3700]={1.00000000,  0.80053332,  0.58884417},
  [3800]={1.00000000,  0.81024551,  0.60916971},
  [3900]={1.00000000,  0.81957693,  0.62893653},
  [4000]={1.00000000,  0.82854786,  0.64816570},
  [4100]={1.00000000,  0.83717703,  0.66687674},
  [4200]={1.00000000,  0.84548188,  0.68508786},
  [4300]={1.00000000,  0.85347859,  0.70281616},
  [4400]={1.00000000,  0.86118227,  0.72007777},
  [4500]={1.00000000,  0.86860704,  0.73688797},
  [4600]={1.00000000,  0.87576611,  0.75326132},
  [4700]={1.00000000,  0.88267187,  0.76921169},
  [4800]={1.00000000,  0.88933596,  0.78475236},
  [4900]={1.00000000,  0.89576933,  0.79989606},
  [5000]={1.00000000,  0.90198230,  0.81465502},
  [5100]={1.00000000,  0.90963069,  0.82838210},
  [5200]={1.00000000,  0.91710889,  0.84190889},
  [5300]={1.00000000,  0.92441842,  0.85523742},
  [5400]={1.00000000,  0.93156127,  0.86836903},
  [5500]={1.00000000,  0.93853986,  0.88130458},
  [5600]={1.00000000,  0.94535695,  0.89404470},
  [5700]={1.00000000,  0.95201559,  0.90658983},
  [5800]={1.00000000,  0.95851906,  0.91894041},
  [5900]={1.00000000,  0.96487079,  0.93109690},
  [6000]={1.00000000,  0.97107439,  0.94305985},
  [6100]={1.00000000,  0.97713351,  0.95482993},
  [6200]={1.00000000,  0.98305189,  0.96640795},
  [6300]={1.00000000,  0.98883326,  0.97779486},
  [6400]={1.00000000,  0.99448139,  0.98899179},
  [6500]={1.00000000,  1.00000000,  1.00000000}, -- 6500K
  --  [6500]={0.99999997,  0.99999997,  0.99999997}, --6500K
  [6600]={0.98947904,  0.99348723,  1.00000000},
  [6700]={0.97940448,  0.98722715,  1.00000000},
  [6800]={0.96975025,  0.98120637,  1.00000000},
  [6900]={0.96049223,  0.97541240,  1.00000000},
  [7000]={0.95160805,  0.96983355,  1.00000000},
  [7100]={0.94303638,  0.96443333,  1.00000000},
  [7200]={0.93480451,  0.95923080,  1.00000000},
  [7300]={0.92689056,  0.95421394,  1.00000000},
  [7400]={0.91927697,  0.94937330,  1.00000000},
  [7500]={0.91194747,  0.94470005,  1.00000000},
  [7600]={0.90488690,  0.94018594,  1.00000000},
  [7700]={0.89808115,  0.93582323,  1.00000000},
  [7800]={0.89151710,  0.93160469,  1.00000000},
  [7900]={0.88518247,  0.92752354,  1.00000000},
  [8000]={0.87906581,  0.92357340,  1.00000000},
  [8100]={0.87315640,  0.91974827,  1.00000000},
  [8200]={0.86744421,  0.91604254,  1.00000000},
  [8300]={0.86191983,  0.91245088,  1.00000000},
  [8400]={0.85657444,  0.90896831,  1.00000000},
  [8500]={0.85139976,  0.90559011,  1.00000000},
  [8600]={0.84638799,  0.90231183,  1.00000000},
  [8700]={0.84153180,  0.89912926,  1.00000000},
  [8800]={0.83682430,  0.89603843,  1.00000000},
  [8900]={0.83225897,  0.89303558,  1.00000000},
  [9000]={0.82782969,  0.89011714,  1.00000000},
  [9100]={0.82353066,  0.88727974,  1.00000000},
  [9200]={0.81935641,  0.88452017,  1.00000000},
  [9300]={0.81530175,  0.88183541,  1.00000000},
  [9400]={0.81136180,  0.87922257,  1.00000000},
  [9500]={0.80753191,  0.87667891,  1.00000000},
  [9600]={0.80380769,  0.87420182,  1.00000000},
  [9700]={0.80018497,  0.87178882,  1.00000000},
  [9800]={0.79665980,  0.86943756,  1.00000000},
  [9900]={0.79322843,  0.86714579,  1.00000000},
  [10000]={0.78988728,  0.86491137,  1.00000000}, -- 10000K
}
return setmetatable(redshift,{__gc=gc})
