import { Characteristic, CharacteristicValue, Logger, PlatformAccessory, Service } from 'homebridge'
import util from 'node:util'

import { Abnormal, Ok, Outcome, EmptyObject, prettyPrint, sleep } from './helpers'
import { wakeOnLan } from './networkUtils'
import VieramaticPlatform from './platform'
import { VieraApp, VieraApps, VieraSpecs, VieraTV } from './viera'

type InputVisibility = 0 | 1

type OnDisk =
  | EmptyObject
  | {
      data: {
        inputs: {
          applications: VieraApps
          hdmi: HdmiInput[]
          TUNER: { hidden?: InputVisibility }
        }
        ipAddress: string
        specs: VieraSpecs
      }
    }

interface HdmiInput {
  name: string
  id: string
  hidden?: InputVisibility
}

interface UserConfig {
  friendlyName?: string
  ipAddress: string
  mac?: string
  encKey?: string
  appId?: string
  customVolumeSlider?: boolean
  disabledAppSupport?: boolean
  hdmiInputs: HdmiInput[]
}

type InputType = 'HDMI' | 'APPLICATION' | 'TUNER'

class VieramaticPlatformAccessory {
  private readonly service: Service

  private readonly Service: typeof Service

  private readonly Characteristic: typeof Characteristic

  private readonly log: Logger

  private readonly storage: OnDisk

  private readonly device: VieraTV

