import {
  $atom,
  $hook,
  $inject,
  $state,
  Alepha,
  SchemaValidator,
  type State,
  type Static,
  z,
} from "alepha";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { BrowserHeadProvider } from "alepha/react/head";
import { LinkProvider } from "alepha/server/links";
import type { RouterPushOptions } from "../services/ReactRouter.ts";
import { ReactBrowserRouterProvider } from "./ReactBrowserRouterProvider.ts";
import type {
  PreviousLayerData,
  ReactRouterState,
} from "./ReactPageProvider.ts";

export type { RouterPushOptions } from "../services/ReactRouter.ts";

/**
 * React browser renderer configuration atom
 */
export const reactBrowserOptions = $atom({
  name: "alepha.react.browser.options",
  schema: z.object({
    scrollRestoration: z.enum(["top", "manual"]), // TODO: must be per page?
    /**
     * Intercept clicks on plain `<a href="/...">` anchors and route them
     * through the SPA router, so authors don't need `<Link>` everywhere
     * (notably for SSR/Markdown HTML rendered as raw markup).
     *
     * Skips: modifier keys, non-primary mouse buttons, `target` other than
     * `_self`, `download`, `data-no-router`, non-http(s) schemes, hash-only
     * hrefs, external origins, and clicks already `defaultPrevented`.
     */
    interceptAnchorClicks: z.boolean().default(true),
  }),
  default: {
    scrollRestoration: "top" as const,
    interceptAnchorClicks: true,
  },
});

export type ReactBrowserRendererOptions = Static<
  typeof reactBrowserOptions.schema
>;

declare module "alepha" {
  interface State {
    [reactBrowserOptions.key]: ReactBrowserRendererOptions;
  }
}

// ---------------------------------------------------------------------------------------------------------------------

export class ReactBrowserProvider {
  protected readonly log = $logger();
  protected readonly client = $inject(LinkProvider);
  protected readonly alepha = $inject(Alepha);
  protected readonly router = $inject(ReactBrowserRouterProvider);
  protected readonly dateTimeProvider = $inject(DateTimeProvider);
  protected readonly browserHeadProvider = $inject(BrowserHeadProvider);
  protected readonly validator = $inject(SchemaValidator);

  protected readonly options = $state(reactBrowserOptions);

  public get rootId() {
    return "root";
  }

  protected getRootElement() {
    const root = this.document.getElementById(this.rootId);
    if (root) {
      return root;
    }

    const div = this.document.createElement("div");
    div.id = this.rootId;

    this.document.body.prepend(div);

    return div;
  }

  public transitioning?: {
    to: string;
    from?: string;
  };

  /**
   * Monotonic counter used to detect stale (superseded) transitions.
   *
   * Each call to `render()` captures `++this.transitionId` and any
   * subsequent `render()` invalidates older in-flight transitions.
   * This prevents a slow page from racing past a newer navigation
   * (e.g. user clicks /pageA which has a 2s loader, then clicks /pageB
   * — pageB must remain the committed page).
   */
  protected transitionId = 0;

  public get state(): ReactRouterState {
    return this.alepha.store.get("alepha.react.router.state")!;
  }

  /**
   * Accessor for Document DOM API.
   */
  public get document() {
    return window.document;
  }

  /**
   * Accessor for History DOM API.
   */
  public get history() {
    return window.history;
  }

  /**
   * Accessor for Location DOM API.
   */
  public get location() {
    return window.location;
  }

  public get base() {
    const base = import.meta.env?.BASE_URL;
    if (!base || base === "/") {
      return "";
    }

    return base;
  }

  public get url(): string {
    const url =
      this.location.pathname + this.location.search + this.location.hash;
    if (this.base) {
      return url.replace(this.base, "");
    }
    return url;
  }

  public pushState(path: string, replace?: boolean) {
    const url = this.base + path;

    if (replace) {
      this.history.replaceState({}, "", url);
    } else {
      this.history.pushState({}, "", url);
    }
  }

  public async invalidate(props?: Record<string, any>) {
    const previous: PreviousLayerData[] = [];

    this.log.trace("Invalidating layers");

    if (props) {
      const [key] = Object.keys(props);
      const value = props[key];

      for (const layer of this.state.layers) {
        if (layer.props?.[key]) {
          previous.push({
            ...layer,
            props: {
              ...layer.props,
              [key]: value,
            },
          });
          break;
        }
        previous.push(layer);
      }
    }

    await this.render({ previous });
  }

