import {
  mdiAlertCircle,
  mdiCircle,
  mdiCircleOutline,
  mdiProgressClock,
  mdiProgressWrench,
  mdiRecordCircleOutline,
} from "@mdi/js";
import {
  css,
  CSSResultGroup,
  html,
  LitElement,
  PropertyValues,
  TemplateResult,
} from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { relativeTime } from "../../common/datetime/relative_time";
import { fireEvent } from "../../common/dom/fire_event";
import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { LogbookEntry } from "../../data/logbook";
import {
  ChooseAction,
  ChooseActionChoice,
  getActionType,
} from "../../data/script";
import { describeAction } from "../../data/script_i18n";
import {
  AutomationTraceExtended,
  ChooseActionTraceStep,
  getDataFromPath,
  isTriggerPath,
  TriggerTraceStep,
} from "../../data/trace";
import { HomeAssistant } from "../../types";
import "./ha-timeline";
import type { HaTimeline } from "./ha-timeline";

const LOGBOOK_ENTRIES_BEFORE_FOLD = 2;

/* eslint max-classes-per-file: "off" */

// Report time entry when more than this time has passed
const SIGNIFICANT_TIME_CHANGE = 1000; // 1 seconds

const isSignificantTimeChange = (a: Date, b: Date) =>
  Math.abs(b.getTime() - a.getTime()) > SIGNIFICANT_TIME_CHANGE;

class RenderedTimeTracker {
  private lastReportedTime: Date;

  constructor(
    private hass: HomeAssistant,
    private entries: TemplateResult[],
    trace: AutomationTraceExtended
  ) {
    this.lastReportedTime = new Date(trace.timestamp.start);
  }

  setLastReportedTime(date: Date) {
    this.lastReportedTime = date;
  }

  renderTime(from: Date, to: Date): void {
    this.entries.push(html`
      <ha-timeline label>
        ${relativeTime(from, this.hass.locale, to, false)} later
      </ha-timeline>
    `);
    this.lastReportedTime = to;
  }

  maybeRenderTime(timestamp: Date): boolean {
    if (!isSignificantTimeChange(timestamp, this.lastReportedTime)) {
      this.lastReportedTime = timestamp;
      return false;
    }

    this.renderTime(this.lastReportedTime, timestamp);
    return true;
  }
}

class LogbookRenderer {
  private curIndex: number;

  private pendingItems: Array<[Date, LogbookEntry]> = [];

  constructor(
    private entries: TemplateResult[],
    private timeTracker: RenderedTimeTracker,
    private logbookEntries: LogbookEntry[]
  ) {
    // Skip the "automation got triggered item"
    this.curIndex =
      logbookEntries.length > 0 && logbookEntries[0].domain === "automation"
        ? 1
        : 0;
  }

  get curItem() {
    return this.logbookEntries[this.curIndex];
  }

  get hasNext() {
    return this.curIndex !== this.logbookEntries.length;
  }

  maybeRenderItem() {
    const logbookEntry = this.curItem;
    this.curIndex++;
    const entryDate = new Date(logbookEntry.when);

    if (this.pendingItems.length === 0) {
      this.pendingItems.push([entryDate, logbookEntry]);
      return;
    }

    const previousEntryDate =
      this.pendingItems[this.pendingItems.length - 1][0];

    // If logbook entry is too long after the last one,
    // add a time passed label
    if (isSignificantTimeChange(previousEntryDate, entryDate)) {
      this._renderLogbookEntries();
      this.timeTracker.renderTime(previousEntryDate, entryDate);
    }

    this.pendingItems.push([entryDate, logbookEntry]);
  }

  flush() {
    if (this.pendingItems.length > 0) {
      this._renderLogbookEntries();
    }
  }