  constructor(
    private readonly platform: VieramaticPlatform,
    readonly accessory: PlatformAccessory,
    private readonly userConfig: UserConfig
  ) {
    this.log = this.platform.log
    this.Service = this.platform.Service
    this.Characteristic = this.platform.Characteristic

    this.log.debug(prettyPrint(this.userConfig))

    const handler = {
      get: <T, K extends keyof T>(obj: T, prop: K): T[K] | boolean | undefined => {
        if (prop === 'isProxy') return true

        const property = obj[prop]
        if (typeof property === 'undefined') return

        if (!util.types.isProxy(property) && typeof property === 'object')
          obj[prop] = new Proxy(
            property as unknown as Record<string, unknown>,
            handler
          ) as unknown as T[K]

        return obj[prop]
      },
      set: <T, K extends keyof T>(obj: T, prop: K, value: T[K]) => {
        obj[prop] = value
        this.platform.storage.save()
        return true
      }
    }

    this.device = this.accessory.context.device
    this.storage = new Proxy<OnDisk>(
      this.platform.storage.get(this.device.specs.serialNumber),
      handler
    )

    const svc = this.accessory.getService(this.Service.AccessoryInformation)
    const model = `${this.device.specs.modelName} ${this.device.specs.modelNumber}`
    if (svc)
      svc
        .setCharacteristic(this.Characteristic.Manufacturer, this.device.specs.manufacturer)
        .setCharacteristic(this.Characteristic.SerialNumber, this.device.specs.serialNumber)
        .setCharacteristic(this.Characteristic.Model, model)

    this.accessory.on('identify', () =>
      this.log.info(this.device.specs.friendlyName, 'Identified!')
    )

    this.service = this.accessory.addService(this.Service.Television)

    this.service.setCharacteristic(this.Characteristic.Name, this.device.specs.friendlyName)

    this.service
      .setCharacteristic(this.Characteristic.ConfiguredName, this.device.specs.friendlyName)
      .setCharacteristic(
        this.Characteristic.SleepDiscoveryMode,
        this.Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE
      )

    this.service.addCharacteristic(this.Characteristic.PowerModeSelection).onSet(async () => {
      const outcome = await this.device.sendKey('MENU')
      if (Abnormal(outcome))
        this.log.error('unexpected error in PowerModeSelection.set', outcome.error)
    })

    this.service
      .getCharacteristic(this.Characteristic.Active)
      .onSet(this.setPowerStatus.bind(this))
      .onGet(this.getPowerStatus.bind(this))
    this.service
      .getCharacteristic(this.Characteristic.RemoteKey)
      .onSet(this.remoteControl.bind(this))
    this.service
      .getCharacteristic(this.Characteristic.ActiveIdentifier)
      .onSet(this.setInput.bind(this))

    const speakerService = this.accessory.addService(
      this.Service.TelevisionSpeaker,
      `${this.device.specs.friendlyName} Volume`,
      'volumeService'
    )

    speakerService.addCharacteristic(this.Characteristic.Volume)
    speakerService.addCharacteristic(this.Characteristic.Active)
    speakerService.setCharacteristic(
      this.Characteristic.VolumeControlType,
      this.Characteristic.VolumeControlType.ABSOLUTE
    )
    this.service.addLinkedService(speakerService)

    speakerService
      .getCharacteristic(this.Characteristic.Mute)
      .onGet(this.getMute.bind(this))
      .onSet(this.setMute.bind(this))
    speakerService
      .getCharacteristic(this.Characteristic.Volume)
      .onGet(this.getVolume.bind(this))
      .onSet(this.setVolume.bind(this))
    speakerService
      .getCharacteristic(this.Characteristic.VolumeSelector)
      .onSet(this.setVolumeSelector.bind(this))

    if (this.userConfig.customVolumeSlider === true) {
      const [friendlyN, svcN] = [`${this.device.specs.friendlyName} Volume`, 'VolumeAsFanService']
      const customSpeakerService = this.accessory.addService(this.Service.Fan, friendlyN, svcN)
      this.service.addLinkedService(customSpeakerService)

      customSpeakerService
        .getCharacteristic(this.Characteristic.On)
        .onGet(() => {
          const { value } = this.service.getCharacteristic(this.Characteristic.Active)
          this.log.debug('(customSpeakerService/On.get)', value)
          return value
        })
        .onSet(async (value: CharacteristicValue) => {
          this.log.debug('(customSpeakerService/On.set)', value)
          const state =
            this.service.getCharacteristic(this.Characteristic.Active).value ===
            this.Characteristic.Active.INACTIVE
              ? false
              : !(value as boolean)
          await this.device.setMute(state)
        })

      customSpeakerService
        .getCharacteristic(this.Characteristic.RotationSpeed)
        .onGet(this.getVolume.bind(this))
        .onSet(this.setVolume.bind(this))
    }

    setInterval(async () => await this.getPowerStatus(), 5000)

    this.userConfig.hdmiInputs ||= []

    // ignore HDMI configs
    this.userConfig.hdmiInputs = this.userConfig.hdmiInputs.filter((input) => {
      const required = ['id', 'name']

      for (const req of required)
        if (!Object.prototype.hasOwnProperty.call(input, req)) {
          this.log.warn(
            `ignoring hdmi input "${prettyPrint(
              input
            )}" as it has a missing required field ("${req}" is required)`
          )
          return false
        }

      return true
    })
    const apps = Ok(this.device.apps) ? this.device.apps.value : []
    if (!(this.storage.data as unknown))
      this.storage.data = {
        inputs: {
          applications: { ...apps },
          hdmi: this.userConfig.hdmiInputs,
          // add default TUNER (live TV)... visible by default
          TUNER: { hidden: 0 }
        },
        ipAddress: this.userConfig.ipAddress,
        specs: { ...this.device.specs }
      }
    else {
      this.log.debug('Restoring', this.device.specs.friendlyName)
      // properly handle hdmi interface renaming (#78)
      const sameId = (a: HdmiInput, b: HdmiInput): boolean => a.id === b.id
      const sameNameId = (a: HdmiInput, b: HdmiInput): boolean => a.id === b.id && a.name === b.name

      this.storage.data.inputs.hdmi = this.storage.data.inputs.hdmi.map(
        (element: HdmiInput): HdmiInput => {
          const found = userConfig.hdmiInputs.findIndex((x) => sameId(x, element))
          if (found !== -1 && userConfig.hdmiInputs[found].name !== element.name) {
            const msg = "HDMI input '%s' renamed from '%s' to '%s'"
            this.log.info(msg, element.id, element.name, userConfig.hdmiInputs[found].name)
            element.name = userConfig.hdmiInputs[found].name
          }
          return element
        }
      )
      // check for new user added inputs
      for (const input of userConfig.hdmiInputs) {
        const found = this.storage.data.inputs.hdmi.findIndex((x) => sameNameId(x, input))
        if (found === -1) {
          const msg = `appending HDMI input '${input.id}':'${input.name}' as it was appended to config.json`
          this.log.info(msg)
          this.storage.data.inputs.hdmi.push(input)
        }
      }
      // check for user removed inputs
      this.storage.data.inputs.hdmi = this.storage.data.inputs.hdmi.filter((input) => {
        const found = userConfig.hdmiInputs.findIndex((x) => sameId(x, input))
        if (found !== -1) return true

        const msg = `removing HDMI input '${input.id}':'${input.name}' as it was dropped from the config.json`
        this.log.info(msg, input.id, input.name)
        return false
      })

      this.storage.data.ipAddress = this.userConfig.ipAddress
      this.storage.data.specs = { ...this.device.specs }
      if (apps.length > 0) {
        const next: VieraApps = []
        for (const line of Object.entries(this.storage.data.inputs.applications)) {
          const [_, app] = line
          const found = [...apps].some((x: VieraApp): boolean => x.name === app.name)
          if (!found) {
            this.log.warn(`deleting TV App '${app.name}' as it wasn't removed from your TV's`)
          } else next.push(app)
        }
        for (const line of Object.entries([...apps])) {
          const [_, app] = line
          const found = next.some((x: VieraApp): boolean => x.name === app.name)
          if (!found) {
            this.log.warn(`adding TV App '${app.name}' since it was added to your TV`)
            next.push(app)
          }
        }
        this.storage.data.inputs.applications = { ...next }
      } else this.log.warn('Using previously cached App listing.')
    }

    // TV Tuner
    this.configureInputSource('TUNER', 'TV Tuner', 500)
    // HDMI inputs ...
    this.storage.data.inputs.hdmi = this.storage.data.inputs.hdmi.filter(
      (input: HdmiInput): boolean => {
        // catch gracefully user cfg errors (#67)
        try {
          this.configureInputSource('HDMI', input.name, Number.parseInt(input.id, 10))
        } catch {
          this.log.error(
            "Unable to add as an accessory to your TV 'HDMI' input:\n%s\n\n%s",
            prettyPrint(input),
            "If you do believe that your homebridge's 'config.json' is in order and",
            'has absolutelly no duplicated entries for HDMI inputs then please fill',
            'a bug at https://github.com/AntonioMeireles/homebridge-vieramatic/issues,',
            'otherwise just remove or fix the duplicated stuff.'
          )
          return false
        }
        return true
      }
    )
    // Apps
    for (const line of Object.entries(this.storage.data.inputs.applications)) {
      const [id, app] = line
      const sig = 1000 + Number.parseInt(id, 10)
      this.configureInputSource('APPLICATION', app.name, sig)
    }
  }

