"""Flow that builds a Harness model from YAML inputs and renders outputs."""

import logging
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union

from filare.errors import (
    ComponentTypeMismatch,
    ConnectionCountMismatchError,
    FilareFlowException,
    MissingConnectionCountError,
    MissingOutputSpecification,
    MultipleSeparatorError,
    RedefinedDesignatorError,
    UnknownTemplateDesignator,
)
from filare.models.cable import CableModel
from filare.models.colors import ColorOutputMode, SingleColor
from filare.models.component import ComponentModel
from filare.models.connector import ConnectorModel
from filare.models.document import DocumentHashRegistry, DocumentRepresentation
from filare.models.harness import Harness
from filare.models.metadata import Metadata
from filare.models.notes import Notes, get_page_notes
from filare.models.options import PageOptions, get_page_options
from filare.models.page import (
    BOMPage,
    CutPage,
    HarnessPage,
    PageType,
    TerminationPage,
    TitlePage,
)
from filare.models.types import AUTOGENERATED_PREFIX
from filare.models.utils import expand, get_single_key_and_value, smart_file_resolve
from filare.parser import parse_concat_merge_files

from .render_outputs import render_harness_outputs


def _make_jsonable(value):
    if isinstance(value, dict):
        return {k: _make_jsonable(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_make_jsonable(v) for v in value]
    if isinstance(value, Path):
        return str(value)
    if isinstance(value, Enum):
        return value.value
    return value


def _maybe_write_document(
    doc_path: Path,
    hash_registry_path: Path,
    document: DocumentRepresentation,
    doc_hash: str,
) -> DocumentRepresentation:
    registry = DocumentHashRegistry(hash_registry_path)
    registry.load()
    allow_override = registry.allow_override(doc_path.name)

    if doc_path.exists():
        existing = DocumentRepresentation.from_yaml(doc_path)
        existing_hash = existing.compute_hash()
        if (not allow_override) and (
            not registry.contains(doc_path.name, existing_hash)
        ):
            old = existing.as_dict()
            new = document.as_dict()
            changed_keys = [
                key
                for key in set(old.keys()).union(new.keys())
                if old.get(key) != new.get(key)
            ]
            logging.warning(
                "Document representation appears user-edited; skipping overwrite: %s (allow_override=%s, changed=%s)",
                doc_path,
                allow_override,
                ",".join(changed_keys) if changed_keys else "unknown",
            )
            return existing

    if allow_override or not registry.contains(doc_path.name, doc_hash):
        document.to_yaml(doc_path)
        registry.add(doc_path.name, doc_hash, allow_override=allow_override)
        registry.save()
    return document


def _resolve_diagram_svg(options: PageOptions, search_paths) -> None:
    """Resolve diagram_svg paths relative to known input/image directories."""
    spec = getattr(options, "diagram_svg", None)
    if not spec or not getattr(spec, "src", None):
        return
    candidate_paths = list(search_paths) + [
        Path(p) for p in getattr(options, "image_paths", []) or []
    ]
    resolved = smart_file_resolve(Path(spec.src), candidate_paths)
    spec.src = resolved


def _build_metadata(yaml_file, yaml_data, extra_metadata, metadata_output_name):
    return Metadata(
        **{
            **{
                "output_name": metadata_output_name,
                "title": yaml_file.stem,
                "pn": yaml_file.stem,
                "company": "",
                "address": "",
            },
            **yaml_data.get("metadata", {}),
            **extra_metadata,
        }
    )


def _apply_document_to_harness(
    harness: Harness, document: DocumentRepresentation
) -> None:
    """Use a document representation to drive harness options/flags."""
    if not document:
        return

    doc_options = document.extras.get("options", {})
    if doc_options:
        logging.debug(
            "Applying document options from %s with keys: %s",
            getattr(document, "metadata", {}).get("output_name", "document"),
            ", ".join(sorted(doc_options.keys())),
        )
        options_data = dict(doc_options)
        if "color_output_mode" in options_data:
            com_value = options_data["color_output_mode"]
            if isinstance(com_value, str) and com_value.isdigit():
                com_value = int(com_value)
            try:
                options_data["color_output_mode"] = ColorOutputMode(com_value)
            except Exception as exc:
                logging.warning(
                    "Document options color_output_mode %r could not be parsed; "
                    "using it verbatim. Set a valid ColorOutputMode value to control output colors. error=%s",
                    com_value,
                    exc,
                )
                options_data["color_output_mode"] = com_value
        color_fields = [
            "bgcolor",
            "bgcolor_node",
            "bgcolor_connector",
            "bgcolor_cable",
            "bgcolor_bundle",
        ]
        for field in color_fields:
            value = options_data.get(field)
            if isinstance(value, dict):
                try:
                    options_data[field] = SingleColor(**value)
                except Exception as exc:
                    logging.warning(
                        "Document options %s=%r is invalid; ignoring override. "
                        "Use a valid SingleColor mapping to customize backgrounds. error=%s",
                        field,
                        value,
                        exc,
                    )
        int_fields = [
            "bom_rows",
            "titleblock_rows",
            "pad",
            "bom_rows_per_page",
            "cut_rows_per_page",
            "termination_rows_per_page",
        ]
        for field in int_fields:
            value = options_data.get(field)
            if isinstance(value, str) and value.isdigit():
                options_data[field] = int(value)
        if hasattr(harness.options, "model_copy"):
            harness.options = harness.options.model_copy(update=options_data)
        else:
            harness.options = PageOptions(**options_data)

    page_types = {getattr(page, "type", None) for page in document.pages}
    if page_types:
        harness.options.include_bom = PageType.bom in page_types
        harness.options.show_bom = harness.options.include_bom
        harness.options.include_cut_diagram = PageType.cut in page_types
        harness.options.include_termination_diagram = PageType.termination in page_types


def _collect_formats_from_document(
    document: Optional[DocumentRepresentation],
) -> Tuple[str, ...]:
    """Collect requested output formats from a document representation."""
    if not document:
        return ()
    formats: List[str] = []
    for page in document.pages:
        for fmt in getattr(page, "formats", []) or []:
            if fmt not in formats:
                formats.append(fmt)
    return tuple(formats)


def _collect_templates(yaml_data, image_paths):
    template_connectors = {}
    template_cables = {}
    connection_sets = []
    sections = ["connectors", "cables", "connections"]
    types = [dict, dict, list]
    for sec, ty in zip(sections, types):
        if sec in yaml_data and type(yaml_data[sec]) == ty:
            if len(yaml_data[sec]) > 0:
                if ty == dict:
                    for key, attribs in yaml_data[sec].items():
                        image = attribs.get("image")
                        if isinstance(image, dict):
                            image_path = Path(image["src"])
                            if image_path and not image_path.is_absolute():
                                attribs["image"]["src"] = smart_file_resolve(
                                    image_path, image_paths
                                )
                        if sec == "connectors":
                            template_connectors[key] = attribs
                        elif sec == "cables":
                            template_cables[key] = attribs
            else:
                pass
        else:
            if ty == dict:
                yaml_data[sec] = {}
            elif ty == list:
                yaml_data[sec] = []
    connection_sets = yaml_data["connections"]
    return template_connectors, template_cables, connection_sets


def build_harness_from_models(
    connector_models: List[Union[ConnectorModel, Dict[str, Any]]],
    cable_models: List[Union[CableModel, Dict[str, Any]]],
    metadata: Metadata,
    options: PageOptions,
    notes: Notes = Notes([]),
    additional_bom: Optional[List[Union[ComponentModel, Dict[str, Any]]]] = None,
    connections: Optional[List[Union[Dict[str, Any], Any]]] = None,
) -> Harness:
    """Build a Harness from already-instantiated connector/cable models."""
    harness = Harness(metadata=metadata, options=options, notes=notes)
    for connector in connector_models:
        harness.add_connector_model(connector)
    for cable in cable_models:
        harness.add_cable_model(cable)
    for item in additional_bom or []:
        harness.add_additional_bom_item(item)
    for connection in connections or []:
        harness.connect_model(connection)
    harness.populate_bom()
    return harness


def _normalize_connection_set(
    connection_set,
    template_separator_char,
    designators_and_templates,
    autogenerated_designators,
):
    def resolve_designator(inp, separator, idx=None):
        if separator in inp:
            if inp.count(separator) > 1:
                raise MultipleSeparatorError(inp, separator, idx=idx)
            template, designator = inp.split(separator)
            if designator == "":
                autogenerated_designators[template] = (
                    autogenerated_designators.get(template, 0) + 1
                )
                designator = (
                    f"{AUTOGENERATED_PREFIX}"
                    f"{template}_{autogenerated_designators[template]}"
                )
            if designator in designators_and_templates:
                if designators_and_templates[designator] != template:
                    raise RedefinedDesignatorError(
                        designator, designators_and_templates[designator], template
                    )
            else:
                designators_and_templates[designator] = template
        else:
            template, designator = (inp, inp)
            if designator in designators_and_templates:
                pass
            else:
                designators_and_templates[designator] = template
        return (template, designator)

    connectioncount = []
    for idx, entry in enumerate(connection_set):
        if isinstance(entry, list):
            connectioncount.append(len(entry))
        elif isinstance(entry, dict):
            connectioncount.append(len(expand(list(entry.values())[0])))

    if not any(connectioncount):
        raise MissingConnectionCountError()

    if len(set(connectioncount)) > 1:
        pretty_sets = [
            f'length {len(v)}: "{k}": {v}' for s in connection_set for k, v in s.items()
        ]
        prettyer_sets = "\n\t" + "\n\t".join(pretty_sets)
        raise ConnectionCountMismatchError(prettyer_sets)

    connectioncount = connectioncount[0]

    for index, entry in enumerate(connection_set):
        if isinstance(entry, str):
            connection_set[index] = [entry] * connectioncount

    for index, entry in enumerate(connection_set):
        if isinstance(entry, list):
            for subindex, item in enumerate(entry):
                template, designator = resolve_designator(
                    item, template_separator_char, idx=subindex
                )
                connection_set[index][subindex] = designator
        elif isinstance(entry, dict):
            key = list(entry.keys())[0]
            template, designator = resolve_designator(
                key, template_separator_char, idx=index
            )
            value = entry[key]
            connection_set[index] = {designator: value}

    for index, entry in enumerate(connection_set):
        if isinstance(entry, list):
            connection_set[index] = [{designator: 1} for designator in entry]
        elif isinstance(entry, dict):
            designator = list(entry.keys())[0]
            pinlist = expand(entry[designator])
            connection_set[index] = [{designator: pin} for pin in pinlist]

    return connection_set, connectioncount


def build_harness_from_files(
    inp: Sequence[Path],
    metadata_files: Sequence[Path],
    return_types: Union[None, str, Sequence[str]] = None,
    output_formats: Union[None, str, Sequence[str]] = None,
    output_dir: Optional[Path] = None,
    extra_metadata: Dict = {},
    shared_bom: Dict = {},
    output_name_override: Optional[str] = None,
    connector_view: str = "detailed",
    metadata_output_name: Optional[str] = None,
    update_shared_bom: bool = True,
) -> Any:
    if not output_formats and not return_types:
        raise MissingOutputSpecification()

    yaml_file = inp[-1]
    concatenated_files: List[Path] = list(inp)
    metadata_file_list: List[Path] = list(metadata_files)
    yaml_data = parse_concat_merge_files(concatenated_files, metadata_file_list)

    image_paths = {f.parent for f in inp if f.parent.is_dir()}

    output_dir = yaml_file.parent if not output_dir else output_dir
    if output_dir is None:
        raise FilareFlowException("Output directory could not be resolved")
    output_name = output_name_override if output_name_override else yaml_file.stem
    metadata_output_name = metadata_output_name or output_name
    doc_yaml_path: Path = output_dir / f"{output_name}.document.yaml"
    hash_registry_path: Path = output_dir / "document_hashes.yaml"

    try:
        metadata = _build_metadata(
            yaml_file, yaml_data, extra_metadata, metadata_output_name
        )
    except TypeError as exc:
        if output_formats:
            raise FilareFlowException(
                "Metadata definition is missing an argument, refer to trace for which one; see src/filare/metadata.py for field definitions"
            ) from exc
        raise

    harness = Harness(
        metadata=metadata,
        options=get_page_options(yaml_data, output_name),
        notes=get_page_notes(yaml_data, output_name),
        shared_bom=shared_bom,
    )
    _resolve_diagram_svg(harness.options, list(image_paths))
    designators_and_templates = {}
    autogenerated_designators = {}

    template_connectors, template_cables, connection_sets = _collect_templates(
        yaml_data, image_paths
    )

    alternating_types = ["connector", "cable"]
    expected_type: Optional[str] = None

    def check_type(designator, template, actual_type):
        nonlocal expected_type
        if expected_type is None:
            expected_type = actual_type
            return
        if actual_type != expected_type:
            raise ComponentTypeMismatch(
                expected_type, designator, template, actual_type
            )

    def alternate_type():
        nonlocal expected_type
        if expected_type is None:
            expected_type = alternating_types[0]
            return
        expected_type = alternating_types[1 - alternating_types.index(expected_type)]

    template_separator_char = harness.options.template_separator

    for connection_set in connection_sets:
        connection_set, connectioncount = _normalize_connection_set(
            connection_set,
            template_separator_char,
            designators_and_templates,
            autogenerated_designators,
        )

        expected_type = None

        for entry in connection_set:
            for item in entry:
                designator = list(item.keys())[0]
                template = designators_and_templates[designator]

                if designator in harness.connectors:
                    check_type(designator, template, "connector")
                elif template in template_connectors.keys():
                    check_type(designator, template, "connector")
                    harness.add_connector(
                        designator=designator, **template_connectors[template]
                    )

                elif designator in harness.cables:
                    check_type(designator, template, "cable")
                elif template in template_cables.keys():
                    check_type(designator, template, "cable")
                    harness.add_cable(
                        designator=designator, **template_cables[template]
                    )
                else:
                    known_connectors = ", ".join(sorted(template_connectors))
                    known_cables = ", ".join(sorted(template_cables))
                    raise UnknownTemplateDesignator(
                        template,
                        known_connectors=known_connectors,
                        known_cables=known_cables,
                    )

            alternate_type()

        connection_set = list(map(list, zip(*connection_set)))

        for index_entry, entry in enumerate(connection_set):
            for index_item, item in enumerate(entry):
                designator = list(item.keys())[0]

                if designator in harness.cables:
                    if index_item == 0:
                        from_name, from_pin = (None, None)
                    else:
                        from_name, from_pin = get_single_key_and_value(
                            entry[index_item - 1]
                        )
                    via_name, via_pin = (designator, item[designator])
                    if index_item == len(entry) - 1:
                        to_name, to_pin = (None, None)
                    else:
                        to_name, to_pin = get_single_key_and_value(
                            entry[index_item + 1]
                        )
                    harness.connect(
                        str(from_name or ""),
                        str(from_pin or ""),
                        str(via_name or ""),
                        str(via_pin or ""),
                        str(to_name or ""),
                        str(to_pin or ""),
                    )

    if "additional_bom_items" in yaml_data:
        for line in yaml_data["additional_bom_items"]:
            try:
                harness.add_additional_bom_item(line)
            except TypeError as e:
                logging.error(f"Failed to add line {line} as an additional bom item")
                raise

    if connector_view == "overview":
        harness.orient_connectors_overview()

    if connector_view == "overview":
        for connector in harness.connectors.values():
            if connector.style != "simple":
                connector.style = "overview"
        for cable in harness.cables.values():
            cable.style = "overview"

    harness.populate_bom()

    document_representation: Optional[DocumentRepresentation] = None
    registry = DocumentHashRegistry(hash_registry_path)
    registry.load()

    generate_document = bool(doc_yaml_path)
    if doc_yaml_path and doc_yaml_path.exists():
        document_representation = DocumentRepresentation.from_yaml(doc_yaml_path)
        if not registry.allow_override(doc_yaml_path.name):
            harness.document = document_representation
            _apply_document_to_harness(harness, document_representation)
            logging.warning(
                "Document representation locked (allow_override=False); using existing %s",
                doc_yaml_path,
            )
            generate_document = False
    if generate_document and doc_yaml_path:
        pages = [
            TitlePage(type=PageType.title, name="titlepage"),
            HarnessPage(
                type=PageType.harness,
                name=getattr(metadata, "name", output_name),
                formats=list(output_formats or []),
            ),
        ]
        if getattr(harness.options, "include_bom", True):
            pages.append(BOMPage(type=PageType.bom, name="bom", formats=["tsv"]))
        if getattr(harness.options, "include_cut_diagram", False):
            pages.append(CutPage(type=PageType.cut, name="cut"))
        if getattr(harness.options, "include_termination_diagram", False):
            pages.append(TerminationPage(type=PageType.termination, name="termination"))

        metadata_payload = _make_jsonable(
            metadata.model_dump() if hasattr(metadata, "model_dump") else {}
        )
        if not isinstance(metadata_payload, dict):
            metadata_payload = {}
        options_payload = (
            _make_jsonable(harness.options.model_dump(mode="json"))
            if hasattr(harness.options, "model_dump")
            else {}
        )
        if not isinstance(options_payload, dict):
            options_payload = {}
        document_representation = DocumentRepresentation(
            metadata=metadata_payload,
            pages=pages,
            notes=str(harness.notes) if harness.notes else None,
            bom={} if not harness.options.include_bom else {},
            extras={"options": options_payload},
        )
        doc_hash = document_representation.compute_hash()
        document_representation = _maybe_write_document(
            doc_yaml_path, hash_registry_path, document_representation, doc_hash
        )
        harness.document = document_representation

    effective_output_formats: Tuple[str, ...] = tuple(output_formats or ())
    doc_formats = _collect_formats_from_document(document_representation)
    if doc_formats:
        merged_formats = list(effective_output_formats)
        for fmt in doc_formats:
            if fmt not in merged_formats:
                merged_formats.append(fmt)
        effective_output_formats = tuple(merged_formats)
    if not getattr(harness.options, "include_bom", True):
        effective_output_formats = tuple(
            fmt for fmt in effective_output_formats if fmt != "tsv"
        )

    if effective_output_formats:
        render_harness_outputs(
            harness, output_dir, output_name, effective_output_formats
        )

    if return_types:
        if isinstance(return_types, str):
            return_types = [return_types]

        return_types = [t.lower() for t in return_types]

        returns = {}
        for rt in return_types:
            if rt == "png":
                returns["png"] = harness.png
            if rt == "svg":
                returns["svg"] = harness.svg
            if rt == "harness":
                returns["harness"] = harness
            if rt == "shared_bom":
                returns["shared_bom"] = harness.shared_bom
            if rt in ("document", "doc"):
                returns["document"] = (
                    document_representation or DocumentRepresentation()
                )

        return returns