  private _renderLogbookEntries() {
    this.timeTracker.maybeRenderTime(this.pendingItems[0][0]);

    const parts: TemplateResult[] = [];

    let i;

    for (
      i = 0;
      i < Math.min(this.pendingItems.length, LOGBOOK_ENTRIES_BEFORE_FOLD);
      i++
    ) {
      parts.push(this._renderLogbookEntryHelper(this.pendingItems[i][1]));
    }

    let moreItems: TemplateResult[] | undefined;

    // If we didn't render all items, push rest into `moreItems`
    if (i < this.pendingItems.length) {
      moreItems = [];
      for (; i < this.pendingItems.length; i++) {
        moreItems.push(this._renderLogbookEntryHelper(this.pendingItems[i][1]));
      }
    }

    this.entries.push(html`
      <ha-timeline .icon=${mdiCircleOutline} .moreItems=${moreItems}>
        ${parts}
      </ha-timeline>
    `);

    // Clear rendered items.
    this.timeTracker.setLastReportedTime(
      this.pendingItems[this.pendingItems.length - 1][0]
    );
    this.pendingItems = [];
  }

  private _renderLogbookEntryHelper(entry: LogbookEntry) {
    return html`${entry.name} (${entry.entity_id})
      ${entry.message || `turned ${entry.state}`}<br />`;
  }
}

class ActionRenderer {
  private curIndex = 0;

  private keys: string[];

  constructor(
    private hass: HomeAssistant,
    private entries: TemplateResult[],
    private trace: AutomationTraceExtended,
    private logbookRenderer: LogbookRenderer,
    private timeTracker: RenderedTimeTracker
  ) {
    this.keys = Object.keys(trace.trace);
  }

  get curItem() {
    return this._getItem(this.curIndex);
  }

  get hasNext() {
    return this.curIndex !== this.keys.length;
  }

  renderItem() {
    this.curIndex = this._renderItem(this.curIndex);
  }

  private _getItem(index: number) {
    return this.trace.trace[this.keys[index]];
  }