  private async setInput(value: CharacteristicValue): Promise<void> {
    const fn = async (): Promise<Outcome<void>> => {
      let app: VieraApp, real: number

      switch (true) {
        case value < 100:
          this.log.debug('(setInput) switching to HDMI INPUT ', value)
          return await this.device.switchToHDMI((value as number).toString())
        case value > 999:
          real = (value as number) - 1000
          app = this.storage.data.inputs.applications[real]
          this.log.debug('(setInput) switching to App', app.name)
          return await this.device.launchApp(app.id)
        // case value === 500:
        default:
          this.log.debug('(setInput) switching to internal TV tunner')
          return await this.device.sendKey('AD_CHANGE')
      }
    }

    const cmd = await fn()
    if (Abnormal(cmd)) this.log.error('setInput', value, cmd.error)
  }

  private configureInputSource(type: InputType, configuredName: string, identifier: number): void {
    const fn = (element: HdmiInput): boolean => element.id === identifier.toString()

    const visibility = (): string => {
      let idx: number
      let hidden: number
      const { inputs } = this.storage.data

      switch (type) {
        case 'HDMI':
          idx = inputs.hdmi.findIndex((x: HdmiInput) => fn(x))
          // by default all hdmiInputs will be visible
          hidden = inputs.hdmi[idx].hidden ?? 0
          break
        case 'APPLICATION':
          idx = identifier - 1000
          // by default all apps will be hidden
          hidden = inputs.applications[idx].hidden ?? 1
          break
        // case 'TUNER':
        default:
          // by default TUNER is visible
          hidden = inputs.TUNER.hidden ?? 0
      }
      return hidden.toFixed(0)
    }

    const source = this.accessory.addService(
      this.Service.InputSource,
      configuredName.toLowerCase().replace(/\s/gu, ''),
      identifier
    )
    const visibilityState = (state: CharacteristicValue): void => {
      let idx: number
      const id = source.getCharacteristic(this.Characteristic.Identifier).value ?? 500
      const { inputs } = this.storage.data

      switch (true) {
        case id < 100:
          // hdmi input
          idx = inputs.hdmi.findIndex((x: HdmiInput) => fn(x))
          inputs.hdmi[idx].hidden = state as InputVisibility
          break
        case id > 999:
          // APP
          idx = (id as number) - 1000
          inputs.applications[idx].hidden = state as InputVisibility
          break
        // case id === 500:
        default:
          inputs.TUNER.hidden = state as InputVisibility
          break
      }
      source.updateCharacteristic(this.Characteristic.CurrentVisibilityState, state)
    }
    const hidden = visibility()

    source
      .setCharacteristic(
        this.Characteristic.InputSourceType,
        this.Characteristic.InputSourceType[type]
      )
      .setCharacteristic(this.Characteristic.CurrentVisibilityState, hidden)
      .setCharacteristic(this.Characteristic.TargetVisibilityState, hidden)
      .setCharacteristic(this.Characteristic.Identifier, identifier)
      .setCharacteristic(this.Characteristic.ConfiguredName, configuredName)
      .setCharacteristic(
        this.Characteristic.IsConfigured,
        this.Characteristic.IsConfigured.CONFIGURED
      )
    source.getCharacteristic(this.Characteristic.TargetVisibilityState).onSet(visibilityState)

    const svc = this.accessory.getService(this.Service.Television)
    if (svc) svc.addLinkedService(source)
  }