  public async push(
    url: string,
    options: RouterPushOptions = {},
  ): Promise<void> {
    this.log.trace(`Going to ${url}`, {
      url,
      options,
    });

    const myTransitionId = ++this.transitionId;

    await this.render({
      url,
      previous: options.force ? [] : this.state.layers,
      meta: options.meta,
      transitionId: myTransitionId,
    });

    // A newer navigation has superseded us — bail out without touching
    // history, otherwise we'd push a duplicate/stale entry.
    if (myTransitionId !== this.transitionId) {
      return;
    }

    // when redirecting in browser
    // The hash is part of the identity of the committed route: without it,
    // pushing "/docs#section" never matches "/docs" and takes the redirect
    // branch below, which rewrites history without the fragment.
    const committed =
      this.state.url.pathname + this.state.url.search + this.state.url.hash;

    if (committed !== url) {
      this.pushState(committed);
      return;
    }

    this.pushState(url, options.replace);
  }

  protected async render(options: RouterRenderOptions = {}): Promise<void> {
    const myTransitionId = options.transitionId ?? ++this.transitionId;
    const previous = options.previous ?? this.state.layers;
    const url = options.url ?? this.url;
    const start = this.dateTimeProvider.now();

    this.transitioning = {
      to: url,
      from: this.state?.url.pathname,
    };

    this.log.debug("Transitioning...", {
      to: url,
    });

    const isStale = () => this.transitionId !== myTransitionId;

    const redirect = await this.router.transition(
      new URL(`http://localhost${url}`),
      previous,
      options.meta,
      isStale,
    );

    // A newer navigation has superseded us between the time we awaited
    // transition() and now. Drop everything: don't follow redirects, don't
    // log success, don't clear `transitioning` (the newer render owns it).
    if (isStale()) {
      this.log.debug("Transition superseded — discarding stale result", {
        to: url,
      });
      return;
    }

    if (redirect) {
      this.log.info("Redirecting to", {
        redirect,
      });

      // if redirect is an absolute URL, use window.location.href (full page reload)
      if (redirect.startsWith("http")) {
        window.location.href = redirect;
      } else {
        // if redirect is a relative URL, use render() (single page app).
        // Inherit the current transitionId: a redirect is a continuation of
        // the same navigation, not a new one. Allocating a fresh id would
        // mark the caller's `push()` stale, so it would skip `pushState()`
        // and the URL bar would never sync to the redirect target.
        return await this.render({
          url: redirect,
          transitionId: myTransitionId,
        });
      }
    }

    const ms = this.dateTimeProvider.now().diff(start);
    this.log.info(`Transition OK [${ms}ms]`, this.transitioning);

    this.transitioning = undefined;
  }

  /**
   * Get embedded layers from the server.
   */
  protected getHydrationState(): ReactHydrationState | undefined {
    try {
      const el = this.document.getElementById("__ssr");
      if (el?.textContent) {
        return JSON.parse(el.textContent) as ReactHydrationState;
      }
    } catch (error) {
      console.error(error);
    }
  }

  /**
   * Apply the SSR hydration payload (the `#__ssr` script tag) to the atom
   * store.
   *
   * Every key except `alepha.react.router.layers` is treated as an atom
   * value. A registered atom's value is explicitly schema-validated: an
   * invalid value is dropped (warn + keep the atom's default) instead of
   * being trusted, so a tampered payload can't smuggle bad data into a
   * validated atom. Atoms not registered yet fall through to
   * `Alepha.set()`, which lets `StateManager.register()` decode them
   * against the schema the moment they first get used.
   *
   * `alepha.react.router.layers` is deliberately skipped by this loop: it
   * carries render instructions (`part`, `name`, `config`, `props`,
   * `error`), not atom values. Those are NOT hardened here and are trusted
   * as-is from the SSR payload — a tampered payload can still influence
   * rendering through this key. Validating router layers is separate,
   * future work; this method only guarantees atom values.
   */
  protected applyHydration(hydration: ReactHydrationState): void {
    for (const [key, value] of Object.entries(hydration)) {
      if (key === "alepha.react.router.layers") {
        // Render instructions, not an atom value — see the method doc
        // above. Intentionally trusted as-is.
        continue;
      }

      const atom = this.alepha.store.getAtom(key);
      if (atom) {
        const result = this.validator.safeValidate(atom.schema, value);
        if (!result.success) {
          this.log.warn(
            `Hydrated value for atom "${key}" failed schema validation, keeping default`,
          );
          continue;
        }
        this.alepha.store.set(key as keyof State, result.data as any, {
          skipValidation: true,
        });
      } else {
        // Not registered yet — register() will decode it when the atom
        // first gets used.
        this.alepha.set(key as keyof State, value);
      }
    }
  }