  private _renderItem(
    index: number,
    actionType?: ReturnType<typeof getActionType>
  ): number {
    const value = this._getItem(index);

    if (isTriggerPath(value[0].path)) {
      return this._handleTrigger(index, value[0] as TriggerTraceStep);
    }

    const timestamp = new Date(value[0].timestamp);

    // Render all logbook items that are in front of this item.
    while (
      this.logbookRenderer.hasNext &&
      new Date(this.logbookRenderer.curItem.when) < timestamp
    ) {
      this.logbookRenderer.maybeRenderItem();
    }

    this.logbookRenderer.flush();
    this.timeTracker.maybeRenderTime(timestamp);

    const path = value[0].path;
    let data;
    try {
      data = getDataFromPath(this.trace.config, path);
    } catch (err: any) {
      this._renderEntry(
        path,
        `Unable to extract path ${path}. Download trace and report as bug`
      );
      return index + 1;
    }

    const parts = path.split("/");
    const isTopLevel = parts.length === 2;

    if (!isTopLevel && !actionType) {
      this._renderEntry(path, path.replace(/\//g, " "));
      return index + 1;
    }

    if (!actionType) {
      actionType = getActionType(data);
    }

    if (actionType === "choose") {
      return this._handleChoose(index);
    }

    this._renderEntry(path, describeAction(this.hass, data, actionType));

    let i = index + 1;

    for (; i < this.keys.length; i++) {
      if (this.keys[i].split("/").length === parts.length) {
        break;
      }
    }

    return i;
  }

  private _handleTrigger(index: number, triggerStep: TriggerTraceStep): number {
    this._renderEntry(
      triggerStep.path,
      `Triggered ${
        triggerStep.path === "trigger"
          ? "manually"
          : `by the ${this.trace.trigger}`
      } at
    ${formatDateTimeWithSeconds(
      new Date(triggerStep.timestamp),
      this.hass.locale
    )}`,
      mdiCircle
    );
    return index + 1;
  }

  private _handleChoose(index: number): number {
    // startLevel: choose root config

    // +1: 'default
    // +2: executed sequence

    // +1: 'choose'
    // +2: current choice

    // +3: 'conditions'
    // +4: evaluated condition

    // +3: 'sequence'
    // +4: executed sequence

    const choosePath = this.keys[index];
    const startLevel = choosePath.split("/").length;

    const chooseTrace = this._getItem(index)[0] as ChooseActionTraceStep;
    const defaultExecuted = chooseTrace.result?.choice === "default";
    const chooseConfig = this._getDataFromPath(
      this.keys[index]
    ) as ChooseAction;
    const name = chooseConfig.alias || "Choose";

    if (defaultExecuted) {
      this._renderEntry(choosePath, `${name}: Default action executed`);
    } else if (chooseTrace.result) {
      const choiceNumeric =
        chooseTrace.result.choice !== "default"
          ? chooseTrace.result.choice + 1
          : undefined;
      const choiceConfig = this._getDataFromPath(
        `${this.keys[index]}/choose/${chooseTrace.result.choice}`
      ) as ChooseActionChoice | undefined;
      const choiceName = choiceConfig
        ? `${choiceConfig.alias || `Option ${choiceNumeric}`} executed`
        : `Error: ${chooseTrace.error}`;
      this._renderEntry(choosePath, `${name}: ${choiceName}`);
    } else {
      this._renderEntry(choosePath, `${name}: No action taken`);
    }

    let i;

    // Skip over conditions
    for (i = index + 1; i < this.keys.length; i++) {
      const parts = this.keys[i].split("/");

      // We're done if no more sequence in current level
      if (parts.length <= startLevel) {
        return i;
      }

      // We're going to skip all conditions
      if (
        (defaultExecuted && parts[startLevel + 1] === "default") ||
        (!defaultExecuted && parts[startLevel + 3] === "sequence")
      ) {
        break;
      }
    }

    // Render choice
    while (i < this.keys.length) {
      const path = this.keys[i];
      const parts = path.split("/");

      // We're done if no more sequence in current level
      if (parts.length <= startLevel) {
        return i;
      }

      // We know it's an action sequence, so force the type like that
      // for rendering.
      i = this._renderItem(i, getActionType(this._getDataFromPath(path)));
    }

    return i;
  }

  private _renderEntry(
    path: string,
    description: string,
    icon = mdiRecordCircleOutline
  ) {
    this.entries.push(html`
      <ha-timeline .icon=${icon} data-path=${path}>
        ${description}
      </ha-timeline>
    `);
  }

  private _getDataFromPath(path: string) {
    return getDataFromPath(this.trace.config, path);
  }
}

@customElement("hat-trace-timeline")
export class HaAutomationTracer extends LitElement {
  @property({ attribute: false }) public hass!: HomeAssistant;

  @property({ attribute: false }) public trace?: AutomationTraceExtended;

  @property({ attribute: false }) public logbookEntries?: LogbookEntry[];

  @property({ attribute: false }) public selectedPath?: string;

  @property({ type: Boolean }) public allowPick = false;

  protected render(): TemplateResult {
    if (!this.trace) {
      return html``;
    }

    const entries: TemplateResult[] = [];

    const timeTracker = new RenderedTimeTracker(this.hass, entries, this.trace);
    const logbookRenderer = new LogbookRenderer(
      entries,
      timeTracker,
      this.logbookEntries || []
    );
    const actionRenderer = new ActionRenderer(
      this.hass,
      entries,
      this.trace,
      logbookRenderer,
      timeTracker
    );

    while (actionRenderer.hasNext) {
      actionRenderer.renderItem();
    }

    while (logbookRenderer.hasNext) {
      logbookRenderer.maybeRenderItem();
    }

    logbookRenderer.flush();

    // Render footer
    const renderFinishedAt = () =>
      formatDateTimeWithSeconds(
        new Date(this.trace!.timestamp.finish!),
        this.hass.locale
      );
    const renderRuntime = () => `(runtime:
      ${(
        (new Date(this.trace!.timestamp.finish!).getTime() -
          new Date(this.trace!.timestamp.start).getTime()) /
        1000
      ).toFixed(2)}
      seconds)`;

    let entry: {
      description: TemplateResult | string;
      icon: string;
      className?: string;
    };

    if (this.trace.state === "running") {
      entry = {
        description: "Still running",
        icon: mdiProgressClock,
      };
    } else if (this.trace.state === "debugged") {
      entry = {
        description: "Debugged",
        icon: mdiProgressWrench,
      };
    } else if (this.trace.script_execution === "finished") {
      entry = {
        description: `Finished at ${renderFinishedAt()} ${renderRuntime()}`,
        icon: mdiCircle,
      };
    } else if (this.trace.script_execution === "aborted") {
      entry = {
        description: `Aborted at ${renderFinishedAt()} ${renderRuntime()}`,
        icon: mdiAlertCircle,
      };
    } else if (this.trace.script_execution === "cancelled") {
      entry = {
        description: `Cancelled at ${renderFinishedAt()} ${renderRuntime()}`,
        icon: mdiAlertCircle,
      };
    } else {
      let reason: string;
      let isError = false;
      let extra: TemplateResult | undefined;

      switch (this.trace.script_execution) {
        case "failed_conditions":
          reason = "a condition failed";
          break;
        case "failed_single":
          reason = "only a single execution is allowed";
          break;
        case "failed_max_runs":
          reason = "maximum number of parallel runs reached";
          break;
        case "error":
          reason = "an error was encountered";
          isError = true;
          extra = html`<br /><br />${this.trace.error!}`;
          break;
        default:
          reason = `of unknown reason "${this.trace.script_execution}"`;
          isError = true;
      }

      entry = {
        description: html`Stopped because ${reason} at ${renderFinishedAt()}
        ${renderRuntime()}${extra || ""}`,
        icon: mdiAlertCircle,
        className: isError ? "error" : undefined,
      };
    }

    entries.push(html`
      <ha-timeline
        lastItem
        .icon=${entry.icon}
        class=${ifDefined(entry.className)}
      >
        ${entry.description}
      </ha-timeline>
    `);

    return html`${entries}`;
  }

  protected updated(props: PropertyValues) {
    super.updated(props);

    // Pick first path when we load a new trace.
    if (
      this.allowPick &&
      props.has("trace") &&
      this.trace &&
      this.selectedPath &&
      !(this.selectedPath in this.trace.trace)
    ) {
      const element = this.shadowRoot!.querySelector<HaTimeline>(
        "ha-timeline[data-path]"
      );
      if (element) {
        fireEvent(this, "value-changed", { value: element.dataset.path });
        this.selectedPath = element.dataset.path;
      }
    }

    if (props.has("trace") || props.has("selectedPath")) {
      this.shadowRoot!.querySelectorAll<HaTimeline>(
        "ha-timeline[data-path]"
      ).forEach((el) => {
        toggleAttribute(el, "selected", this.selectedPath === el.dataset.path);
        if (!this.allowPick || el.tabIndex === 0) {
          return;
        }
        el.tabIndex = 0;
        const selectEl = () => {
          this.selectedPath = el.dataset.path;
          fireEvent(this, "value-changed", { value: el.dataset.path });
        };
        el.addEventListener("click", selectEl);
        el.addEventListener("keydown", (ev: KeyboardEvent) => {
          if (ev.key === "Enter" || ev.key === " ") {
            selectEl();
          }
        });
        el.addEventListener("mouseover", () => {
          el.raised = true;
        });
        el.addEventListener("mouseout", () => {
          el.raised = false;
        });
      });
    }
  }

  static get styles(): CSSResultGroup {
    return [
      css`
        ha-timeline[lastItem].condition {
          --timeline-ball-color: var(--error-color);
        }
        ha-timeline[data-path] {
          cursor: pointer;
        }
        ha-timeline[selected] {
          --timeline-ball-color: var(--primary-color);
        }
        ha-timeline:focus {
          outline: none;
          --timeline-ball-color: var(--accent-color);
        }
        .error {
          --timeline-ball-color: var(--error-color);
          color: var(--error-color);
        }
      `,
    ];
  }
}

declare global {
  interface HTMLElementTagNameMap {
    "hat-trace-timeline": HaAutomationTracer;
  }
}