  async setPowerStatus(nextState: CharacteristicValue): Promise<void> {
    const message = nextState === this.Characteristic.Active.ACTIVE ? 'ON' : 'into STANDBY'
    const currentState = await VieraTV.isTurnedOn(this.device.address)
    this.log.debug('(setPowerStatus)', nextState, currentState)
    if ((nextState === this.Characteristic.Active.ACTIVE) === currentState)
      this.log.debug('TV is already %s: Ignoring!', message)
    else if (nextState === this.Characteristic.Active.ACTIVE && this.userConfig.mac) {
      this.log.debug('sending WOL packets to awake TV')
      // takes 1 sec (10 magic pkts sent with 100ms interval)
      await wakeOnLan(this.userConfig.mac, this.device.address, 10)
      await sleep(1000)
      await this.updateTVstatus(nextState)
      this.log.debug('Turned TV', message)
    } else {
      const cmd = await this.device.sendKey('POWER')
      if (Abnormal(cmd))
        this.log.error('(setPowerStatus)/-> %s - unable to power cycle TV - unpowered ?', message)
      else {
        await this.updateTVstatus(nextState)
        this.log.debug('Turned TV', message)
      }
    }
  }

  async getPowerStatus(): Promise<boolean> {
    const currentState = await VieraTV.isTurnedOn(this.device.address)

    await this.updateTVstatus(currentState)

    return currentState
  }