  // -------------------------------------------------------------------------------------------------------------------

  protected readonly onTransitionEnd = $hook({
    on: "react:transition:end",
    handler: () => {
      if (
        this.options.scrollRestoration === "top" &&
        typeof window !== "undefined" &&
        !this.alepha.isTest()
      ) {
        this.log.trace("Restoring scroll position to top");
        window.scrollTo(0, 0);
      }
    },
  });

  public readonly ready = $hook({
    on: "ready",
    handler: async () => {
      const hydration = this.getHydrationState();
      const previous = hydration?.["alepha.react.router.layers"] ?? [];

      if (hydration) {
        this.applyHydration(hydration);
      }

      await this.render({ previous });

      const element = this.router.root(this.state);

      await this.alepha.events.emit("react:browser:render", {
        element,
        root: this.getRootElement(),
        hydration,
        state: this.state,
      });

      window.addEventListener("popstate", () => {
        // Skip rendering only if the entire URL (path + search) is
        // unchanged from current state. Comparing pathname alone misses
        // back/forward between two URLs that share a path but differ
        // in query params (e.g. an in-page filter `?dir=5` → root) —
        // those legitimately need a re-render. If you want to update
        // query params silently without triggering a render, use
        // history.replaceState directly; popstate by definition means
        // the user navigated.
        if (
          this.base + this.state.url.pathname === this.location.pathname &&
          (this.state.url.search ?? "") === (this.location.search ?? "")
        ) {
          return;
        }

        this.log.debug("Popstate event triggered - rendering new state", {
          url: this.location.pathname + this.location.search,
        });

        this.render();
      });

      this.attachAnchorInterceptor();
    },
  });

  /**
   * Attach a delegated click listener that routes plain `<a href="/...">`
   * clicks through the SPA router. Returns a detach function (used in tests).
   *
   * Bails out on modifier keys, non-primary mouse buttons, `target`, `download`,
   * `data-no-router`, hash-only/external/non-http hrefs, and already-prevented
   * events. Honors the runtime `interceptAnchorClicks` flag.
   */
  protected attachAnchorInterceptor(): () => void {
    const onClick = (ev: MouseEvent) => {
      if (!this.options.interceptAnchorClicks) return;
      if (ev.defaultPrevented) return;
      if (ev.button !== 0) return;
      if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;

      const node = ev.target as Element | null;
      const a = node?.closest?.("a");
      if (!a) return;

      if (a.hasAttribute("download")) return;
      if (a.hasAttribute("data-no-router")) return;

      const target = a.getAttribute("target");
      if (target && target !== "_self") return;

      const href = a.getAttribute("href");
      if (!href) return;
      if (href.startsWith("#")) return;
      if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
        // absolute scheme: only intercept if it points at our own origin
        let url: URL;
        try {
          url = new URL(href);
        } catch {
          return;
        }
        if (url.origin !== this.location.origin) return;
        ev.preventDefault();
        const path = url.pathname + url.search + url.hash;
        this.push(this.stripBase(path)).catch((e) => this.log.error(e));
        return;
      }

      ev.preventDefault();
      const url = new URL(href, this.location.href);
      const path = url.pathname + url.search + url.hash;
      this.push(this.stripBase(path)).catch((e) => this.log.error(e));
    };

    this.document.addEventListener("click", onClick);
    return () => this.document.removeEventListener("click", onClick);
  }

  protected stripBase(path: string): string {
    if (this.base && path.startsWith(this.base)) {
      return path.slice(this.base.length) || "/";
    }
    return path;
  }
}

// ---------------------------------------------------------------------------------------------------------------------

export type ReactHydrationState = {
  "alepha.react.router.layers"?: Array<PreviousLayerData>;
} & {
  [key: string]: any;
};

export interface RouterRenderOptions {
  url?: string;
  previous?: PreviousLayerData[];
  meta?: Record<string, any>;
  /**
   * Transition id used to detect supersession by a newer navigation.
   * When omitted, render() allocates a fresh id internally.
   */
  transitionId?: number;
}
