/**
 * Postcode search domain model — pure logic for the add-properties-by-postcode
 * journey. See CONTEXT.md (Postcode search) and ADR-0007.
 */

/** Canonical form: uppercase, single space before the inward code. Null if implausible. */
export function normalisePostcode(input: string): string | null {
  const m = input.trim().toUpperCase().replace(/\s+/g, "").match(
    /^([A-Z]{1,2}\d[A-Z\d]?)(\d[A-Z]{2})$/,
  );
  return m ? `${m[1]} ${m[2]}` : null;
}

export interface OsClassification {
  selectable: boolean;
  propertyType: string | null;
  builtForm: string | null;
}

/**
 * Deterministic OS classification-code mapping (ADR-0007). Only codes with a
 * confident enum equivalent write facts; "Unknown" is never produced — an
 * unmappable residential code stays selectable with no facts. RD10
 * (residential institutions) and all non-RD codes are not addable.
 */
const OS_CODE_FACTS: Record<string, { propertyType: string; builtForm: string | null }> = {
  RD02: { propertyType: "House", builtForm: "Detached" },
  RD03: { propertyType: "House", builtForm: "Semi-Detached" },
  RD04: { propertyType: "House", builtForm: null }, // terraced — mid/end unknowable
  RD06: { propertyType: "Flat", builtForm: null },
  RD07: { propertyType: "House", builtForm: null }, // HMO (RD-family code)
  // RH is the dedicated HMO family; treat as House like RD07, built form unknowable.
  RH01: { propertyType: "House", builtForm: null }, // HMO parent
  RH02: { propertyType: "House", builtForm: null }, // HMO bedsit / non-self-contained
  RH03: { propertyType: "House", builtForm: null }, // HMO not further divided
};

const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;

/** Read-through cache rule: serve postcode_search rows younger than 30 days. */
export function isCacheFresh(fetchedAt: Date | null, now: Date): boolean {
  return !!fetchedAt && now.getTime() - fetchedAt.getTime() < CACHE_MAX_AGE_MS;
}

type OsItem = { DPA?: Record<string, unknown>; LPI?: Record<string, unknown> };

export interface AddressCandidate {
  uprn: string;
  address: string;
  postcode: string;
  classificationCode: string | null;
  lat: number | null;
  lng: number | null;
  selectable: boolean;
  propertyType: string | null;
  builtForm: string | null;
}

const titleCase = (s: string) =>
  s.toLowerCase().replace(/\b[a-z]/g, (c) => c.toUpperCase());

/**
 * Flatten OS Places items into unique address candidates: DPA preferred over
 * LPI for the same UPRN; address rendered in title case with the postcode
 * suffix stripped (it lives in its own column); ordered naturally so
 * "Flat 2" sorts before "Flat 10".
 */
export function toAddressCandidates(
  items: OsItem[],
  postcode: string,
): AddressCandidate[] {
  const byUprn = new Map<string, { rec: Record<string, unknown>; isDpa: boolean }>();
  for (const item of items) {
    const rec = item.DPA ?? item.LPI;
    if (!rec || rec.UPRN == null) continue;
    const uprn = String(rec.UPRN);
    const existing = byUprn.get(uprn);
    if (!existing || (item.DPA && !existing.isDpa)) {
      byUprn.set(uprn, { rec, isDpa: !!item.DPA });
    }
  }
  return [...byUprn.values()].map(({ rec }) => {
    const code = (rec.CLASSIFICATION_CODE as string) ?? null;
    const cls = classifyOsCode(code);
    const raw = String(rec.ADDRESS ?? "");
    const address = titleCase(
      raw.replace(new RegExp(`,?\\s*${postcode.replace(" ", "\\s*")}\\s*$`, "i"), ""),
    );
    return {
      uprn: String(rec.UPRN),
      address,
      postcode,
      classificationCode: code,
      lat: typeof rec.LAT === "number" ? rec.LAT : null,
      lng: typeof rec.LNG === "number" ? rec.LNG : null,
      ...cls,
    };
  }).sort((a, b) =>
    a.address.localeCompare(b.address, "en", { numeric: true, sensitivity: "base" }),
  );
}

export interface PostcodeGeo {
  localAuthority: string | null;
  constituency: string | null;
}

/** The rows a selected candidate produces: one property + 0–2 override facts (ADR-0007). */
export function buildWritePlan(c: AddressCandidate, geo: PostcodeGeo) {
  const overrides: {
    component: "property_type" | "built_form_type";
    value: string;
    originalDescription: string;
    buildingPart: number;
  }[] = [];
  if (c.propertyType && c.classificationCode) {
    overrides.push({
      component: "property_type",
      value: c.propertyType,
      originalDescription: c.classificationCode,
      buildingPart: 0,
    });
  }
  if (c.builtForm && c.classificationCode) {
    overrides.push({
      component: "built_form_type",
      value: c.builtForm,
      originalDescription: c.classificationCode,
      buildingPart: 0,
    });
  }
  return {
    property: {
      address: c.address,
      postcode: c.postcode,
      uprn: BigInt(c.uprn),
      latitude: c.lat,
      longitude: c.lng,
      localAuthority: geo.localAuthority,
      constituency: geo.constituency,
    },
    overrides,
  };
}

/**
 * Presentation of a candidate's classification: the type chip label plus an
 * optional explanatory note (why terraced has no built form; why an unmapped
 * residential code still adds cleanly).
 */
export function describeCandidate(c: {
  selectable: boolean;
  propertyType?: string | null;
  builtForm?: string | null;
  classificationCode?: string | null;
}): { label: string; note: string | null } {
  if (!c.selectable) return { label: "Non-residential", note: null };
  if (c.propertyType && c.builtForm) {
    return { label: `${c.propertyType} · ${c.builtForm}`, note: null };
  }
  if (c.propertyType) {
    return {
      label: c.propertyType,
      note: c.classificationCode?.toUpperCase() === "RD04"
        ? "Terraced — mid or end unknown"
        : null,
    };
  }
  return { label: "Residential", note: "No classification — type comes with modelling" };
}

/**
 * postcode_search cache keys to read, canonical spaced form first. Legacy rows
 * were keyed without the space; new rows are written under the canonical form.
 */
export function postcodeCacheKeys(postcode: string): string[] {
  return [postcode, postcode.replace(" ", "")];
}

/** Whole days since the cache row was refreshed (for the "N days old" line). */
export function cacheAgeDays(fetchedAt: Date, now: Date): number {
  return Math.max(0, Math.floor((now.getTime() - fetchedAt.getTime()) / (24 * 60 * 60 * 1000)));
}

export function classifyOsCode(code: string | null): OsClassification {
  if (!code) return { selectable: false, propertyType: null, builtForm: null };
  const c = code.trim().toUpperCase();
  // Addable dwellings: the RD (dwelling) family except RD10 (institutions),
  // plus the RH (HMO) family. Everything else — commercial, institutional,
  // land — is not addable.
  const residential = (c.startsWith("RD") && c !== "RD10") || c.startsWith("RH");
  if (!residential) return { selectable: false, propertyType: null, builtForm: null };
  const facts = OS_CODE_FACTS[c];
  return {
    selectable: true,
    propertyType: facts?.propertyType ?? null,
    builtForm: facts?.builtForm ?? null,
  };
}