  async getMute(): Promise<boolean> {
    const state = await VieraTV.isTurnedOn(this.device.address)
    let mute: boolean

    if (state) {
      const cmd = await this.device.getMute()
      mute = Ok(cmd) ? cmd.value : true
    } else mute = true

    this.log.debug('(getMute) is', mute)
    return mute
  }

  async setMute(state: CharacteristicValue): Promise<void> {
    this.log.debug('(setMute) is', state)
    if (Abnormal(await this.device.setMute(state as boolean)))
      this.log.error('(setMute)/(%s) unable to change mute state on TV...', state)
  }

  async setVolume(value: CharacteristicValue): Promise<void> {
    this.log.debug('(setVolume)', value)
    if (Abnormal(await this.device.setVolume((value as number).toString())))
      this.log.error('(setVolume)/(%s) unable to set volume on TV...', value)
  }

  async getVolume(): Promise<number> {
    const cmd = await this.device.getVolume()
    let volume = 0

    Ok(cmd) ? (volume = Number(cmd.value)) : this.log.debug('(getVolume) no volume from TV...')

    return volume
  }

  async setVolumeSelector(key: CharacteristicValue): Promise<void> {
    this.log.debug('setVolumeSelector', key)
    const action = key === this.Characteristic.VolumeSelector.INCREMENT ? 'VOLUP' : 'VOLDOWN'
    const cmd = await this.device.sendKey(action)
    if (Abnormal(cmd)) this.log.error('(setVolumeSelector) unable to change volume', cmd.error)
  }

  async updateTVstatus(nextState: CharacteristicValue): Promise<void> {
    const tvService = this.accessory.getService(this.Service.Television)
    const speakerService = this.accessory.getService(this.Service.TelevisionSpeaker)
    const customSpeakerService = this.accessory.getService(this.Service.Fan)

    if (!tvService || !speakerService) return

    speakerService.updateCharacteristic(this.Characteristic.Active, nextState)
    tvService.updateCharacteristic(this.Characteristic.Active, nextState)

    if (nextState === true) {
      const [cmd, volume] = [await this.device.getMute(), await this.getVolume()]
      const muted = Ok(cmd) ? cmd.value : true

      speakerService
        .updateCharacteristic(this.Characteristic.Mute, muted)
        .updateCharacteristic(this.Characteristic.Volume, volume)

      if (customSpeakerService)
        customSpeakerService
          .updateCharacteristic(this.Characteristic.On, !muted)
          .updateCharacteristic(this.Characteristic.RotationSpeed, volume)
    } else if (customSpeakerService)
      customSpeakerService.updateCharacteristic(this.Characteristic.On, nextState)
  }

  async remoteControl(keyId: CharacteristicValue): Promise<void> {
    // https://github.com/homebridge/HAP-NodeJS/blob/master/src/lib/definitions/CharacteristicDefinitions.ts#L3029
    const keys: Record<number, string> = {
      // Rewind
      0: 'REW',
      // Fast Forward
      1: 'FF',
      // Next Track
      2: 'SKIP_NEXT',
      // Previous Track
      3: 'SKIP_PREV',
      // Up Arrow
      4: 'UP',
      // Down Arrow
      5: 'DOWN',
      // Left Arrow
      6: 'LEFT',
      // Right Arrow
      7: 'RIGHT',
      // Select
      8: 'ENTER',
      // Back
      9: 'RETURN',
      // Exit
      10: 'CANCEL',
      // Play / Pause
      11: 'PLAY',
      // Information
      15: 'HOME'
    }
    const action = (keyId as number) in keys ? keys[keyId as number] : 'HOME'
    this.log.debug('remote control:', action)
    const cmd = await this.device.sendKey(action)

    if (Abnormal(cmd)) this.log.error('(remoteControl)/(%s) %s', action, cmd.error)
  }
}

export { InputVisibility, OnDisk, UserConfig, VieramaticPlatformAccessory }